From 09695ad093c7be6695b7f8109b6a1b8a88ab5427 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Wed, 13 May 2026 02:25:13 -0400 Subject: [PATCH 01/68] cli: add helper script for adding packages --- .husky/commit-msg | 4 +- cli/package.json | 4 +- cli/scripts/add-package.tsx | 859 ++++++++++++++++++++++++++++++++++++ cli/scripts/tsconfig.json | 7 + package-lock.json | 508 ++++++++++++++++++++- package.json | 1 + 6 files changed, 1378 insertions(+), 5 deletions(-) create mode 100644 cli/scripts/add-package.tsx create mode 100644 cli/scripts/tsconfig.json diff --git a/.husky/commit-msg b/.husky/commit-msg index e89aef78..16c5abd4 100644 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -5,7 +5,7 @@ message_file="$1" subject="$(sed -n '1p' "$message_file")" case "$subject" in - ci:*|cli:*|app:*|lua:*|tauri:*|feather:*|docs:*) + ci:*|cli:*|package:*|plugin:*|app:*|lua:*|tauri:*|feather:*|docs:*) exit 0 ;; esac @@ -16,6 +16,8 @@ cat <<'EOF' Commit message must start with one of: ci: cli: + package: + plugin: app: lua: tauri: diff --git a/cli/package.json b/cli/package.json index d352b893..2618b80c 100644 --- a/cli/package.json +++ b/cli/package.json @@ -36,7 +36,8 @@ "prepack": "npm run bundle:lua && npm run build", "typecheck": "tsc --noEmit", "test": "node --test test/package.test.mjs", - "test:e2e": "npm run build && node --test test/package.test.mjs" + "test:e2e": "npm run build && node --test test/package.test.mjs", + "package:add": "tsx --tsconfig scripts/tsconfig.json scripts/add-package.tsx" }, "dependencies": { "chalk": "^5.3.0", @@ -47,6 +48,7 @@ }, "devDependencies": { "@types/node": "^24.2.0", + "tsx": "^4.21.0", "typescript": "~5.6.2" }, "engines": { diff --git a/cli/scripts/add-package.tsx b/cli/scripts/add-package.tsx new file mode 100644 index 00000000..e4d4e3b3 --- /dev/null +++ b/cli/scripts/add-package.tsx @@ -0,0 +1,859 @@ +#!/usr/bin/env node +/** + * Interactive wizard to add a new package to the Feather catalog. + * + * Usage (from repo root): + * npm run package:add + * + * Steps: + * 1. Package ID + * 2. GitHub repo (owner/name) + * 3. Select release tag (fetched from GitHub API) + * 4. Trust level (verified / known) + * 5. Description + * 6. Tags (comma-separated) + * 7. Select .lua files (fetched from GitHub tree API) + * 8. Set install target for each file + * 9. Require path + * 10. Compute SHA-256 checksums + * 11. Review & write packages/.json + * 12. Regenerate registry + */ + +import { render, Text, Box, useInput, useApp } from 'ink'; +import { useState, useEffect } from 'react'; +import { createHash } from 'node:crypto'; +import { existsSync, writeFileSync } from 'node:fs'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, '../..'); +const packagesDir = join(root, 'packages'); + +type Step = + | 'id' + | 'repo' + | 'fetch-tags' + | 'tag' + | 'trust' + | 'description' + | 'pkg-tags' + | 'fetch-files' + | 'files' + | 'targets' + | 'require' + | 'fetch-checksums' + | 'review' + | 'write' + | 'done' + | 'error'; + +interface FileEntry { + name: string; + target: string; + sha256: string; +} + +interface FormData { + id: string; + repo: string; + homepage: string; + tag: string; + baseUrl: string; + trust: 'verified' | 'known'; + description: string; + tags: string[]; + selectedFiles: string[]; + targetMap: Record; + require: string; + example: string; + files: FileEntry[]; +} + +function Header({ step, total }: { step: number; total: number }) { + return ( + + + {' feather package:add'} + + {` Step ${step} of ${total}`} + + ); +} + +function Hint({ children }: { children: string }) { + return ( + + {' '} + {children} + + ); +} + +interface TextInputStepProps { + stepNum: number; + total: number; + label: string; + hint?: string; + defaultValue?: string; + validate?: (v: string) => string | null; + onSubmit: (value: string) => void; +} + +function TextInputStep({ stepNum, total, label, hint, defaultValue = '', validate, onSubmit }: TextInputStepProps) { + const [value, setValue] = useState(defaultValue); + const [error, setError] = useState(null); + + useInput((input, key) => { + if (key.return) { + const err = validate ? validate(value.trim()) : null; + if (err) { + setError(err); + return; + } + onSubmit(value.trim()); + return; + } + if (key.backspace || key.delete) { + setValue((v) => v.slice(0, -1)); + setError(null); + return; + } + if (key.ctrl || key.meta) return; + if (input) { + setValue((v) => v + input); + setError(null); + } + }); + + return ( + +
+ + {' '} + {label} + + {hint && {hint}} + + {' '} + {value} + + + {error && ( + + + {' ✖ '} + {error} + + + )} + + {' Enter to confirm'} + + + ); +} + +interface SelectStepProps { + stepNum: number; + total: number; + label: string; + hint?: string; + options: string[]; + onSelect: (value: string) => void; +} + +function SelectStep({ stepNum, total, label, hint, options, onSelect }: SelectStepProps) { + const [cursor, setCursor] = useState(0); + + useInput((_, key) => { + if (key.upArrow) setCursor((c) => Math.max(0, c - 1)); + if (key.downArrow) setCursor((c) => Math.min(options.length - 1, c + 1)); + if (key.return) onSelect(options[cursor]!); + }); + + return ( + +
+ + {' '} + {label} + + {hint && {hint}} + + {options.map((opt, i) => ( + + + {' '} + {i === cursor ? '❯ ' : ' '} + {opt} + + + ))} + + + {' ↑↓ navigate · Enter to select'} + + + ); +} + +interface MultiSelectStepProps { + stepNum: number; + total: number; + label: string; + hint?: string; + options: string[]; + onSubmit: (selected: string[]) => void; +} + +function MultiSelectStep({ stepNum, total, label, hint, options, onSubmit }: MultiSelectStepProps) { + const [cursor, setCursor] = useState(0); + const [selected, setSelected] = useState>(new Set(options.map((_, i) => i))); + + useInput((input, key) => { + if (key.upArrow) setCursor((c) => Math.max(0, c - 1)); + if (key.downArrow) setCursor((c) => Math.min(options.length - 1, c + 1)); + if (input === ' ') { + setSelected((s) => { + const next = new Set(s); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + next.has(cursor) ? next.delete(cursor) : next.add(cursor); + return next; + }); + } + if (key.return) { + const chosen = options.filter((_, i) => selected.has(i)); + if (chosen.length > 0) onSubmit(chosen); + } + }); + + return ( + +
+ + {' '} + {label} + + {hint && {hint}} + + {options.map((opt, i) => ( + + + {' '} + {i === cursor ? '❯ ' : ' '} + {selected.has(i) ? '◉ ' : '○ '} + {opt} + + + ))} + + + {' ↑↓ navigate · Space toggle · Enter confirm'} + + + ); +} + +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +function Spinner({ label }: { label: string }) { + const [frame, setFrame] = useState(0); + + useEffect(() => { + const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 80); + return () => clearInterval(id); + }, []); + + return ( + + {SPINNER_FRAMES[frame]} + {label} + + ); +} + +interface AutoStepProps { + label: string; + run: () => Promise; + onError: (msg: string) => void; +} + +function AutoStep({ label, run, onError }: AutoStepProps) { + const [status, setStatus] = useState<'running' | 'done' | 'error'>('running'); + const [error, setError] = useState(''); + + useEffect(() => { + run() + .then(() => setStatus('done')) + .catch((err: Error) => { + setError(err.message); + setStatus('error'); + onError(err.message); + }); + }, []); + + return ( + + {status === 'running' && } + {status === 'done' && ✔ {label}} + {status === 'error' && ✖ {error}} + + ); +} + +interface TargetsStepProps { + stepNum: number; + total: number; + id: string; + files: string[]; + onSubmit: (targets: Record) => void; +} + +function TargetsStep({ stepNum, total, id, files, onSubmit }: TargetsStepProps) { + const defaultTarget = (filename: string): string => { + if (files.length === 1) return `lib/${filename}`; + return `lib/${id}/${filename}`; + }; + + const [index, setIndex] = useState(0); + const [targets, setTargets] = useState>( + Object.fromEntries(files.map((f) => [f, defaultTarget(f)])), + ); + const [current, setCurrent] = useState(defaultTarget(files[0]!)); + const [error, setError] = useState(null); + + const file = files[index]!; + + const advance = () => { + const val = current.trim(); + if (!val) { + setError('Target path is required'); + return; + } + if (!val.endsWith('.lua')) { + setError('Target path must end in .lua'); + return; + } + const next = { ...targets, [file]: val }; + setTargets(next); + if (index + 1 < files.length) { + const nextFile = files[index + 1]!; + setIndex(index + 1); + setCurrent(next[nextFile] ?? defaultTarget(nextFile)); + setError(null); + } else { + onSubmit(next); + } + }; + + useInput((input, key) => { + if (key.return) { + advance(); + return; + } + if (key.backspace || key.delete) { + setCurrent((v) => v.slice(0, -1)); + setError(null); + return; + } + if (key.ctrl || key.meta) return; + if (input) { + setCurrent((v) => v + input); + setError(null); + } + }); + + return ( + +
+ {' '}Install target paths + {`File ${index + 1} of ${files.length}: ${file}`} + + {' '} + {current} + + + {error && ( + + + {' ✖ '} + {error} + + + )} + + {' Enter to confirm path'} + + + ); +} + +interface ReviewStepProps { + stepNum: number; + total: number; + json: string; + onConfirm: () => void; + onAbort: () => void; +} + +function ReviewStep({ stepNum, total, json, onConfirm, onAbort }: ReviewStepProps) { + useInput((input, key) => { + if (key.return || input === 'y' || input === 'Y') onConfirm(); + if (input === 'n' || input === 'N' || key.escape) onAbort(); + }); + + return ( + +
+ {' '}Review generated package + + {json.split('\n').map((line, i) => ( + + {' '} + {line} + + ))} + + + {' '}y/Enter to write · n/Esc to abort + + + ); +} + +interface ChecksumStepProps { + files: Array<{ name: string; url: string }>; + onDone: (checksums: Record) => void; + onError: (msg: string) => void; +} + +function ChecksumStep({ files, onDone, onError }: ChecksumStepProps) { + const [done, setDone] = useState>({}); + const [current, setCurrent] = useState(files[0]?.name ?? ''); + + useEffect(() => { + const run = async () => { + const results: Record = {}; + for (const f of files) { + setCurrent(f.name); + const res = await fetch(f.url); + if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${f.url}`); + const buf = await res.arrayBuffer(); + const hash = createHash('sha256').update(Buffer.from(buf)).digest('hex'); + results[f.name] = hash; + setDone((d) => ({ ...d, [f.name]: hash })); + } + onDone(results); + }; + run().catch((err: Error) => onError(err.message)); + }, []); + + return ( + + Computing SHA-256 checksums… + {files.map((f) => { + const sha = done[f.name]; + const isActive = f.name === current && !sha; + return ( + + {sha ? ( + + {' ✔ '} + {f.name} + + ) : isActive ? ( + + {' '} + + + ) : ( + + {' ○ '} + {f.name} + + )} + + ); + })} + + ); +} + +async function fetchTags(repo: string): Promise { + const res = await fetch(`https://api.github.com/repos/${repo}/tags?per_page=20`, { + headers: { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }, + }); + if (!res.ok) throw new Error(`GitHub API ${res.status} for ${repo}`); + const data = (await res.json()) as Array<{ name: string }>; + return data.map((t) => t.name); +} + +async function fetchLuaFiles(repo: string, tag: string): Promise { + const res = await fetch(`https://api.github.com/repos/${repo}/git/trees/${tag}?recursive=1`, { + headers: { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }, + }); + if (!res.ok) throw new Error(`GitHub API ${res.status} for ${repo}@${tag}`); + const data = (await res.json()) as { tree: Array<{ path: string; type: string }> }; + return data.tree + .filter((node) => node.type === 'blob' && node.path.endsWith('.lua')) + .map((node) => node.path) + .sort(); +} + +function DoneStep({ id, onExit }: { id: string; onExit: () => void }) { + useEffect(() => { + const timer = setTimeout(onExit, 300); + return () => clearTimeout(timer); + }, [onExit]); + + return ( + + + ✔ Done! + + + {' '}packages/{id}.json written + + {' '}Registry regenerated + + Commit packages/{id}.json and cli/src/generated/registry.json + + + Then push — GitHub Actions will publish the registry to the packages branch. + + + ); +} + +const TOTAL_STEPS = 12; + +function Wizard() { + const { exit } = useApp(); + const [step, setStep] = useState('id'); + const [data, setData] = useState>({}); + const [fetchedTags, setFetchedTags] = useState([]); + const [fetchedFiles, setFetchedFiles] = useState([]); + const [errorMsg, setErrorMsg] = useState(''); + const [outputJson, setOutputJson] = useState(''); + + const handleError = (msg: string) => { + setErrorMsg(msg); + setStep('error'); + }; + + // Step 1: package ID + if (step === 'id') { + return ( + { + if (!v) return 'Required'; + if (!/^[a-z0-9][a-z0-9.-]*$/.test(v)) return 'Use only a-z, 0-9, dots, hyphens'; + if (existsSync(join(packagesDir, `${v}.json`))) return `packages/${v}.json already exists`; + return null; + }} + onSubmit={(id) => { + setData((d) => ({ ...d, id })); + setStep('repo'); + }} + /> + ); + } + + // Step 2: GitHub repo + if (step === 'repo') { + return ( + { + if (!v) return 'Required'; + if (!/^[^/]+\/[^/]+$/.test(v)) return 'Must be owner/repo format'; + return null; + }} + onSubmit={(repo) => { + setData((d) => ({ + ...d, + repo, + homepage: `https://github.com/${repo}`, + })); + setStep('fetch-tags'); + }} + /> + ); + } + + // Step 3: fetch tags + if (step === 'fetch-tags') { + return ( + { + const tags = await fetchTags(data.repo!); + if (tags.length === 0) throw new Error('No tags found. Push a release tag first.'); + setFetchedTags(tags); + setStep('tag'); + }} + onError={handleError} + /> + ); + } + + // Step 4: select tag + if (step === 'tag') { + return ( + { + const baseUrl = `https://raw.githubusercontent.com/${data.repo}/${tag}/`; + setData((d) => ({ ...d, tag, baseUrl })); + setStep('trust'); + }} + /> + ); + } + + // Step 5: trust level + if (step === 'trust') { + return ( + { + setData((d) => ({ ...d, trust: trust as 'verified' | 'known' })); + setStep('description'); + }} + /> + ); + } + + // Step 6: description + if (step === 'description') { + return ( + (v ? null : 'Required')} + onSubmit={(description) => { + setData((d) => ({ ...d, description })); + setStep('pkg-tags'); + }} + /> + ); + } + + // Step 7: tags + if (step === 'pkg-tags') { + return ( + (v ? null : 'Required')} + onSubmit={(tagStr) => { + const tags = tagStr + .split(',') + .map((t) => t.trim()) + .filter(Boolean); + setData((d) => ({ ...d, tags })); + setStep('fetch-files'); + }} + /> + ); + } + + // Step 8: fetch file tree + if (step === 'fetch-files') { + return ( + { + const files = await fetchLuaFiles(data.repo!, data.tag!); + if (files.length === 0) throw new Error('No .lua files found at this tag.'); + setFetchedFiles(files); + setStep('files'); + }} + onError={handleError} + /> + ); + } + + // Step 9: multi-select files + if (step === 'files') { + return ( + { + setData((d) => ({ ...d, selectedFiles })); + setStep('targets'); + }} + /> + ); + } + + // Step 10: target paths + if (step === 'targets') { + return ( + { + setData((d) => ({ ...d, targetMap })); + setStep('require'); + }} + /> + ); + } + + // Step 11: require path + if (step === 'require') { + const targets = Object.values(data.targetMap ?? {}); + const firstTarget = targets[0] ?? `lib/${data.id}.lua`; + const suggested = firstTarget.replace(/\.lua$/, '').replace(/\//g, '.'); + return ( + (v ? null : 'Required')} + onSubmit={(req) => { + const example = `local ${data.id!.replace(/[.-]/g, '_')} = require('${req}')`; + setData((d) => ({ ...d, require: req, example })); + setStep('fetch-checksums'); + }} + /> + ); + } + + // Step 12: compute checksums + if (step === 'fetch-checksums') { + const fileList = (data.selectedFiles ?? []).map((name) => ({ + name, + url: data.baseUrl! + name, + })); + return ( + { + const files: FileEntry[] = (data.selectedFiles ?? []).map((name) => ({ + name, + target: data.targetMap![name]!, + sha256: checksums[name]!, + })); + const fullData = { ...data, files } as FormData; + setData(fullData); + + const pkg = { + type: 'love2d-library', + trust: fullData.trust, + description: fullData.description, + tags: fullData.tags, + homepage: fullData.homepage, + source: { + repo: fullData.repo, + tag: fullData.tag, + baseUrl: fullData.baseUrl, + }, + install: { + files: files.map((f) => ({ + name: f.name, + sha256: f.sha256, + target: f.target, + })), + }, + require: fullData.require, + example: fullData.example, + }; + setOutputJson(JSON.stringify(pkg, null, 2)); + setStep('review'); + }} + onError={handleError} + /> + ); + } + + // Step 13: review & confirm + if (step === 'review') { + return ( + setStep('write')} + onAbort={() => { + setErrorMsg('Aborted by user.'); + setStep('error'); + }} + /> + ); + } + + // Step 14: write + regenerate + if (step === 'write') { + return ( + { + const outPath = join(packagesDir, `${data.id}.json`); + writeFileSync(outPath, outputJson + '\n', 'utf8'); + + const genScript = join(root, 'scripts', 'generate-registry.mjs'); + const result = spawnSync(process.execPath, [genScript], { + cwd: root, + stdio: 'pipe', + encoding: 'utf8', + }); + if (result.status !== 0) { + throw new Error(result.stderr || 'generate-registry.mjs failed'); + } + setStep('done'); + }} + onError={handleError} + /> + ); + } + + // Done + if (step === 'done') { + return ; + } + + // Error + return ( + + + ✖ Error + + {errorMsg} + + ); +} + +const { waitUntilExit } = render(); +await waitUntilExit(); diff --git a/cli/scripts/tsconfig.json b/cli/scripts/tsconfig.json new file mode 100644 index 00000000..69aec6df --- /dev/null +++ b/cli/scripts/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["."] +} diff --git a/package-lock.json b/package-lock.json index 70025fd8..5e9d3109 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "feather", - "version": "0.9.3", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "feather", - "version": "0.9.3", + "version": "0.10.0", "workspaces": [ "cli" ], @@ -82,7 +82,7 @@ }, "cli": { "name": "@kyonru/feather", - "version": "0.9.3", + "version": "0.10.0", "license": "EPL-2.0", "dependencies": { "chalk": "^5.3.0", @@ -96,6 +96,7 @@ }, "devDependencies": { "@types/node": "^24.2.0", + "tsx": "^4.21.0", "typescript": "~5.6.2" }, "engines": { @@ -5639,6 +5640,19 @@ "node": ">=6" } }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/gif.js": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/gif.js/-/gif.js-0.2.0.tgz", @@ -7186,6 +7200,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -7597,6 +7621,484 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, "node_modules/tw-animate-css": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.3.6.tgz", diff --git a/package.json b/package.json index 57ffa05d..fb926ab3 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "typecheck": "npm run typecheck:web && npm run typecheck:lua", "generate:plugin-catalog": "node scripts/generate-plugin-catalog.mjs", "check:plugin-catalog": "npm run generate:plugin-catalog && git diff --exit-code cli/src/generated/plugin-catalog.ts", + "package:add": "npm run package:add --workspace=cli", "generate:registry": "node scripts/generate-registry.mjs", "check:registry": "npm run generate:registry && git diff --exit-code cli/src/generated/registry.json", "verify:packages": "node scripts/compute-checksums.mjs --all", From 019d7d1176c0096eae7addb265c8170dc6cf8b54 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Wed, 13 May 2026 02:25:38 -0400 Subject: [PATCH 02/68] package: add shove --- cli/src/generated/registry.json | 43 ++++++++++++++++++++++++++++++++- packages/shove.json | 41 +++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 packages/shove.json diff --git a/cli/src/generated/registry.json b/cli/src/generated/registry.json index d3db219b..7ed7477c 100644 --- a/cli/src/generated/registry.json +++ b/cli/src/generated/registry.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-05-12", + "updatedAt": "2026-05-13", "packages": { "anim8": { "type": "love2d-library", @@ -452,6 +452,47 @@ "require": "lib.push", "example": "local push = require('lib.push')" }, + "shove": { + "type": "love2d-library", + "trust": "verified", + "description": "A powerful resolution-handler and rendering library for LÖVE 📐", + "tags": [ + "resolution", + "gamedev", + "performance", + "lua", + "rendering", + "aspect-ratio", + "game-development", + "love2d", + "layers", + "pixel-perfect", + "scaling", + "love2d-framework" + ], + "homepage": "https://github.com/oval-tutu/shove", + "source": { + "repo": "oval-tutu/shove", + "tag": "1.0.6", + "baseUrl": "https://raw.githubusercontent.com/oval-tutu/shove/1.0.6/" + }, + "install": { + "files": [ + { + "name": "shove-profiler.lua", + "sha256": "8c2c78a0d4dac5afaa3e487648c1e12fad632bd8971dd21424da765ac0135e88", + "target": "lib/shove/profiler.lua" + }, + { + "name": "shove.lua", + "sha256": "151b3bb65aa33114189bd7a2abe23b61acb58bd0530e4a3a92531ee0502e4903", + "target": "lib/shove/init.lua" + } + ] + }, + "require": "lib.shove", + "example": "local shove = require('lib.shove')" + }, "sti": { "type": "love2d-library", "trust": "known", diff --git a/packages/shove.json b/packages/shove.json new file mode 100644 index 00000000..2c6b806d --- /dev/null +++ b/packages/shove.json @@ -0,0 +1,41 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "A powerful resolution-handler and rendering library for LÖVE 📐", + "tags": [ + "resolution", + "gamedev", + "performance", + "lua", + "rendering", + "aspect-ratio", + "game-development", + "love2d", + "layers", + "pixel-perfect", + "scaling", + "love2d-framework" + ], + "homepage": "https://github.com/oval-tutu/shove", + "source": { + "repo": "oval-tutu/shove", + "tag": "1.0.6", + "baseUrl": "https://raw.githubusercontent.com/oval-tutu/shove/1.0.6/" + }, + "install": { + "files": [ + { + "name": "shove-profiler.lua", + "sha256": "8c2c78a0d4dac5afaa3e487648c1e12fad632bd8971dd21424da765ac0135e88", + "target": "lib/shove/profiler.lua" + }, + { + "name": "shove.lua", + "sha256": "151b3bb65aa33114189bd7a2abe23b61acb58bd0530e4a3a92531ee0502e4903", + "target": "lib/shove/init.lua" + } + ] + }, + "require": "lib.shove", + "example": "local shove = require('lib.shove')" +} From 6c76ea2a45bef564c5c99764c2d895c654014fad Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Wed, 13 May 2026 18:00:35 -0400 Subject: [PATCH 03/68] package: improve dev management --- cli/package.json | 4 +- cli/scripts/add-package.tsx | 635 ++++---------------------------- cli/scripts/remove-package.tsx | 124 +++++++ cli/scripts/update-package.tsx | 361 ++++++++++++++++++ cli/scripts/wizard-shared.tsx | 510 +++++++++++++++++++++++++ cli/src/commands/package.ts | 1 + cli/src/lib/package/registry.ts | 1 + cli/src/ui/package-workflow.tsx | 1 + package.json | 2 + scripts/generate-registry.mjs | 2 + 10 files changed, 1077 insertions(+), 564 deletions(-) create mode 100644 cli/scripts/remove-package.tsx create mode 100644 cli/scripts/update-package.tsx create mode 100644 cli/scripts/wizard-shared.tsx diff --git a/cli/package.json b/cli/package.json index 2618b80c..3ab5e93c 100644 --- a/cli/package.json +++ b/cli/package.json @@ -37,7 +37,9 @@ "typecheck": "tsc --noEmit", "test": "node --test test/package.test.mjs", "test:e2e": "npm run build && node --test test/package.test.mjs", - "package:add": "tsx --tsconfig scripts/tsconfig.json scripts/add-package.tsx" + "package:add": "tsx --tsconfig scripts/tsconfig.json scripts/add-package.tsx", + "package:update": "tsx --tsconfig scripts/tsconfig.json scripts/update-package.tsx", + "package:remove": "tsx --tsconfig scripts/tsconfig.json scripts/remove-package.tsx" }, "dependencies": { "chalk": "^5.3.0", diff --git a/cli/scripts/add-package.tsx b/cli/scripts/add-package.tsx index e4d4e3b3..a1ae8cd2 100644 --- a/cli/scripts/add-package.tsx +++ b/cli/scripts/add-package.tsx @@ -4,33 +4,31 @@ * * Usage (from repo root): * npm run package:add - * - * Steps: - * 1. Package ID - * 2. GitHub repo (owner/name) - * 3. Select release tag (fetched from GitHub API) - * 4. Trust level (verified / known) - * 5. Description - * 6. Tags (comma-separated) - * 7. Select .lua files (fetched from GitHub tree API) - * 8. Set install target for each file - * 9. Require path - * 10. Compute SHA-256 checksums - * 11. Review & write packages/.json - * 12. Regenerate registry */ -import { render, Text, Box, useInput, useApp } from 'ink'; +import { render, Text, Box, useApp } from 'ink'; import { useState, useEffect } from 'react'; -import { createHash } from 'node:crypto'; import { existsSync, writeFileSync } from 'node:fs'; -import { join, dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const root = resolve(__dirname, '../..'); -const packagesDir = join(root, 'packages'); +import { + root, + packagesDir, + type FormData, + type FileEntry, + TextInputStep, + SelectStep, + MultiSelectStep, + AutoStep, + TargetsStep, + ReviewStep, + ChecksumStep, + fetchRepoMeta, + fetchLuaFiles, + buildPackageJson, +} from './wizard-shared.js'; + +// ─── Steps ──────────────────────────────────────────────────────────────────── type Step = | 'id' @@ -50,463 +48,15 @@ type Step = | 'done' | 'error'; -interface FileEntry { - name: string; - target: string; - sha256: string; -} - -interface FormData { - id: string; - repo: string; - homepage: string; - tag: string; - baseUrl: string; - trust: 'verified' | 'known'; - description: string; - tags: string[]; - selectedFiles: string[]; - targetMap: Record; - require: string; - example: string; - files: FileEntry[]; -} - -function Header({ step, total }: { step: number; total: number }) { - return ( - - - {' feather package:add'} - - {` Step ${step} of ${total}`} - - ); -} - -function Hint({ children }: { children: string }) { - return ( - - {' '} - {children} - - ); -} - -interface TextInputStepProps { - stepNum: number; - total: number; - label: string; - hint?: string; - defaultValue?: string; - validate?: (v: string) => string | null; - onSubmit: (value: string) => void; -} - -function TextInputStep({ stepNum, total, label, hint, defaultValue = '', validate, onSubmit }: TextInputStepProps) { - const [value, setValue] = useState(defaultValue); - const [error, setError] = useState(null); - - useInput((input, key) => { - if (key.return) { - const err = validate ? validate(value.trim()) : null; - if (err) { - setError(err); - return; - } - onSubmit(value.trim()); - return; - } - if (key.backspace || key.delete) { - setValue((v) => v.slice(0, -1)); - setError(null); - return; - } - if (key.ctrl || key.meta) return; - if (input) { - setValue((v) => v + input); - setError(null); - } - }); - - return ( - -
- - {' '} - {label} - - {hint && {hint}} - - {' '} - {value} - - - {error && ( - - - {' ✖ '} - {error} - - - )} - - {' Enter to confirm'} - - - ); -} - -interface SelectStepProps { - stepNum: number; - total: number; - label: string; - hint?: string; - options: string[]; - onSelect: (value: string) => void; -} - -function SelectStep({ stepNum, total, label, hint, options, onSelect }: SelectStepProps) { - const [cursor, setCursor] = useState(0); - - useInput((_, key) => { - if (key.upArrow) setCursor((c) => Math.max(0, c - 1)); - if (key.downArrow) setCursor((c) => Math.min(options.length - 1, c + 1)); - if (key.return) onSelect(options[cursor]!); - }); - - return ( - -
- - {' '} - {label} - - {hint && {hint}} - - {options.map((opt, i) => ( - - - {' '} - {i === cursor ? '❯ ' : ' '} - {opt} - - - ))} - - - {' ↑↓ navigate · Enter to select'} - - - ); -} - -interface MultiSelectStepProps { - stepNum: number; - total: number; - label: string; - hint?: string; - options: string[]; - onSubmit: (selected: string[]) => void; -} - -function MultiSelectStep({ stepNum, total, label, hint, options, onSubmit }: MultiSelectStepProps) { - const [cursor, setCursor] = useState(0); - const [selected, setSelected] = useState>(new Set(options.map((_, i) => i))); - - useInput((input, key) => { - if (key.upArrow) setCursor((c) => Math.max(0, c - 1)); - if (key.downArrow) setCursor((c) => Math.min(options.length - 1, c + 1)); - if (input === ' ') { - setSelected((s) => { - const next = new Set(s); - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - next.has(cursor) ? next.delete(cursor) : next.add(cursor); - return next; - }); - } - if (key.return) { - const chosen = options.filter((_, i) => selected.has(i)); - if (chosen.length > 0) onSubmit(chosen); - } - }); - - return ( - -
- - {' '} - {label} - - {hint && {hint}} - - {options.map((opt, i) => ( - - - {' '} - {i === cursor ? '❯ ' : ' '} - {selected.has(i) ? '◉ ' : '○ '} - {opt} - - - ))} - - - {' ↑↓ navigate · Space toggle · Enter confirm'} - - - ); -} - -const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - -function Spinner({ label }: { label: string }) { - const [frame, setFrame] = useState(0); - - useEffect(() => { - const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 80); - return () => clearInterval(id); - }, []); - - return ( - - {SPINNER_FRAMES[frame]} - {label} - - ); -} - -interface AutoStepProps { - label: string; - run: () => Promise; - onError: (msg: string) => void; -} - -function AutoStep({ label, run, onError }: AutoStepProps) { - const [status, setStatus] = useState<'running' | 'done' | 'error'>('running'); - const [error, setError] = useState(''); - - useEffect(() => { - run() - .then(() => setStatus('done')) - .catch((err: Error) => { - setError(err.message); - setStatus('error'); - onError(err.message); - }); - }, []); - - return ( - - {status === 'running' && } - {status === 'done' && ✔ {label}} - {status === 'error' && ✖ {error}} - - ); -} - -interface TargetsStepProps { - stepNum: number; - total: number; - id: string; - files: string[]; - onSubmit: (targets: Record) => void; -} +const TITLE = 'feather package:add'; +const TOTAL = 11; -function TargetsStep({ stepNum, total, id, files, onSubmit }: TargetsStepProps) { - const defaultTarget = (filename: string): string => { - if (files.length === 1) return `lib/${filename}`; - return `lib/${id}/${filename}`; - }; - - const [index, setIndex] = useState(0); - const [targets, setTargets] = useState>( - Object.fromEntries(files.map((f) => [f, defaultTarget(f)])), - ); - const [current, setCurrent] = useState(defaultTarget(files[0]!)); - const [error, setError] = useState(null); - - const file = files[index]!; - - const advance = () => { - const val = current.trim(); - if (!val) { - setError('Target path is required'); - return; - } - if (!val.endsWith('.lua')) { - setError('Target path must end in .lua'); - return; - } - const next = { ...targets, [file]: val }; - setTargets(next); - if (index + 1 < files.length) { - const nextFile = files[index + 1]!; - setIndex(index + 1); - setCurrent(next[nextFile] ?? defaultTarget(nextFile)); - setError(null); - } else { - onSubmit(next); - } - }; - - useInput((input, key) => { - if (key.return) { - advance(); - return; - } - if (key.backspace || key.delete) { - setCurrent((v) => v.slice(0, -1)); - setError(null); - return; - } - if (key.ctrl || key.meta) return; - if (input) { - setCurrent((v) => v + input); - setError(null); - } - }); - - return ( - -
- {' '}Install target paths - {`File ${index + 1} of ${files.length}: ${file}`} - - {' '} - {current} - - - {error && ( - - - {' ✖ '} - {error} - - - )} - - {' Enter to confirm path'} - - - ); -} - -interface ReviewStepProps { - stepNum: number; - total: number; - json: string; - onConfirm: () => void; - onAbort: () => void; -} - -function ReviewStep({ stepNum, total, json, onConfirm, onAbort }: ReviewStepProps) { - useInput((input, key) => { - if (key.return || input === 'y' || input === 'Y') onConfirm(); - if (input === 'n' || input === 'N' || key.escape) onAbort(); - }); - - return ( - -
- {' '}Review generated package - - {json.split('\n').map((line, i) => ( - - {' '} - {line} - - ))} - - - {' '}y/Enter to write · n/Esc to abort - - - ); -} - -interface ChecksumStepProps { - files: Array<{ name: string; url: string }>; - onDone: (checksums: Record) => void; - onError: (msg: string) => void; -} - -function ChecksumStep({ files, onDone, onError }: ChecksumStepProps) { - const [done, setDone] = useState>({}); - const [current, setCurrent] = useState(files[0]?.name ?? ''); - - useEffect(() => { - const run = async () => { - const results: Record = {}; - for (const f of files) { - setCurrent(f.name); - const res = await fetch(f.url); - if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${f.url}`); - const buf = await res.arrayBuffer(); - const hash = createHash('sha256').update(Buffer.from(buf)).digest('hex'); - results[f.name] = hash; - setDone((d) => ({ ...d, [f.name]: hash })); - } - onDone(results); - }; - run().catch((err: Error) => onError(err.message)); - }, []); - - return ( - - Computing SHA-256 checksums… - {files.map((f) => { - const sha = done[f.name]; - const isActive = f.name === current && !sha; - return ( - - {sha ? ( - - {' ✔ '} - {f.name} - - ) : isActive ? ( - - {' '} - - - ) : ( - - {' ○ '} - {f.name} - - )} - - ); - })} - - ); -} - -async function fetchTags(repo: string): Promise { - const res = await fetch(`https://api.github.com/repos/${repo}/tags?per_page=20`, { - headers: { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }, - }); - if (!res.ok) throw new Error(`GitHub API ${res.status} for ${repo}`); - const data = (await res.json()) as Array<{ name: string }>; - return data.map((t) => t.name); -} - -async function fetchLuaFiles(repo: string, tag: string): Promise { - const res = await fetch(`https://api.github.com/repos/${repo}/git/trees/${tag}?recursive=1`, { - headers: { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }, - }); - if (!res.ok) throw new Error(`GitHub API ${res.status} for ${repo}@${tag}`); - const data = (await res.json()) as { tree: Array<{ path: string; type: string }> }; - return data.tree - .filter((node) => node.type === 'blob' && node.path.endsWith('.lua')) - .map((node) => node.path) - .sort(); -} +// ─── Done step ──────────────────────────────────────────────────────────────── function DoneStep({ id, onExit }: { id: string; onExit: () => void }) { useEffect(() => { - const timer = setTimeout(onExit, 300); - return () => clearTimeout(timer); + const t = setTimeout(onExit, 300); + return () => clearTimeout(t); }, [onExit]); return ( @@ -522,13 +72,13 @@ function DoneStep({ id, onExit }: { id: string; onExit: () => void }) { Commit packages/{id}.json and cli/src/generated/registry.json - Then push — GitHub Actions will publish the registry to the packages branch. + Then push — GitHub Actions will publish to the packages branch. ); } -const TOTAL_STEPS = 12; +// ─── Wizard ─────────────────────────────────────────────────────────────────── function Wizard() { const { exit } = useApp(); @@ -544,18 +94,19 @@ function Wizard() { setStep('error'); }; - // Step 1: package ID if (step === 'id') { return ( { if (!v) return 'Required'; if (!/^[a-z0-9][a-z0-9.-]*$/.test(v)) return 'Use only a-z, 0-9, dots, hyphens'; - if (existsSync(join(packagesDir, `${v}.json`))) return `packages/${v}.json already exists`; + if (existsSync(join(packagesDir, `${v}.json`))) + return `packages/${v}.json already exists — use package:update to edit it`; return null; }} onSubmit={(id) => { @@ -566,40 +117,36 @@ function Wizard() { ); } - // Step 2: GitHub repo if (step === 'repo') { return ( { if (!v) return 'Required'; if (!/^[^/]+\/[^/]+$/.test(v)) return 'Must be owner/repo format'; return null; }} onSubmit={(repo) => { - setData((d) => ({ - ...d, - repo, - homepage: `https://github.com/${repo}`, - })); + setData((d) => ({ ...d, repo, homepage: `https://github.com/${repo}` })); setStep('fetch-tags'); }} /> ); } - // Step 3: fetch tags if (step === 'fetch-tags') { return ( { - const tags = await fetchTags(data.repo!); + const { tags, license } = await fetchRepoMeta(data.repo!); if (tags.length === 0) throw new Error('No tags found. Push a release tag first.'); setFetchedTags(tags); + setData((d) => ({ ...d, license })); setStep('tag'); }} onError={handleError} @@ -607,47 +154,46 @@ function Wizard() { ); } - // Step 4: select tag if (step === 'tag') { return ( { - const baseUrl = `https://raw.githubusercontent.com/${data.repo}/${tag}/`; - setData((d) => ({ ...d, tag, baseUrl })); + setData((d) => ({ ...d, tag, baseUrl: `https://raw.githubusercontent.com/${d.repo}/${tag}/` })); setStep('trust'); }} /> ); } - // Step 5: trust level if (step === 'trust') { return ( { - setData((d) => ({ ...d, trust: trust as 'verified' | 'known' })); + setData((d) => ({ ...d, trust: trust as FormData['trust'] })); setStep('description'); }} /> ); } - // Step 6: description if (step === 'description') { return ( (v ? null : 'Required')} @@ -659,14 +205,14 @@ function Wizard() { ); } - // Step 7: tags if (step === 'pkg-tags') { return ( (v ? null : 'Required')} onSubmit={(tagStr) => { const tags = tagStr @@ -680,7 +226,6 @@ function Wizard() { ); } - // Step 8: fetch file tree if (step === 'fetch-files') { return ( { setData((d) => ({ ...d, selectedFiles })); @@ -713,12 +258,12 @@ function Wizard() { ); } - // Step 10: target paths if (step === 'targets') { return ( { @@ -729,17 +274,15 @@ function Wizard() { ); } - // Step 11: require path if (step === 'require') { - const targets = Object.values(data.targetMap ?? {}); - const firstTarget = targets[0] ?? `lib/${data.id}.lua`; + const firstTarget = Object.values(data.targetMap ?? {})[0] ?? `lib/${data.id}.lua`; const suggested = firstTarget.replace(/\.lua$/, '').replace(/\//g, '.'); return ( (v ? null : 'Required')} onSubmit={(req) => { @@ -751,12 +294,8 @@ function Wizard() { ); } - // Step 12: compute checksums if (step === 'fetch-checksums') { - const fileList = (data.selectedFiles ?? []).map((name) => ({ - name, - url: data.baseUrl! + name, - })); + const fileList = (data.selectedFiles ?? []).map((name) => ({ name, url: data.baseUrl! + name })); return ( ({ - name: f.name, - sha256: f.sha256, - target: f.target, - })), - }, - require: fullData.require, - example: fullData.example, - }; - setOutputJson(JSON.stringify(pkg, null, 2)); + setOutputJson(JSON.stringify(buildPackageJson(fullData), null, 2)); setStep('review'); }} onError={handleError} @@ -798,40 +315,34 @@ function Wizard() { ); } - // Step 13: review & confirm if (step === 'review') { return ( setStep('write')} onAbort={() => { - setErrorMsg('Aborted by user.'); + setErrorMsg('Aborted.'); setStep('error'); }} /> ); } - // Step 14: write + regenerate if (step === 'write') { return ( { - const outPath = join(packagesDir, `${data.id}.json`); - writeFileSync(outPath, outputJson + '\n', 'utf8'); - - const genScript = join(root, 'scripts', 'generate-registry.mjs'); - const result = spawnSync(process.execPath, [genScript], { + writeFileSync(join(packagesDir, `${data.id}.json`), outputJson + '\n', 'utf8'); + const result = spawnSync(process.execPath, [join(root, 'scripts', 'generate-registry.mjs')], { cwd: root, stdio: 'pipe', encoding: 'utf8', }); - if (result.status !== 0) { - throw new Error(result.stderr || 'generate-registry.mjs failed'); - } + if (result.status !== 0) throw new Error(result.stderr || 'generate-registry.mjs failed'); setStep('done'); }} onError={handleError} @@ -839,12 +350,8 @@ function Wizard() { ); } - // Done - if (step === 'done') { - return ; - } + if (step === 'done') return ; - // Error return ( @@ -855,5 +362,7 @@ function Wizard() { ); } +// ─── Entry ──────────────────────────────────────────────────────────────────── + const { waitUntilExit } = render(); await waitUntilExit(); diff --git a/cli/scripts/remove-package.tsx b/cli/scripts/remove-package.tsx new file mode 100644 index 00000000..cfedf6f9 --- /dev/null +++ b/cli/scripts/remove-package.tsx @@ -0,0 +1,124 @@ +#!/usr/bin/env node +/** + * Interactive wizard to remove a package from the Feather catalog. + * + * Usage (from repo root): + * npm run package:remove + */ + +import { render, Text, Box, useInput, useApp } from 'ink'; +import { useState, useEffect } from 'react'; +import { readdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { root, packagesDir, SelectStep, AutoStep } from './wizard-shared.js'; + +const TITLE = 'feather package:remove'; + +function ConfirmStep({ id, onConfirm, onAbort }: { id: string; onConfirm: () => void; onAbort: () => void }) { + useInput((input, key) => { + if (input === 'y' || input === 'Y' || key.return) onConfirm(); + if (input === 'n' || input === 'N' || key.escape) onAbort(); + }); + + return ( + + {' '}{TITLE} + + {' Remove '} + {id} + {' from the catalog?'} + + + {' This deletes packages/'}{id}{'.json and regenerates the registry.'} + + + {' y/Enter to confirm · n/Esc to abort'} + + + ); +} + +function DoneStep({ id, onExit }: { id: string; onExit: () => void }) { + useEffect(() => { + const t = setTimeout(onExit, 300); + return () => clearTimeout(t); + }, [onExit]); + + return ( + + ✔ Done! + {' '}packages/{id}.json removed + {' '}Registry regenerated + + Commit the deletions and cli/src/generated/registry.json + + + ); +} + +type Step = 'pick' | 'confirm' | 'remove' | 'done' | 'error'; + +function Wizard() { + const { exit } = useApp(); + const [step, setStep] = useState('pick'); + const [id, setId] = useState(''); + const [errorMsg, setErrorMsg] = useState(''); + + const handleError = (msg: string) => { setErrorMsg(msg); setStep('error'); }; + + if (step === 'pick') { + const ids = readdirSync(packagesDir) + .filter((f) => f.endsWith('.json')) + .map((f) => f.replace(/\.json$/, '')) + .sort(); + + return ( + { setId(picked); setStep('confirm'); }} + /> + ); + } + + if (step === 'confirm') { + return ( + setStep('remove')} + onAbort={() => { setErrorMsg('Aborted.'); setStep('error'); }} + /> + ); + } + + if (step === 'remove') { + return ( + { + rmSync(join(packagesDir, `${id}.json`)); + const result = spawnSync(process.execPath, [join(root, 'scripts', 'generate-registry.mjs')], { + cwd: root, stdio: 'pipe', encoding: 'utf8', + }); + if (result.status !== 0) throw new Error(result.stderr || 'generate-registry.mjs failed'); + setStep('done'); + }} + onError={handleError} + /> + ); + } + + if (step === 'done') return ; + + return ( + + ✖ Error + {errorMsg} + + ); +} + +const { waitUntilExit } = render(); +await waitUntilExit(); diff --git a/cli/scripts/update-package.tsx b/cli/scripts/update-package.tsx new file mode 100644 index 00000000..f15fe36d --- /dev/null +++ b/cli/scripts/update-package.tsx @@ -0,0 +1,361 @@ +#!/usr/bin/env node +/** + * Interactive wizard to update an existing package in the Feather catalog. + * + * Usage (from repo root): + * npm run package:update + * + * Loads packages/.json, pre-fills every field so you can change only + * what's wrong, re-computes checksums, and overwrites the file. + */ + +import { render, Text, Box, useApp } from 'ink'; +import { useState, useEffect } from 'react'; +import { readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { + root, packagesDir, + type FormData, type FileEntry, + TextInputStep, SelectStep, MultiSelectStep, + AutoStep, TargetsStep, ReviewStep, ChecksumStep, + fetchRepoMeta, fetchLuaFiles, buildPackageJson, +} from './wizard-shared.js'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type Step = + | 'pick' | 'repo' | 'fetch-tags' | 'tag' | 'trust' + | 'description' | 'pkg-tags' | 'fetch-files' | 'files' + | 'targets' | 'require' | 'fetch-checksums' | 'review' + | 'write' | 'done' | 'error'; + +interface RawPackage { + trust?: string; + description?: string; + tags?: string[]; + homepage?: string; + license?: string; + source?: { repo?: string; tag?: string; baseUrl?: string }; + install?: { files?: Array<{ name: string; sha256: string; target: string }> }; + require?: string; + example?: string; +} + +const TITLE = 'feather package:update'; +const TOTAL = 11; + +// ─── Done step ──────────────────────────────────────────────────────────────── + +function DoneStep({ id, onExit }: { id: string; onExit: () => void }) { + useEffect(() => { + const t = setTimeout(onExit, 300); + return () => clearTimeout(t); + }, [onExit]); + + return ( + + ✔ Done! + {' '}packages/{id}.json updated + {' '}Registry regenerated + + Commit packages/{id}.json and cli/src/generated/registry.json + + + ); +} + +// ─── Wizard ─────────────────────────────────────────────────────────────────── + +function Wizard() { + const { exit } = useApp(); + const [step, setStep] = useState('pick'); + const [data, setData] = useState>({}); + const [fetchedTags, setFetchedTags] = useState([]); + const [fetchedFiles, setFetchedFiles] = useState([]); + const [errorMsg, setErrorMsg] = useState(''); + const [outputJson, setOutputJson] = useState(''); + + // pre-selection state from the loaded package + const [initialTagIndex, setInitialTagIndex] = useState(0); + const [initialFileSelected, setInitialFileSelected] = useState | undefined>(undefined); + const [initialTargets, setInitialTargets] = useState | undefined>(undefined); + + const handleError = (msg: string) => { setErrorMsg(msg); setStep('error'); }; + + // Step 1: pick which package to update + if (step === 'pick') { + const ids = readdirSync(packagesDir) + .filter((f) => f.endsWith('.json')) + .map((f) => f.replace(/\.json$/, '')) + .sort(); + + return ( + { + const raw: RawPackage = JSON.parse(readFileSync(join(packagesDir, `${id}.json`), 'utf8')); + const existingFiles = raw.install?.files ?? []; + setInitialTargets(Object.fromEntries(existingFiles.map((f) => [f.name, f.target]))); + setData({ + id, + repo: raw.source?.repo ?? '', + homepage: raw.homepage ?? `https://github.com/${raw.source?.repo ?? ''}`, + license: raw.license ?? '', + tag: raw.source?.tag ?? '', + baseUrl: raw.source?.baseUrl ?? '', + trust: (raw.trust as FormData['trust']) ?? 'known', + description: raw.description ?? '', + tags: raw.tags ?? [], + selectedFiles: existingFiles.map((f) => f.name), + targetMap: Object.fromEntries(existingFiles.map((f) => [f.name, f.target])), + require: raw.require ?? '', + example: raw.example ?? '', + }); + setStep('repo'); + }} + /> + ); + } + + // Step 2: repo (pre-filled) + if (step === 'repo') { + return ( + { + if (!v) return 'Required'; + if (!/^[^/]+\/[^/]+$/.test(v)) return 'Must be owner/repo format'; + return null; + }} + onSubmit={(repo) => { + setData((d) => ({ ...d, repo, homepage: `https://github.com/${repo}` })); + setStep('fetch-tags'); + }} + /> + ); + } + + // Step 3: fetch tags + license + if (step === 'fetch-tags') { + return ( + { + const { tags, license } = await fetchRepoMeta(data.repo!); + if (tags.length === 0) throw new Error('No tags found.'); + setFetchedTags(tags); + // pre-position cursor at the currently pinned tag + const idx = tags.indexOf(data.tag ?? ''); + setInitialTagIndex(idx >= 0 ? idx : 0); + setData((d) => ({ ...d, license })); + setStep('tag'); + }} + onError={handleError} + /> + ); + } + + // Step 4: select tag (cursor starts at current pin) + if (step === 'tag') { + return ( + { + setData((d) => ({ ...d, tag, baseUrl: `https://raw.githubusercontent.com/${d.repo}/${tag}/` })); + setStep('trust'); + }} + /> + ); + } + + // Step 5: trust level + if (step === 'trust') { + const currentIdx = ['verified', 'known'].indexOf(data.trust ?? 'known'); + return ( + = 0 ? currentIdx : 0} + onSelect={(trust) => { + setData((d) => ({ ...d, trust: trust as FormData['trust'] })); + setStep('description'); + }} + /> + ); + } + + // Step 6: description + if (step === 'description') { + return ( + (v ? null : 'Required')} + onSubmit={(description) => { setData((d) => ({ ...d, description })); setStep('pkg-tags'); }} + /> + ); + } + + // Step 7: tags + if (step === 'pkg-tags') { + return ( + (v ? null : 'Required')} + onSubmit={(tagStr) => { + const tags = tagStr.split(',').map((t) => t.trim()).filter(Boolean); + setData((d) => ({ ...d, tags })); + setStep('fetch-files'); + }} + /> + ); + } + + // Step 8: fetch files for the (possibly new) tag + if (step === 'fetch-files') { + return ( + { + const files = await fetchLuaFiles(data.repo!, data.tag!); + if (files.length === 0) throw new Error('No .lua files found at this tag.'); + setFetchedFiles(files); + // pre-select files that were already installed + const existing = new Set(data.selectedFiles ?? []); + setInitialFileSelected(new Set(files.map((f, i) => ({ f, i })).filter(({ f }) => existing.has(f)).map(({ i }) => i))); + setStep('files'); + }} + onError={handleError} + /> + ); + } + + // Step 9: multi-select files + if (step === 'files') { + return ( + { + setData((d) => ({ ...d, selectedFiles })); + setStep('targets'); + }} + /> + ); + } + + // Step 10: target paths (pre-filled from existing package) + if (step === 'targets') { + return ( + { setData((d) => ({ ...d, targetMap })); setStep('require'); }} + /> + ); + } + + // Step 11: require path + if (step === 'require') { + return ( + (v ? null : 'Required')} + onSubmit={(req) => { + const example = `local ${data.id!.replace(/[.-]/g, '_')} = require('${req}')`; + setData((d) => ({ ...d, require: req, example })); + setStep('fetch-checksums'); + }} + /> + ); + } + + // Step 12: compute checksums (always fresh — tag may have changed) + if (step === 'fetch-checksums') { + const fileList = (data.selectedFiles ?? []).map((name) => ({ name, url: data.baseUrl! + name })); + return ( + { + const files: FileEntry[] = (data.selectedFiles ?? []).map((name) => ({ + name, + target: data.targetMap![name]!, + sha256: checksums[name]!, + })); + const fullData = { ...data, files } as FormData; + setData(fullData); + setOutputJson(JSON.stringify(buildPackageJson(fullData), null, 2)); + setStep('review'); + }} + onError={handleError} + /> + ); + } + + // Step 13: review + if (step === 'review') { + return ( + setStep('write')} + onAbort={() => { setErrorMsg('Aborted.'); setStep('error'); }} + /> + ); + } + + // Step 14: write + regenerate + if (step === 'write') { + return ( + { + writeFileSync(join(packagesDir, `${data.id}.json`), outputJson + '\n', 'utf8'); + const result = spawnSync(process.execPath, [join(root, 'scripts', 'generate-registry.mjs')], { + cwd: root, stdio: 'pipe', encoding: 'utf8', + }); + if (result.status !== 0) throw new Error(result.stderr || 'generate-registry.mjs failed'); + setStep('done'); + }} + onError={handleError} + /> + ); + } + + if (step === 'done') return ; + + return ( + + ✖ Error + {errorMsg} + + ); +} + +// ─── Entry ──────────────────────────────────────────────────────────────────── + +const { waitUntilExit } = render(); +await waitUntilExit(); diff --git a/cli/scripts/wizard-shared.tsx b/cli/scripts/wizard-shared.tsx new file mode 100644 index 00000000..0ef51a7f --- /dev/null +++ b/cli/scripts/wizard-shared.tsx @@ -0,0 +1,510 @@ +/** + * Shared primitives for add-package and update-package wizards. + */ + +import { Text, Box, useInput } from 'ink'; +import { useState, useEffect } from 'react'; +import { createHash } from 'node:crypto'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const __dirname = dirname(fileURLToPath(import.meta.url)); +export const root = resolve(__dirname, '../..'); +export const packagesDir = join(root, 'packages'); + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface FileEntry { + name: string; + target: string; + sha256: string; +} + +export interface FormData { + id: string; + repo: string; + homepage: string; + license: string; + tag: string; + baseUrl: string; + trust: 'verified' | 'known'; + description: string; + tags: string[]; + selectedFiles: string[]; + targetMap: Record; + require: string; + example: string; + files: FileEntry[]; +} + +export type InkKey = Parameters[0]>[1]; + +// ─── Header & Hint ─────────────────────────────────────────────────────────── + +export function Header({ step, total, title }: { step: number; total: number; title?: string }) { + return ( + + + {' '}{title ?? 'feather package:add'} + + {` Step ${step} of ${total}`} + + ); +} + +export function Hint({ children }: { children: string }) { + return ( + + {' '} + {children} + + ); +} + +// ─── Cursor-aware text input ────────────────────────────────────────────────── + +export function useTextInput(initial: string) { + const [value, setValue] = useState(initial); + const [cursor, setCursor] = useState(initial.length); + + const reset = (next: string) => { + setValue(next); + setCursor(next.length); + }; + + const handleKey = (input: string, key: InkKey) => { + if (key.leftArrow) { setCursor((c) => Math.max(0, c - 1)); return true; } + if (key.rightArrow) { setCursor((c) => Math.min(value.length, c + 1)); return true; } + if (key.backspace) { + if (cursor === 0) return true; + const pos = cursor; + setValue((v) => v.slice(0, pos - 1) + v.slice(pos)); + setCursor((c) => c - 1); + return true; + } + if (key.delete) { + const pos = cursor; + setValue((v) => v.slice(0, pos) + v.slice(pos + 1)); + return true; + } + if (!key.ctrl && !key.meta && input) { + const pos = cursor; + setValue((v) => v.slice(0, pos) + input + v.slice(pos)); + setCursor((c) => c + 1); + return true; + } + return false; + }; + + return { + value, + cursor, + reset, + handleKey, + before: value.slice(0, cursor), + at: value[cursor] ?? '', + after: value.slice(cursor + 1), + }; +} + +export function CursorText({ before, at, after }: { before: string; at: string; after: string }) { + return ( + + {before} + {at || ' '} + {after} + + ); +} + +// ─── TextInputStep ──────────────────────────────────────────────────────────── + +interface TextInputStepProps { + stepNum: number; + total: number; + label: string; + hint?: string; + defaultValue?: string; + validate?: (v: string) => string | null; + onSubmit: (value: string) => void; + title?: string; +} + +export function TextInputStep({ stepNum, total, label, hint, defaultValue = '', validate, onSubmit, title }: TextInputStepProps) { + const input = useTextInput(defaultValue); + const [error, setError] = useState(null); + + useInput((char, key) => { + if (key.return) { + const err = validate ? validate(input.value.trim()) : null; + if (err) { setError(err); return; } + onSubmit(input.value.trim()); + return; + } + input.handleKey(char, key); + setError(null); + }); + + return ( + +
+ {' '}{label} + {hint && {hint}} + + {' '} + + + {error && {' ✖ '}{error}} + + {' ←→ move · Backspace/Delete edit · Enter confirm'} + + + ); +} + +// ─── SelectStep ─────────────────────────────────────────────────────────────── + +interface SelectStepProps { + stepNum: number; + total: number; + label: string; + hint?: string; + options: string[]; + initialIndex?: number; + onSelect: (value: string) => void; + title?: string; +} + +export function SelectStep({ stepNum, total, label, hint, options, initialIndex = 0, onSelect, title }: SelectStepProps) { + const [cursor, setCursor] = useState(initialIndex); + + useInput((_, key) => { + if (key.upArrow) setCursor((c) => Math.max(0, c - 1)); + if (key.downArrow) setCursor((c) => Math.min(options.length - 1, c + 1)); + if (key.return) onSelect(options[cursor]!); + }); + + return ( + +
+ {' '}{label} + {hint && {hint}} + + {options.map((opt, i) => ( + + + {' '}{i === cursor ? '❯ ' : ' '}{opt} + + + ))} + + + {' ↑↓ navigate · Enter to select'} + + + ); +} + +// ─── MultiSelectStep ────────────────────────────────────────────────────────── + +interface MultiSelectStepProps { + stepNum: number; + total: number; + label: string; + hint?: string; + options: string[]; + initialSelected?: Set; + onSubmit: (selected: string[]) => void; + title?: string; +} + +export function MultiSelectStep({ stepNum, total, label, hint, options, initialSelected, onSubmit, title }: MultiSelectStepProps) { + const [cursor, setCursor] = useState(0); + const [selected, setSelected] = useState>( + initialSelected ?? new Set(options.map((_, i) => i)), + ); + + useInput((input, key) => { + if (key.upArrow) setCursor((c) => Math.max(0, c - 1)); + if (key.downArrow) setCursor((c) => Math.min(options.length - 1, c + 1)); + if (input === ' ') { + setSelected((s) => { + const next = new Set(s); + next.has(cursor) ? next.delete(cursor) : next.add(cursor); + return next; + }); + } + if (key.return) { + const chosen = options.filter((_, i) => selected.has(i)); + if (chosen.length > 0) onSubmit(chosen); + } + }); + + return ( + +
+ {' '}{label} + {hint && {hint}} + + {options.map((opt, i) => ( + + + {' '}{i === cursor ? '❯ ' : ' '} + {selected.has(i) ? '◉ ' : '○ '} + {opt} + + + ))} + + + {' ↑↓ navigate · Space toggle · Enter confirm'} + + + ); +} + +// ─── Spinner & AutoStep ─────────────────────────────────────────────────────── + +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +export function Spinner({ label }: { label: string }) { + const [frame, setFrame] = useState(0); + + useEffect(() => { + const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 80); + return () => clearInterval(id); + }, []); + + return ( + + {SPINNER_FRAMES[frame]} + {label} + + ); +} + +interface AutoStepProps { + label: string; + run: () => Promise; + onError: (msg: string) => void; +} + +export function AutoStep({ label, run, onError }: AutoStepProps) { + const [status, setStatus] = useState<'running' | 'done' | 'error'>('running'); + const [error, setError] = useState(''); + + useEffect(() => { + run() + .then(() => setStatus('done')) + .catch((err: Error) => { + setError(err.message); + setStatus('error'); + onError(err.message); + }); + }, []); + + return ( + + {status === 'running' && } + {status === 'done' && ✔ {label}} + {status === 'error' && ✖ {error}} + + ); +} + +// ─── TargetsStep ────────────────────────────────────────────────────────────── + +interface TargetsStepProps { + stepNum: number; + total: number; + id: string; + files: string[]; + initialTargets?: Record; + onSubmit: (targets: Record) => void; + title?: string; +} + +export function TargetsStep({ stepNum, total, id, files, initialTargets, onSubmit, title }: TargetsStepProps) { + const defaultTarget = (f: string) => + initialTargets?.[f] ?? (files.length === 1 ? `lib/${f}` : `lib/${id}/${f}`); + + const [index, setIndex] = useState(0); + const [targets, setTargets] = useState>( + Object.fromEntries(files.map((f) => [f, defaultTarget(f)])), + ); + const textInput = useTextInput(defaultTarget(files[0]!)); + const [error, setError] = useState(null); + + const file = files[index]!; + + const advance = () => { + const val = textInput.value.trim(); + if (!val) { setError('Target path is required'); return; } + if (!val.endsWith('.lua')) { setError('Target path must end in .lua'); return; } + const next = { ...targets, [file]: val }; + setTargets(next); + if (index + 1 < files.length) { + const nextFile = files[index + 1]!; + setIndex(index + 1); + textInput.reset(next[nextFile] ?? defaultTarget(nextFile)); + setError(null); + } else { + onSubmit(next); + } + }; + + useInput((char, key) => { + if (key.return) { advance(); return; } + textInput.handleKey(char, key); + setError(null); + }); + + return ( + +
+ {' '}Install target paths + {`File ${index + 1} of ${files.length}: ${file}`} + + {' '} + + + {error && {' ✖ '}{error}} + + {' ←→ move · Backspace/Delete edit · Enter confirm'} + + + ); +} + +// ─── ReviewStep ─────────────────────────────────────────────────────────────── + +interface ReviewStepProps { + stepNum: number; + total: number; + json: string; + onConfirm: () => void; + onAbort: () => void; + title?: string; +} + +export function ReviewStep({ stepNum, total, json, onConfirm, onAbort, title }: ReviewStepProps) { + useInput((input, key) => { + if (key.return || input === 'y' || input === 'Y') onConfirm(); + if (input === 'n' || input === 'N' || key.escape) onAbort(); + }); + + return ( + +
+ {' '}Review generated package + + {json.split('\n').map((line, i) => ( + {' '}{line} + ))} + + + {' '}y/Enter to write · n/Esc to abort + + + ); +} + +// ─── ChecksumStep ───────────────────────────────────────────────────────────── + +interface ChecksumStepProps { + files: Array<{ name: string; url: string }>; + onDone: (checksums: Record) => void; + onError: (msg: string) => void; +} + +export function ChecksumStep({ files, onDone, onError }: ChecksumStepProps) { + const [done, setDone] = useState>({}); + const [current, setCurrent] = useState(files[0]?.name ?? ''); + + useEffect(() => { + const run = async () => { + const results: Record = {}; + for (const f of files) { + setCurrent(f.name); + const res = await fetch(f.url); + if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${f.url}`); + const buf = await res.arrayBuffer(); + const hash = createHash('sha256').update(Buffer.from(buf)).digest('hex'); + results[f.name] = hash; + setDone((d) => ({ ...d, [f.name]: hash })); + } + onDone(results); + }; + run().catch((err: Error) => onError(err.message)); + }, []); + + return ( + + Computing SHA-256 checksums… + {files.map((f) => { + const sha = done[f.name]; + const isActive = f.name === current && !sha; + return ( + + {sha ? ( + {' ✔ '}{f.name} + ) : isActive ? ( + {' '} + ) : ( + {' ○ '}{f.name} + )} + + ); + })} + + ); +} + +// ─── GitHub API helpers ─────────────────────────────────────────────────────── + +const GH_HEADERS = { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }; + +export async function fetchRepoMeta(repo: string): Promise<{ tags: string[]; license: string }> { + const [tagsRes, repoRes] = await Promise.all([ + fetch(`https://api.github.com/repos/${repo}/tags?per_page=20`, { headers: GH_HEADERS }), + fetch(`https://api.github.com/repos/${repo}`, { headers: GH_HEADERS }), + ]); + if (!tagsRes.ok) throw new Error(`GitHub API ${tagsRes.status} for ${repo}/tags`); + if (!repoRes.ok) throw new Error(`GitHub API ${repoRes.status} for ${repo}`); + const [tagsData, repoData] = await Promise.all([ + tagsRes.json() as Promise>, + repoRes.json() as Promise<{ license?: { spdx_id?: string } }>, + ]); + return { tags: tagsData.map((t) => t.name), license: repoData.license?.spdx_id ?? 'unknown' }; +} + +export async function fetchLuaFiles(repo: string, tag: string): Promise { + const res = await fetch(`https://api.github.com/repos/${repo}/git/trees/${tag}?recursive=1`, { + headers: GH_HEADERS, + }); + if (!res.ok) throw new Error(`GitHub API ${res.status} for ${repo}@${tag}`); + const data = (await res.json()) as { tree: Array<{ path: string; type: string }> }; + return data.tree + .filter((node) => node.type === 'blob' && node.path.endsWith('.lua')) + .map((node) => node.path) + .sort(); +} + +// ─── Registry regeneration ──────────────────────────────────────────────────── + +export function buildPackageJson(data: FormData): object { + return { + type: 'love2d-library', + trust: data.trust, + description: data.description, + tags: data.tags, + homepage: data.homepage, + license: data.license, + source: { repo: data.repo, tag: data.tag, baseUrl: data.baseUrl }, + install: { + files: data.files.map((f) => ({ name: f.name, sha256: f.sha256, target: f.target })), + }, + require: data.require, + example: data.example, + }; +} diff --git a/cli/src/commands/package.ts b/cli/src/commands/package.ts index 20c7774f..edb4892f 100644 --- a/cli/src/commands/package.ts +++ b/cli/src/commands/package.ts @@ -173,6 +173,7 @@ export async function packageInfoCommand(name: string, opts: PackageInfoOptions console.log(` Source: ${chalk.cyan(`github.com/${entry.source.repo}`)}`); console.log(` Version: ${entry.source.tag}`); console.log(` Tags: ${entry.tags.join(', ') || '—'}`); + if (entry.license) console.log(` License: ${entry.license}`); if (entry.homepage) console.log(` Docs: ${chalk.cyan(entry.homepage)}`); if (installed) { console.log(` Status: ${chalk.green('installed')} @ ${installed.version}`); diff --git a/cli/src/lib/package/registry.ts b/cli/src/lib/package/registry.ts index e221a702..0ce5ba2b 100644 --- a/cli/src/lib/package/registry.ts +++ b/cli/src/lib/package/registry.ts @@ -22,6 +22,7 @@ export type RegistryEntry = { description: string; tags: string[]; homepage?: string; + license?: string; source: { repo: string; tag: string; diff --git a/cli/src/ui/package-workflow.tsx b/cli/src/ui/package-workflow.tsx index e22457ba..0a5766e4 100644 --- a/cli/src/ui/package-workflow.tsx +++ b/cli/src/ui/package-workflow.tsx @@ -186,6 +186,7 @@ function PackageWorkflow({ {entry.description} github.com/{entry.source.repo} + {entry.license ? license: {entry.license} : null} {entry.subpackages?.length ? modules: {entry.subpackages.join(', ')} : null} {actions.map((act, i) => ( diff --git a/package.json b/package.json index fb926ab3..fd80e9c8 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "generate:plugin-catalog": "node scripts/generate-plugin-catalog.mjs", "check:plugin-catalog": "npm run generate:plugin-catalog && git diff --exit-code cli/src/generated/plugin-catalog.ts", "package:add": "npm run package:add --workspace=cli", + "package:update": "npm run package:update --workspace=cli", + "package:remove": "npm run package:remove --workspace=cli", "generate:registry": "node scripts/generate-registry.mjs", "check:registry": "npm run generate:registry && git diff --exit-code cli/src/generated/registry.json", "verify:packages": "node scripts/compute-checksums.mjs --all", diff --git a/scripts/generate-registry.mjs b/scripts/generate-registry.mjs index ebc1b0bb..7b710fd8 100644 --- a/scripts/generate-registry.mjs +++ b/scripts/generate-registry.mjs @@ -56,6 +56,7 @@ for (const file of packageFiles) { description: sub.description ?? `${id} — ${subId.split(".").pop()} module`, tags: pkg.tags ?? [], homepage: pkg.homepage, + license: pkg.license, source: pkg.source, install: { files: (pkg.install?.files ?? []).filter((f) => @@ -74,6 +75,7 @@ for (const file of packageFiles) { description: pkg.description, tags: pkg.tags ?? [], homepage: pkg.homepage, + license: pkg.license, source: pkg.source, install: pkg.install, subpackages: pkg.subpackages ? Object.keys(pkg.subpackages) : undefined, From f2260a822c798fa6c9330a3639ec34d41555d826 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Wed, 13 May 2026 18:01:34 -0400 Subject: [PATCH 04/68] package: update registry, remove windfield and add baton --- cli/src/generated/registry.json | 103 ++++++++++++++++++++++---------- packages/anim8.json | 7 ++- packages/baton.json | 30 ++++++++++ packages/bump.json | 7 ++- packages/classic.json | 7 ++- packages/flux.json | 7 ++- packages/hump.json | 67 +++++++++++++++++---- packages/inspect.json | 7 ++- packages/lume.json | 7 ++- packages/middleclass.json | 7 ++- packages/push.json | 7 ++- packages/shove.json | 1 + packages/sti.json | 41 +++++++++++-- packages/windfield.json | 19 ------ 14 files changed, 239 insertions(+), 78 deletions(-) create mode 100644 packages/baton.json delete mode 100644 packages/windfield.json diff --git a/cli/src/generated/registry.json b/cli/src/generated/registry.json index 7ed7477c..f20ebcd3 100644 --- a/cli/src/generated/registry.json +++ b/cli/src/generated/registry.json @@ -11,6 +11,7 @@ "sprites" ], "homepage": "https://github.com/kikito/anim8", + "license": "MIT", "source": { "repo": "kikito/anim8", "tag": "v2.3.1", @@ -28,6 +29,36 @@ "require": "lib.anim8", "example": "local anim8 = require('lib.anim8')" }, + "baton": { + "type": "love2d-library", + "trust": "verified", + "description": "An input library for LÖVE.", + "tags": [ + "input", + "controller", + "keyboard", + "mouse", + "touch" + ], + "homepage": "https://github.com/tesselode/baton", + "license": "MIT", + "source": { + "repo": "tesselode/baton", + "tag": "v1.0.2", + "baseUrl": "https://raw.githubusercontent.com/tesselode/baton/v1.0.2/" + }, + "install": { + "files": [ + { + "name": "baton.lua", + "sha256": "5dee8e0984badef516d1a42f5996b8fae332363399940241fb82abeb5ae648f7", + "target": "lib/baton.lua" + } + ] + }, + "require": "lib.baton", + "example": "local baton = require('lib.baton')" + }, "bump": { "type": "love2d-library", "trust": "verified", @@ -37,6 +68,7 @@ "physics" ], "homepage": "https://github.com/kikito/bump.lua", + "license": "MIT", "source": { "repo": "kikito/bump.lua", "tag": "v3.1.7", @@ -63,6 +95,7 @@ "utilities" ], "homepage": "https://github.com/rxi/classic", + "license": "MIT", "source": { "repo": "rxi/classic", "tag": "master", @@ -89,6 +122,7 @@ "tweening" ], "homepage": "https://github.com/rxi/flux", + "license": "MIT", "source": { "repo": "rxi/flux", "tag": "master", @@ -119,6 +153,7 @@ "math" ], "homepage": "https://github.com/vrld/hump", + "license": "MIT", "source": { "repo": "vrld/hump", "tag": "master", @@ -149,6 +184,7 @@ "math" ], "homepage": "https://github.com/vrld/hump", + "license": "MIT", "source": { "repo": "vrld/hump", "tag": "master", @@ -179,6 +215,7 @@ "math" ], "homepage": "https://github.com/vrld/hump", + "license": "MIT", "source": { "repo": "vrld/hump", "tag": "master", @@ -209,6 +246,7 @@ "math" ], "homepage": "https://github.com/vrld/hump", + "license": "MIT", "source": { "repo": "vrld/hump", "tag": "master", @@ -239,6 +277,7 @@ "math" ], "homepage": "https://github.com/vrld/hump", + "license": "MIT", "source": { "repo": "vrld/hump", "tag": "master", @@ -269,6 +308,7 @@ "math" ], "homepage": "https://github.com/vrld/hump", + "license": "MIT", "source": { "repo": "vrld/hump", "tag": "master", @@ -298,6 +338,7 @@ "math" ], "homepage": "https://github.com/vrld/hump", + "license": "MIT", "source": { "repo": "vrld/hump", "tag": "master", @@ -357,6 +398,7 @@ "utilities" ], "homepage": "https://github.com/kikito/inspect.lua", + "license": "MIT", "source": { "repo": "kikito/inspect.lua", "tag": "master", @@ -383,6 +425,7 @@ "math" ], "homepage": "https://github.com/rxi/lume", + "license": "MIT", "source": { "repo": "rxi/lume", "tag": "master", @@ -409,6 +452,7 @@ "utilities" ], "homepage": "https://github.com/kikito/middleclass", + "license": "MIT", "source": { "repo": "kikito/middleclass", "tag": "v4.1.1", @@ -435,6 +479,7 @@ "resolution" ], "homepage": "https://github.com/Ulydev/push", + "license": "MIT", "source": { "repo": "Ulydev/push", "tag": "master", @@ -471,6 +516,7 @@ "love2d-framework" ], "homepage": "https://github.com/oval-tutu/shove", + "license": "MIT", "source": { "repo": "oval-tutu/shove", "tag": "1.0.6", @@ -495,61 +541,52 @@ }, "sti": { "type": "love2d-library", - "trust": "known", + "trust": "verified", "description": "Simple Tiled Implementation — loads and renders Tiled maps (.tmx) in LÖVE.", "tags": [ + "sprites", "maps", - "tiled", - "tilemaps" + "tiles", + "tiled" ], "homepage": "https://github.com/karai17/Simple-Tiled-Implementation", + "license": "NOASSERTION", "source": { "repo": "karai17/Simple-Tiled-Implementation", - "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/karai17/Simple-Tiled-Implementation/master/" + "tag": "v1.2.3.0", + "baseUrl": "https://raw.githubusercontent.com/karai17/Simple-Tiled-Implementation/v1.2.3.0/" }, "install": { "files": [ + { + "name": "sti/graphics.lua", + "sha256": "c77ed8b4220b2e7c37fdf56612365ce3520ea8183deee92e2f337691f7d20b14", + "target": "lib/sti/graphics.lua" + }, { "name": "sti/init.lua", - "sha256": "b7542ac156b3c2ee24d256ce260f73dc01ab2d1245c4eb4b14c92fcdf487c814", + "sha256": "b65f9cc816bd666bf1ba42b4f50ee14d5fa83f22b3f121eb0b0f07c14ecb8df7", "target": "lib/sti/init.lua" }, + { + "name": "sti/plugins/box2d.lua", + "sha256": "5206a9528eeaf19a12b9512f2b8ca2fa9e2af7e3a634eb084b4dd9c0ac070205", + "target": "lib/sti/plugins/box2d.lua" + }, + { + "name": "sti/plugins/bump.lua", + "sha256": "0c2bc5c882aeab0e2dc5ad1019a83aab72ccfe25ef6d9b105b8cb924d2feaab5", + "target": "lib/sti/plugins/bump.lua" + }, { "name": "sti/utils.lua", - "sha256": "ba8abf5a3f5680c6a020234afc248c4fb8945cda993d4cd82b292510e4dab17c", + "sha256": "6993cfde776ec0d1dae4ee53cec8df36fe37801dcf6858f51e443fe65f4ad4c9", "target": "lib/sti/utils.lua" } ] }, "require": "lib.sti", "example": "local sti = require('lib.sti')" - }, - "windfield": { - "type": "love2d-library", - "trust": "known", - "description": "A physics module for LÖVE that makes Box2D easier to use. Wraps love.physics with a simpler API.", - "tags": [ - "physics", - "box2d" - ], - "homepage": "https://github.com/SSYGEN/windfield", - "source": { - "repo": "SSYGEN/windfield", - "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/SSYGEN/windfield/master/" - }, - "install": { - "files": [ - { - "name": "windfield/init.lua", - "sha256": "687c789dceeba3ebb6f5b152b94e9c085268695ed8cc578780d24ec1696f0388", - "target": "lib/windfield/init.lua" - } - ] - }, - "require": "lib.windfield", - "example": "local wf = require('lib.windfield')" } } } diff --git a/packages/anim8.json b/packages/anim8.json index 7fe0b3ce..eee7778e 100644 --- a/packages/anim8.json +++ b/packages/anim8.json @@ -4,6 +4,7 @@ "description": "A 2D animation library for LÖVE. Handles sprite sheets, frames, tags, and playback.", "tags": ["animation", "sprites"], "homepage": "https://github.com/kikito/anim8", + "license": "MIT", "source": { "repo": "kikito/anim8", "tag": "v2.3.1", @@ -11,7 +12,11 @@ }, "install": { "files": [ - { "name": "anim8.lua", "sha256": "c6581c30225a4297924a4f27e56edbc12894fb2e168232fa3a19d21ac149e83f", "target": "lib/anim8.lua" } + { + "name": "anim8.lua", + "sha256": "c6581c30225a4297924a4f27e56edbc12894fb2e168232fa3a19d21ac149e83f", + "target": "lib/anim8.lua" + } ] }, "require": "lib.anim8", diff --git a/packages/baton.json b/packages/baton.json new file mode 100644 index 00000000..0a9658cf --- /dev/null +++ b/packages/baton.json @@ -0,0 +1,30 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "An input library for LÖVE.", + "tags": [ + "input", + "controller", + "keyboard", + "mouse", + "touch" + ], + "homepage": "https://github.com/tesselode/baton", + "license": "MIT", + "source": { + "repo": "tesselode/baton", + "tag": "v1.0.2", + "baseUrl": "https://raw.githubusercontent.com/tesselode/baton/v1.0.2/" + }, + "install": { + "files": [ + { + "name": "baton.lua", + "sha256": "5dee8e0984badef516d1a42f5996b8fae332363399940241fb82abeb5ae648f7", + "target": "lib/baton.lua" + } + ] + }, + "require": "lib.baton", + "example": "local baton = require('lib.baton')" +} diff --git a/packages/bump.json b/packages/bump.json index 92b51e9e..646802e4 100644 --- a/packages/bump.json +++ b/packages/bump.json @@ -4,6 +4,7 @@ "description": "A Lua library for simple AABB collision detection and resolution.", "tags": ["collision", "physics"], "homepage": "https://github.com/kikito/bump.lua", + "license": "MIT", "source": { "repo": "kikito/bump.lua", "tag": "v3.1.7", @@ -11,7 +12,11 @@ }, "install": { "files": [ - { "name": "bump.lua", "sha256": "b947cc4449554816831d5c45b61a169e1559548139478d980c8eef14c7b362a6", "target": "lib/bump.lua" } + { + "name": "bump.lua", + "sha256": "b947cc4449554816831d5c45b61a169e1559548139478d980c8eef14c7b362a6", + "target": "lib/bump.lua" + } ] }, "require": "lib.bump", diff --git a/packages/classic.json b/packages/classic.json index 7fe3b2c0..ddaa3c62 100644 --- a/packages/classic.json +++ b/packages/classic.json @@ -4,6 +4,7 @@ "description": "A tiny class module for Lua. Minimal OOP with inheritance.", "tags": ["oop", "utilities"], "homepage": "https://github.com/rxi/classic", + "license": "MIT", "source": { "repo": "rxi/classic", "tag": "master", @@ -11,7 +12,11 @@ }, "install": { "files": [ - { "name": "classic.lua", "sha256": "52827561440f22ff3f34f926b2f4bdc1b74cbbaafa3b3b8252b530b12387d48b", "target": "lib/classic.lua" } + { + "name": "classic.lua", + "sha256": "52827561440f22ff3f34f926b2f4bdc1b74cbbaafa3b3b8252b530b12387d48b", + "target": "lib/classic.lua" + } ] }, "require": "lib.classic", diff --git a/packages/flux.json b/packages/flux.json index 1fbd41ce..dcd67de7 100644 --- a/packages/flux.json +++ b/packages/flux.json @@ -4,6 +4,7 @@ "description": "A fast, lightweight tweening library for Lua with chainable API and easing functions.", "tags": ["animation", "tweening"], "homepage": "https://github.com/rxi/flux", + "license": "MIT", "source": { "repo": "rxi/flux", "tag": "master", @@ -11,7 +12,11 @@ }, "install": { "files": [ - { "name": "flux.lua", "sha256": "11542ab78001f99d94d0b9ca3f99072494e1e4f4ce395db203bf4f7e6f7889d5", "target": "lib/flux.lua" } + { + "name": "flux.lua", + "sha256": "11542ab78001f99d94d0b9ca3f99072494e1e4f4ce395db203bf4f7e6f7889d5", + "target": "lib/flux.lua" + } ] }, "require": "lib.flux", diff --git a/packages/hump.json b/packages/hump.json index 9c334255..054c01a5 100644 --- a/packages/hump.json +++ b/packages/hump.json @@ -4,6 +4,7 @@ "description": "Helper utilities for LÖVE: camera, timer, signal, class, vector, gamestate.", "tags": ["utilities", "camera", "timer", "oop", "math"], "homepage": "https://github.com/vrld/hump", + "license": "MIT", "source": { "repo": "vrld/hump", "tag": "master", @@ -11,21 +12,63 @@ }, "install": { "files": [ - { "name": "camera.lua", "sha256": "b5f0c086fc652f7f97eef84b6be358f889ab9b8d28cf7f295b46d48638014099", "target": "lib/hump/camera.lua" }, - { "name": "timer.lua", "sha256": "8931203b027ecce889692e67323e7f333881fb230ed9dd735c785f5e3a7def03", "target": "lib/hump/timer.lua" }, - { "name": "signal.lua", "sha256": "4f311197119177453d8759add39531905da2d3590a7e3a7befc618e46f31b051", "target": "lib/hump/signal.lua" }, - { "name": "class.lua", "sha256": "b0b97bb7cc51b9234dfe3dce0f6da12beaebbb6335eaeaa38de8e537a157cb4e", "target": "lib/hump/class.lua" }, - { "name": "vector.lua", "sha256": "70a4d9437e0c7bdf718160eb4a3464f54705129b854f1c9bd60a5adea8d197cc", "target": "lib/hump/vector.lua" }, - { "name": "gamestate.lua", "sha256": "7e11c9f4909ececc3b61fa4313b001b25f33b5ac36bc96b641aedff0328a4406", "target": "lib/hump/gamestate.lua" } + { + "name": "camera.lua", + "sha256": "b5f0c086fc652f7f97eef84b6be358f889ab9b8d28cf7f295b46d48638014099", + "target": "lib/hump/camera.lua" + }, + { + "name": "timer.lua", + "sha256": "8931203b027ecce889692e67323e7f333881fb230ed9dd735c785f5e3a7def03", + "target": "lib/hump/timer.lua" + }, + { + "name": "signal.lua", + "sha256": "4f311197119177453d8759add39531905da2d3590a7e3a7befc618e46f31b051", + "target": "lib/hump/signal.lua" + }, + { + "name": "class.lua", + "sha256": "b0b97bb7cc51b9234dfe3dce0f6da12beaebbb6335eaeaa38de8e537a157cb4e", + "target": "lib/hump/class.lua" + }, + { + "name": "vector.lua", + "sha256": "70a4d9437e0c7bdf718160eb4a3464f54705129b854f1c9bd60a5adea8d197cc", + "target": "lib/hump/vector.lua" + }, + { + "name": "gamestate.lua", + "sha256": "7e11c9f4909ececc3b61fa4313b001b25f33b5ac36bc96b641aedff0328a4406", + "target": "lib/hump/gamestate.lua" + } ] }, "subpackages": { - "hump.camera": { "files": ["camera.lua"], "require": "lib.hump.camera" }, - "hump.timer": { "files": ["timer.lua"], "require": "lib.hump.timer" }, - "hump.signal": { "files": ["signal.lua"], "require": "lib.hump.signal" }, - "hump.class": { "files": ["class.lua"], "require": "lib.hump.class" }, - "hump.vector": { "files": ["vector.lua"], "require": "lib.hump.vector" }, - "hump.gamestate": { "files": ["gamestate.lua"], "require": "lib.hump.gamestate" } + "hump.camera": { + "files": ["camera.lua"], + "require": "lib.hump.camera" + }, + "hump.timer": { + "files": ["timer.lua"], + "require": "lib.hump.timer" + }, + "hump.signal": { + "files": ["signal.lua"], + "require": "lib.hump.signal" + }, + "hump.class": { + "files": ["class.lua"], + "require": "lib.hump.class" + }, + "hump.vector": { + "files": ["vector.lua"], + "require": "lib.hump.vector" + }, + "hump.gamestate": { + "files": ["gamestate.lua"], + "require": "lib.hump.gamestate" + } }, "require": "lib.hump", "example": "local Camera = require('lib.hump.camera')" diff --git a/packages/inspect.json b/packages/inspect.json index b5f48a85..551623c9 100644 --- a/packages/inspect.json +++ b/packages/inspect.json @@ -4,6 +4,7 @@ "description": "Human-readable table serializer and pretty-printer for Lua. Useful for debugging.", "tags": ["debug", "utilities"], "homepage": "https://github.com/kikito/inspect.lua", + "license": "MIT", "source": { "repo": "kikito/inspect.lua", "tag": "master", @@ -11,7 +12,11 @@ }, "install": { "files": [ - { "name": "inspect.lua", "sha256": "656e4be83802750cfe13156810f7990d49893c86518ed5d882f0709d2901dfc5", "target": "lib/inspect.lua" } + { + "name": "inspect.lua", + "sha256": "656e4be83802750cfe13156810f7990d49893c86518ed5d882f0709d2901dfc5", + "target": "lib/inspect.lua" + } ] }, "require": "lib.inspect", diff --git a/packages/lume.json b/packages/lume.json index 589b2928..67d6451e 100644 --- a/packages/lume.json +++ b/packages/lume.json @@ -4,6 +4,7 @@ "description": "A collection of Lua functions for gamedev: math, table manipulation, string utilities, and more.", "tags": ["utilities", "math"], "homepage": "https://github.com/rxi/lume", + "license": "MIT", "source": { "repo": "rxi/lume", "tag": "master", @@ -11,7 +12,11 @@ }, "install": { "files": [ - { "name": "lume.lua", "sha256": "fb2856d60eb48516c8aa04de1e8b58b355a56fb7a05fd1170cfe330f8ad69b1a", "target": "lib/lume.lua" } + { + "name": "lume.lua", + "sha256": "fb2856d60eb48516c8aa04de1e8b58b355a56fb7a05fd1170cfe330f8ad69b1a", + "target": "lib/lume.lua" + } ] }, "require": "lib.lume", diff --git a/packages/middleclass.json b/packages/middleclass.json index c2f06ac4..16a14b57 100644 --- a/packages/middleclass.json +++ b/packages/middleclass.json @@ -4,6 +4,7 @@ "description": "A simple OOP library for Lua. Supports inheritance, mixins, and interfaces.", "tags": ["oop", "utilities"], "homepage": "https://github.com/kikito/middleclass", + "license": "MIT", "source": { "repo": "kikito/middleclass", "tag": "v4.1.1", @@ -11,7 +12,11 @@ }, "install": { "files": [ - { "name": "middleclass.lua", "sha256": "b4f0060e16daa070a0da33b6808f6e4cb9ee819205321d019a8e770af5e77c07", "target": "lib/middleclass.lua" } + { + "name": "middleclass.lua", + "sha256": "b4f0060e16daa070a0da33b6808f6e4cb9ee819205321d019a8e770af5e77c07", + "target": "lib/middleclass.lua" + } ] }, "require": "lib.middleclass", diff --git a/packages/push.json b/packages/push.json index d0b8b78b..1ea4a789 100644 --- a/packages/push.json +++ b/packages/push.json @@ -4,6 +4,7 @@ "description": "Resolution-independence library for LÖVE. Lets you draw at a fixed resolution and scale to any window size.", "tags": ["rendering", "resolution"], "homepage": "https://github.com/Ulydev/push", + "license": "MIT", "source": { "repo": "Ulydev/push", "tag": "master", @@ -11,7 +12,11 @@ }, "install": { "files": [ - { "name": "push.lua", "sha256": "c5702771a3275ce3e3909a8414d1fe76024b30ca3943382d06a9b54f5ee3c517", "target": "lib/push.lua" } + { + "name": "push.lua", + "sha256": "c5702771a3275ce3e3909a8414d1fe76024b30ca3943382d06a9b54f5ee3c517", + "target": "lib/push.lua" + } ] }, "require": "lib.push", diff --git a/packages/shove.json b/packages/shove.json index 2c6b806d..b048faec 100644 --- a/packages/shove.json +++ b/packages/shove.json @@ -17,6 +17,7 @@ "love2d-framework" ], "homepage": "https://github.com/oval-tutu/shove", + "license": "MIT", "source": { "repo": "oval-tutu/shove", "tag": "1.0.6", diff --git a/packages/sti.json b/packages/sti.json index 20166453..3bfcfbae 100644 --- a/packages/sti.json +++ b/packages/sti.json @@ -1,18 +1,47 @@ { "type": "love2d-library", - "trust": "known", + "trust": "verified", "description": "Simple Tiled Implementation — loads and renders Tiled maps (.tmx) in LÖVE.", - "tags": ["maps", "tiled", "tilemaps"], + "tags": [ + "sprites", + "maps", + "tiles", + "tiled" + ], "homepage": "https://github.com/karai17/Simple-Tiled-Implementation", + "license": "NOASSERTION", "source": { "repo": "karai17/Simple-Tiled-Implementation", - "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/karai17/Simple-Tiled-Implementation/master/" + "tag": "v1.2.3.0", + "baseUrl": "https://raw.githubusercontent.com/karai17/Simple-Tiled-Implementation/v1.2.3.0/" }, "install": { "files": [ - { "name": "sti/init.lua", "sha256": "b7542ac156b3c2ee24d256ce260f73dc01ab2d1245c4eb4b14c92fcdf487c814", "target": "lib/sti/init.lua" }, - { "name": "sti/utils.lua", "sha256": "ba8abf5a3f5680c6a020234afc248c4fb8945cda993d4cd82b292510e4dab17c", "target": "lib/sti/utils.lua" } + { + "name": "sti/graphics.lua", + "sha256": "c77ed8b4220b2e7c37fdf56612365ce3520ea8183deee92e2f337691f7d20b14", + "target": "lib/sti/graphics.lua" + }, + { + "name": "sti/init.lua", + "sha256": "b65f9cc816bd666bf1ba42b4f50ee14d5fa83f22b3f121eb0b0f07c14ecb8df7", + "target": "lib/sti/init.lua" + }, + { + "name": "sti/plugins/box2d.lua", + "sha256": "5206a9528eeaf19a12b9512f2b8ca2fa9e2af7e3a634eb084b4dd9c0ac070205", + "target": "lib/sti/plugins/box2d.lua" + }, + { + "name": "sti/plugins/bump.lua", + "sha256": "0c2bc5c882aeab0e2dc5ad1019a83aab72ccfe25ef6d9b105b8cb924d2feaab5", + "target": "lib/sti/plugins/bump.lua" + }, + { + "name": "sti/utils.lua", + "sha256": "6993cfde776ec0d1dae4ee53cec8df36fe37801dcf6858f51e443fe65f4ad4c9", + "target": "lib/sti/utils.lua" + } ] }, "require": "lib.sti", diff --git a/packages/windfield.json b/packages/windfield.json deleted file mode 100644 index 0aea9a9e..00000000 --- a/packages/windfield.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "type": "love2d-library", - "trust": "known", - "description": "A physics module for LÖVE that makes Box2D easier to use. Wraps love.physics with a simpler API.", - "tags": ["physics", "box2d"], - "homepage": "https://github.com/SSYGEN/windfield", - "source": { - "repo": "SSYGEN/windfield", - "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/SSYGEN/windfield/master/" - }, - "install": { - "files": [ - { "name": "windfield/init.lua", "sha256": "687c789dceeba3ebb6f5b152b94e9c085268695ed8cc578780d24ec1696f0388", "target": "lib/windfield/init.lua" } - ] - }, - "require": "lib.windfield", - "example": "local wf = require('lib.windfield')" -} From 33685d0730510d0b18cbb1773364e876029f6ae1 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Wed, 13 May 2026 18:08:25 -0400 Subject: [PATCH 05/68] package: add option to add packages from branches --- cli/scripts/add-package.tsx | 16 +++++++++++----- cli/scripts/update-package.tsx | 24 ++++++++++++++++-------- cli/scripts/wizard-shared.tsx | 25 ++++++++++++++++++++----- 3 files changed, 47 insertions(+), 18 deletions(-) diff --git a/cli/scripts/add-package.tsx b/cli/scripts/add-package.tsx index a1ae8cd2..5e86bd88 100644 --- a/cli/scripts/add-package.tsx +++ b/cli/scripts/add-package.tsx @@ -86,6 +86,7 @@ function Wizard() { const [data, setData] = useState>({}); const [fetchedTags, setFetchedTags] = useState([]); const [fetchedFiles, setFetchedFiles] = useState([]); + const [tagsAreBranches, setTagsAreBranches] = useState(false); const [errorMsg, setErrorMsg] = useState(''); const [outputJson, setOutputJson] = useState(''); @@ -143,9 +144,11 @@ function Wizard() { { - const { tags, license } = await fetchRepoMeta(data.repo!); - if (tags.length === 0) throw new Error('No tags found. Push a release tag first.'); - setFetchedTags(tags); + const { tags, branches, defaultBranch, license } = await fetchRepoMeta(data.repo!); + if (tags.length === 0 && branches.length === 0) throw new Error('No tags or branches found.'); + const useBranches = tags.length === 0; + setTagsAreBranches(useBranches); + setFetchedTags(useBranches ? [defaultBranch, ...branches.filter((b) => b !== defaultBranch)] : tags); setData((d) => ({ ...d, license })); setStep('tag'); }} @@ -155,13 +158,16 @@ function Wizard() { } if (step === 'tag') { + const isBranch = tagsAreBranches; return ( { setData((d) => ({ ...d, tag, baseUrl: `https://raw.githubusercontent.com/${d.repo}/${tag}/` })); diff --git a/cli/scripts/update-package.tsx b/cli/scripts/update-package.tsx index f15fe36d..0fc1eb5a 100644 --- a/cli/scripts/update-package.tsx +++ b/cli/scripts/update-package.tsx @@ -77,6 +77,7 @@ function Wizard() { const [outputJson, setOutputJson] = useState(''); // pre-selection state from the loaded package + const [tagsAreBranches, setTagsAreBranches] = useState(false); const [initialTagIndex, setInitialTagIndex] = useState(0); const [initialFileSelected, setInitialFileSelected] = useState | undefined>(undefined); const [initialTargets, setInitialTargets] = useState | undefined>(undefined); @@ -147,11 +148,16 @@ function Wizard() { { - const { tags, license } = await fetchRepoMeta(data.repo!); - if (tags.length === 0) throw new Error('No tags found.'); - setFetchedTags(tags); - // pre-position cursor at the currently pinned tag - const idx = tags.indexOf(data.tag ?? ''); + const { tags, branches, defaultBranch, license } = await fetchRepoMeta(data.repo!); + if (tags.length === 0 && branches.length === 0) throw new Error('No tags or branches found.'); + const useBranches = tags.length === 0; + setTagsAreBranches(useBranches); + const list = useBranches + ? [defaultBranch, ...branches.filter((b) => b !== defaultBranch)] + : tags; + setFetchedTags(list); + // pre-position cursor at the currently pinned tag/branch + const idx = list.indexOf(data.tag ?? ''); setInitialTagIndex(idx >= 0 ? idx : 0); setData((d) => ({ ...d, license })); setStep('tag'); @@ -161,13 +167,15 @@ function Wizard() { ); } - // Step 4: select tag (cursor starts at current pin) + // Step 4: select tag or branch (cursor starts at current pin) if (step === 'tag') { return ( { diff --git a/cli/scripts/wizard-shared.tsx b/cli/scripts/wizard-shared.tsx index 0ef51a7f..a13be2c9 100644 --- a/cli/scripts/wizard-shared.tsx +++ b/cli/scripts/wizard-shared.tsx @@ -464,18 +464,33 @@ export function ChecksumStep({ files, onDone, onError }: ChecksumStepProps) { const GH_HEADERS = { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }; -export async function fetchRepoMeta(repo: string): Promise<{ tags: string[]; license: string }> { - const [tagsRes, repoRes] = await Promise.all([ +export interface RepoMeta { + tags: string[]; + branches: string[]; + defaultBranch: string; + license: string; +} + +export async function fetchRepoMeta(repo: string): Promise { + const [tagsRes, repoRes, branchesRes] = await Promise.all([ fetch(`https://api.github.com/repos/${repo}/tags?per_page=20`, { headers: GH_HEADERS }), fetch(`https://api.github.com/repos/${repo}`, { headers: GH_HEADERS }), + fetch(`https://api.github.com/repos/${repo}/branches?per_page=30`, { headers: GH_HEADERS }), ]); if (!tagsRes.ok) throw new Error(`GitHub API ${tagsRes.status} for ${repo}/tags`); if (!repoRes.ok) throw new Error(`GitHub API ${repoRes.status} for ${repo}`); - const [tagsData, repoData] = await Promise.all([ + if (!branchesRes.ok) throw new Error(`GitHub API ${branchesRes.status} for ${repo}/branches`); + const [tagsData, repoData, branchesData] = await Promise.all([ tagsRes.json() as Promise>, - repoRes.json() as Promise<{ license?: { spdx_id?: string } }>, + repoRes.json() as Promise<{ license?: { spdx_id?: string }; default_branch?: string }>, + branchesRes.json() as Promise>, ]); - return { tags: tagsData.map((t) => t.name), license: repoData.license?.spdx_id ?? 'unknown' }; + return { + tags: tagsData.map((t) => t.name), + branches: branchesData.map((b) => b.name), + defaultBranch: repoData.default_branch ?? 'main', + license: repoData.license?.spdx_id ?? 'unknown', + }; } export async function fetchLuaFiles(repo: string, tag: string): Promise { From b68665c213ff76b6d8c67f88009ad312346310ae Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Wed, 13 May 2026 18:08:43 -0400 Subject: [PATCH 06/68] package: add beehive --- cli/src/generated/registry.json | 53 +++++++++++++++++++++++++++++++++ packages/beehive.json | 53 +++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 packages/beehive.json diff --git a/cli/src/generated/registry.json b/cli/src/generated/registry.json index f20ebcd3..cc3113dc 100644 --- a/cli/src/generated/registry.json +++ b/cli/src/generated/registry.json @@ -59,6 +59,59 @@ "require": "lib.baton", "example": "local baton = require('lib.baton')" }, + "beehive": { + "type": "love2d-library", + "trust": "verified", + "description": "A behavior tree implementation in lua.", + "tags": [ + "ai", + "behavior", + "tree" + ], + "homepage": "https://github.com/drhayes/beehive.lua", + "license": "MIT", + "source": { + "repo": "drhayes/beehive.lua", + "tag": "main", + "baseUrl": "https://raw.githubusercontent.com/drhayes/beehive.lua/main/" + }, + "install": { + "files": [ + { + "name": "beehive/fail.lua", + "sha256": "8ffc086030857f311398ce2abc578f574c0303364e3f68a3e78e84fb00a9e41e", + "target": "lib/beehive/fail.lua" + }, + { + "name": "beehive/invert.lua", + "sha256": "cb11cc9564409a5419ad113155e357c968e8c6f38ec10cae55403e5ac488040e", + "target": "lib/beehive/invert.lua" + }, + { + "name": "beehive/parallel.lua", + "sha256": "fdeeadcf6e437a0d11bfcd4019a50aaac34cb969d72bfa10cf6cb69ee570fe4f", + "target": "lib/beehive/parallel.lua" + }, + { + "name": "beehive/repeat.lua", + "sha256": "fd4a75cfbba982ba4436716b12ca5a102e13b1bcf6f630a3c162ee7ad1afff38", + "target": "lib/beehive/repeat.lua" + }, + { + "name": "beehive/selector.lua", + "sha256": "0b46e7085065e89bd8488a0c1edaf335640c01c8d04b4fe302e8b02556f16b83", + "target": "lib/beehive/selector.lua" + }, + { + "name": "beehive/sequence.lua", + "sha256": "80ee98a5a1f8fd302bb2b0452b145051c4094cd4f776453eb0df7a10aef2ae15", + "target": "lib/beehive/sequence.lua" + } + ] + }, + "require": "lib.beehive.fail", + "example": "local beehive = require('lib.beehive.fail')" + }, "bump": { "type": "love2d-library", "trust": "verified", diff --git a/packages/beehive.json b/packages/beehive.json new file mode 100644 index 00000000..2ac0f215 --- /dev/null +++ b/packages/beehive.json @@ -0,0 +1,53 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "A behavior tree implementation in lua.", + "tags": [ + "ai", + "behavior", + "tree" + ], + "homepage": "https://github.com/drhayes/beehive.lua", + "license": "MIT", + "source": { + "repo": "drhayes/beehive.lua", + "tag": "main", + "baseUrl": "https://raw.githubusercontent.com/drhayes/beehive.lua/main/" + }, + "install": { + "files": [ + { + "name": "beehive/fail.lua", + "sha256": "8ffc086030857f311398ce2abc578f574c0303364e3f68a3e78e84fb00a9e41e", + "target": "lib/beehive/fail.lua" + }, + { + "name": "beehive/invert.lua", + "sha256": "cb11cc9564409a5419ad113155e357c968e8c6f38ec10cae55403e5ac488040e", + "target": "lib/beehive/invert.lua" + }, + { + "name": "beehive/parallel.lua", + "sha256": "fdeeadcf6e437a0d11bfcd4019a50aaac34cb969d72bfa10cf6cb69ee570fe4f", + "target": "lib/beehive/parallel.lua" + }, + { + "name": "beehive/repeat.lua", + "sha256": "fd4a75cfbba982ba4436716b12ca5a102e13b1bcf6f630a3c162ee7ad1afff38", + "target": "lib/beehive/repeat.lua" + }, + { + "name": "beehive/selector.lua", + "sha256": "0b46e7085065e89bd8488a0c1edaf335640c01c8d04b4fe302e8b02556f16b83", + "target": "lib/beehive/selector.lua" + }, + { + "name": "beehive/sequence.lua", + "sha256": "80ee98a5a1f8fd302bb2b0452b145051c4094cd4f776453eb0df7a10aef2ae15", + "target": "lib/beehive/sequence.lua" + } + ] + }, + "require": "lib.beehive.fail", + "example": "local beehive = require('lib.beehive.fail')" +} From 89abfe6f0009a4960cc4952341dc0f6a73ff0709 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Wed, 13 May 2026 18:10:21 -0400 Subject: [PATCH 07/68] package: add cargo --- cli/src/generated/registry.json | 28 ++++++++++++++++++++++++++++ packages/cargo.json | 28 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 packages/cargo.json diff --git a/cli/src/generated/registry.json b/cli/src/generated/registry.json index cc3113dc..f98e45d8 100644 --- a/cli/src/generated/registry.json +++ b/cli/src/generated/registry.json @@ -139,6 +139,34 @@ "require": "lib.bump", "example": "local bump = require('lib.bump')" }, + "cargo": { + "type": "love2d-library", + "trust": "verified", + "description": "LÖVE asset manager", + "tags": [ + "asset", + "manager", + "loader" + ], + "homepage": "https://github.com/bjornbytes/cargo", + "license": "MIT", + "source": { + "repo": "bjornbytes/cargo", + "tag": "v0.1.1", + "baseUrl": "https://raw.githubusercontent.com/bjornbytes/cargo/v0.1.1/" + }, + "install": { + "files": [ + { + "name": "cargo.lua", + "sha256": "50749991281b9052e8f449c55d5bda0c5855f73c9264adb3f42077cf3ff0ab96", + "target": "lib/cargo.lua" + } + ] + }, + "require": "lib.cargo", + "example": "local cargo = require('lib.cargo')" + }, "classic": { "type": "love2d-library", "trust": "verified", diff --git a/packages/cargo.json b/packages/cargo.json new file mode 100644 index 00000000..264b50a5 --- /dev/null +++ b/packages/cargo.json @@ -0,0 +1,28 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "LÖVE asset manager", + "tags": [ + "asset", + "manager", + "loader" + ], + "homepage": "https://github.com/bjornbytes/cargo", + "license": "MIT", + "source": { + "repo": "bjornbytes/cargo", + "tag": "v0.1.1", + "baseUrl": "https://raw.githubusercontent.com/bjornbytes/cargo/v0.1.1/" + }, + "install": { + "files": [ + { + "name": "cargo.lua", + "sha256": "50749991281b9052e8f449c55d5bda0c5855f73c9264adb3f42077cf3ff0ab96", + "target": "lib/cargo.lua" + } + ] + }, + "require": "lib.cargo", + "example": "local cargo = require('lib.cargo')" +} From 67d3681ac316545323788c99a9ccde0f3e9c00da Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Wed, 13 May 2026 18:17:12 -0400 Subject: [PATCH 08/68] package: add branches to tag list --- cli/scripts/add-package.tsx | 26 ++---- cli/scripts/update-package.tsx | 159 ++++++++++++++++++++++---------- cli/scripts/wizard-shared.tsx | 160 ++++++++++++++++++++++----------- 3 files changed, 229 insertions(+), 116 deletions(-) diff --git a/cli/scripts/add-package.tsx b/cli/scripts/add-package.tsx index 5e86bd88..cca83a8a 100644 --- a/cli/scripts/add-package.tsx +++ b/cli/scripts/add-package.tsx @@ -28,8 +28,6 @@ import { buildPackageJson, } from './wizard-shared.js'; -// ─── Steps ──────────────────────────────────────────────────────────────────── - type Step = | 'id' | 'repo' @@ -51,8 +49,6 @@ type Step = const TITLE = 'feather package:add'; const TOTAL = 11; -// ─── Done step ──────────────────────────────────────────────────────────────── - function DoneStep({ id, onExit }: { id: string; onExit: () => void }) { useEffect(() => { const t = setTimeout(onExit, 300); @@ -78,15 +74,13 @@ function DoneStep({ id, onExit }: { id: string; onExit: () => void }) { ); } -// ─── Wizard ─────────────────────────────────────────────────────────────────── - function Wizard() { const { exit } = useApp(); const [step, setStep] = useState('id'); const [data, setData] = useState>({}); const [fetchedTags, setFetchedTags] = useState([]); + const [fetchedLabels, setFetchedLabels] = useState([]); const [fetchedFiles, setFetchedFiles] = useState([]); - const [tagsAreBranches, setTagsAreBranches] = useState(false); const [errorMsg, setErrorMsg] = useState(''); const [outputJson, setOutputJson] = useState(''); @@ -146,9 +140,11 @@ function Wizard() { run={async () => { const { tags, branches, defaultBranch, license } = await fetchRepoMeta(data.repo!); if (tags.length === 0 && branches.length === 0) throw new Error('No tags or branches found.'); - const useBranches = tags.length === 0; - setTagsAreBranches(useBranches); - setFetchedTags(useBranches ? [defaultBranch, ...branches.filter((b) => b !== defaultBranch)] : tags); + const orderedBranches = [defaultBranch, ...branches.filter((b) => b !== defaultBranch)]; + const values = [...tags, ...orderedBranches]; + const labels = [...tags.map((t) => t), ...orderedBranches.map((b) => `⎇ ${b}`)]; + setFetchedTags(values); + setFetchedLabels(labels); setData((d) => ({ ...d, license })); setStep('tag'); }} @@ -158,17 +154,15 @@ function Wizard() { } if (step === 'tag') { - const isBranch = tagsAreBranches; return ( { setData((d) => ({ ...d, tag, baseUrl: `https://raw.githubusercontent.com/${d.repo}/${tag}/` })); setStep('trust'); @@ -368,7 +362,5 @@ function Wizard() { ); } -// ─── Entry ──────────────────────────────────────────────────────────────────── - const { waitUntilExit } = render(); await waitUntilExit(); diff --git a/cli/scripts/update-package.tsx b/cli/scripts/update-package.tsx index 0fc1eb5a..cbb8eb53 100644 --- a/cli/scripts/update-package.tsx +++ b/cli/scripts/update-package.tsx @@ -15,20 +15,39 @@ import { readFileSync, readdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; import { - root, packagesDir, - type FormData, type FileEntry, - TextInputStep, SelectStep, MultiSelectStep, - AutoStep, TargetsStep, ReviewStep, ChecksumStep, - fetchRepoMeta, fetchLuaFiles, buildPackageJson, + root, + packagesDir, + type FormData, + type FileEntry, + TextInputStep, + SelectStep, + MultiSelectStep, + AutoStep, + TargetsStep, + ReviewStep, + ChecksumStep, + fetchRepoMeta, + fetchLuaFiles, + buildPackageJson, } from './wizard-shared.js'; -// ─── Types ──────────────────────────────────────────────────────────────────── - type Step = - | 'pick' | 'repo' | 'fetch-tags' | 'tag' | 'trust' - | 'description' | 'pkg-tags' | 'fetch-files' | 'files' - | 'targets' | 'require' | 'fetch-checksums' | 'review' - | 'write' | 'done' | 'error'; + | 'pick' + | 'repo' + | 'fetch-tags' + | 'tag' + | 'trust' + | 'description' + | 'pkg-tags' + | 'fetch-files' + | 'files' + | 'targets' + | 'require' + | 'fetch-checksums' + | 'review' + | 'write' + | 'done' + | 'error'; interface RawPackage { trust?: string; @@ -45,8 +64,6 @@ interface RawPackage { const TITLE = 'feather package:update'; const TOTAL = 11; -// ─── Done step ──────────────────────────────────────────────────────────────── - function DoneStep({ id, onExit }: { id: string; onExit: () => void }) { useEffect(() => { const t = setTimeout(onExit, 300); @@ -55,8 +72,12 @@ function DoneStep({ id, onExit }: { id: string; onExit: () => void }) { return ( - ✔ Done! - {' '}packages/{id}.json updated + + ✔ Done! + + + {' '}packages/{id}.json updated + {' '}Registry regenerated Commit packages/{id}.json and cli/src/generated/registry.json @@ -65,8 +86,6 @@ function DoneStep({ id, onExit }: { id: string; onExit: () => void }) { ); } -// ─── Wizard ─────────────────────────────────────────────────────────────────── - function Wizard() { const { exit } = useApp(); const [step, setStep] = useState('pick'); @@ -77,12 +96,15 @@ function Wizard() { const [outputJson, setOutputJson] = useState(''); // pre-selection state from the loaded package - const [tagsAreBranches, setTagsAreBranches] = useState(false); + const [fetchedLabels, setFetchedLabels] = useState([]); const [initialTagIndex, setInitialTagIndex] = useState(0); const [initialFileSelected, setInitialFileSelected] = useState | undefined>(undefined); const [initialTargets, setInitialTargets] = useState | undefined>(undefined); - const handleError = (msg: string) => { setErrorMsg(msg); setStep('error'); }; + const handleError = (msg: string) => { + setErrorMsg(msg); + setStep('error'); + }; // Step 1: pick which package to update if (step === 'pick') { @@ -93,7 +115,9 @@ function Wizard() { return ( { @@ -125,7 +149,9 @@ function Wizard() { if (step === 'repo') { return ( { const { tags, branches, defaultBranch, license } = await fetchRepoMeta(data.repo!); if (tags.length === 0 && branches.length === 0) throw new Error('No tags or branches found.'); - const useBranches = tags.length === 0; - setTagsAreBranches(useBranches); - const list = useBranches - ? [defaultBranch, ...branches.filter((b) => b !== defaultBranch)] - : tags; - setFetchedTags(list); + const orderedBranches = [defaultBranch, ...branches.filter((b) => b !== defaultBranch)]; + const values = [...tags, ...orderedBranches]; + const labels = [...tags.map((t) => t), ...orderedBranches.map((b) => `⎇ ${b}`)]; + setFetchedTags(values); + setFetchedLabels(labels); // pre-position cursor at the currently pinned tag/branch - const idx = list.indexOf(data.tag ?? ''); + const idx = values.indexOf(data.tag ?? ''); setInitialTagIndex(idx >= 0 ? idx : 0); setData((d) => ({ ...d, license })); setStep('tag'); @@ -171,12 +196,13 @@ function Wizard() { if (step === 'tag') { return ( { setData((d) => ({ ...d, tag, baseUrl: `https://raw.githubusercontent.com/${d.repo}/${tag}/` })); @@ -191,7 +217,9 @@ function Wizard() { const currentIdx = ['verified', 'known'].indexOf(data.trust ?? 'known'); return ( (v ? null : 'Required')} - onSubmit={(description) => { setData((d) => ({ ...d, description })); setStep('pkg-tags'); }} + onSubmit={(description) => { + setData((d) => ({ ...d, description })); + setStep('pkg-tags'); + }} /> ); } @@ -221,13 +254,18 @@ function Wizard() { if (step === 'pkg-tags') { return ( (v ? null : 'Required')} onSubmit={(tagStr) => { - const tags = tagStr.split(',').map((t) => t.trim()).filter(Boolean); + const tags = tagStr + .split(',') + .map((t) => t.trim()) + .filter(Boolean); setData((d) => ({ ...d, tags })); setStep('fetch-files'); }} @@ -246,7 +284,14 @@ function Wizard() { setFetchedFiles(files); // pre-select files that were already installed const existing = new Set(data.selectedFiles ?? []); - setInitialFileSelected(new Set(files.map((f, i) => ({ f, i })).filter(({ f }) => existing.has(f)).map(({ i }) => i))); + setInitialFileSelected( + new Set( + files + .map((f, i) => ({ f, i })) + .filter(({ f }) => existing.has(f)) + .map(({ i }) => i), + ), + ); setStep('files'); }} onError={handleError} @@ -258,7 +303,9 @@ function Wizard() { if (step === 'files') { return ( { setData((d) => ({ ...d, targetMap })); setStep('require'); }} + onSubmit={(targetMap) => { + setData((d) => ({ ...d, targetMap })); + setStep('require'); + }} /> ); } @@ -288,7 +340,9 @@ function Wizard() { if (step === 'require') { return ( (v ? null : 'Required')} @@ -327,10 +381,15 @@ function Wizard() { if (step === 'review') { return ( setStep('write')} - onAbort={() => { setErrorMsg('Aborted.'); setStep('error'); }} + onAbort={() => { + setErrorMsg('Aborted.'); + setStep('error'); + }} /> ); } @@ -343,7 +402,9 @@ function Wizard() { run={async () => { writeFileSync(join(packagesDir, `${data.id}.json`), outputJson + '\n', 'utf8'); const result = spawnSync(process.execPath, [join(root, 'scripts', 'generate-registry.mjs')], { - cwd: root, stdio: 'pipe', encoding: 'utf8', + cwd: root, + stdio: 'pipe', + encoding: 'utf8', }); if (result.status !== 0) throw new Error(result.stderr || 'generate-registry.mjs failed'); setStep('done'); @@ -357,13 +418,13 @@ function Wizard() { return ( - ✖ Error + + ✖ Error + {errorMsg} ); } -// ─── Entry ──────────────────────────────────────────────────────────────────── - const { waitUntilExit } = render(); await waitUntilExit(); diff --git a/cli/scripts/wizard-shared.tsx b/cli/scripts/wizard-shared.tsx index a13be2c9..7dd58380 100644 --- a/cli/scripts/wizard-shared.tsx +++ b/cli/scripts/wizard-shared.tsx @@ -12,8 +12,6 @@ export const __dirname = dirname(fileURLToPath(import.meta.url)); export const root = resolve(__dirname, '../..'); export const packagesDir = join(root, 'packages'); -// ─── Types ──────────────────────────────────────────────────────────────────── - export interface FileEntry { name: string; target: string; @@ -39,13 +37,12 @@ export interface FormData { export type InkKey = Parameters[0]>[1]; -// ─── Header & Hint ─────────────────────────────────────────────────────────── - export function Header({ step, total, title }: { step: number; total: number; title?: string }) { return ( - {' '}{title ?? 'feather package:add'} + {' '} + {title ?? 'feather package:add'} {` Step ${step} of ${total}`} @@ -61,8 +58,6 @@ export function Hint({ children }: { children: string }) { ); } -// ─── Cursor-aware text input ────────────────────────────────────────────────── - export function useTextInput(initial: string) { const [value, setValue] = useState(initial); const [cursor, setCursor] = useState(initial.length); @@ -73,8 +68,14 @@ export function useTextInput(initial: string) { }; const handleKey = (input: string, key: InkKey) => { - if (key.leftArrow) { setCursor((c) => Math.max(0, c - 1)); return true; } - if (key.rightArrow) { setCursor((c) => Math.min(value.length, c + 1)); return true; } + if (key.leftArrow) { + setCursor((c) => Math.max(0, c - 1)); + return true; + } + if (key.rightArrow) { + setCursor((c) => Math.min(value.length, c + 1)); + return true; + } if (key.backspace) { if (cursor === 0) return true; const pos = cursor; @@ -117,8 +118,6 @@ export function CursorText({ before, at, after }: { before: string; at: string; ); } -// ─── TextInputStep ──────────────────────────────────────────────────────────── - interface TextInputStepProps { stepNum: number; total: number; @@ -130,14 +129,26 @@ interface TextInputStepProps { title?: string; } -export function TextInputStep({ stepNum, total, label, hint, defaultValue = '', validate, onSubmit, title }: TextInputStepProps) { +export function TextInputStep({ + stepNum, + total, + label, + hint, + defaultValue = '', + validate, + onSubmit, + title, +}: TextInputStepProps) { const input = useTextInput(defaultValue); const [error, setError] = useState(null); useInput((char, key) => { if (key.return) { const err = validate ? validate(input.value.trim()) : null; - if (err) { setError(err); return; } + if (err) { + setError(err); + return; + } onSubmit(input.value.trim()); return; } @@ -148,13 +159,23 @@ export function TextInputStep({ stepNum, total, label, hint, defaultValue = '', return (
- {' '}{label} + + {' '} + {label} + {hint && {hint}} {' '} - {error && {' ✖ '}{error}} + {error && ( + + + {' ✖ '} + {error} + + + )} {' ←→ move · Backspace/Delete edit · Enter confirm'} @@ -162,20 +183,29 @@ export function TextInputStep({ stepNum, total, label, hint, defaultValue = '', ); } -// ─── SelectStep ─────────────────────────────────────────────────────────────── - interface SelectStepProps { stepNum: number; total: number; label: string; hint?: string; options: string[]; + labels?: string[]; // display labels parallel to options; falls back to option value initialIndex?: number; onSelect: (value: string) => void; title?: string; } -export function SelectStep({ stepNum, total, label, hint, options, initialIndex = 0, onSelect, title }: SelectStepProps) { +export function SelectStep({ + stepNum, + total, + label, + hint, + options, + labels, + initialIndex = 0, + onSelect, + title, +}: SelectStepProps) { const [cursor, setCursor] = useState(initialIndex); useInput((_, key) => { @@ -187,13 +217,18 @@ export function SelectStep({ stepNum, total, label, hint, options, initialIndex return (
- {' '}{label} + + {' '} + {label} + {hint && {hint}} {options.map((opt, i) => ( - {' '}{i === cursor ? '❯ ' : ' '}{opt} + {' '} + {i === cursor ? '❯ ' : ' '} + {labels?.[i] ?? opt} ))} @@ -205,8 +240,6 @@ export function SelectStep({ stepNum, total, label, hint, options, initialIndex ); } -// ─── MultiSelectStep ────────────────────────────────────────────────────────── - interface MultiSelectStepProps { stepNum: number; total: number; @@ -218,11 +251,18 @@ interface MultiSelectStepProps { title?: string; } -export function MultiSelectStep({ stepNum, total, label, hint, options, initialSelected, onSubmit, title }: MultiSelectStepProps) { +export function MultiSelectStep({ + stepNum, + total, + label, + hint, + options, + initialSelected, + onSubmit, + title, +}: MultiSelectStepProps) { const [cursor, setCursor] = useState(0); - const [selected, setSelected] = useState>( - initialSelected ?? new Set(options.map((_, i) => i)), - ); + const [selected, setSelected] = useState>(initialSelected ?? new Set(options.map((_, i) => i))); useInput((input, key) => { if (key.upArrow) setCursor((c) => Math.max(0, c - 1)); @@ -230,6 +270,7 @@ export function MultiSelectStep({ stepNum, total, label, hint, options, initialS if (input === ' ') { setSelected((s) => { const next = new Set(s); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions next.has(cursor) ? next.delete(cursor) : next.add(cursor); return next; }); @@ -243,13 +284,17 @@ export function MultiSelectStep({ stepNum, total, label, hint, options, initialS return (
- {' '}{label} + + {' '} + {label} + {hint && {hint}} {options.map((opt, i) => ( - {' '}{i === cursor ? '❯ ' : ' '} + {' '} + {i === cursor ? '❯ ' : ' '} {selected.has(i) ? '◉ ' : '○ '} {opt} @@ -263,8 +308,6 @@ export function MultiSelectStep({ stepNum, total, label, hint, options, initialS ); } -// ─── Spinner & AutoStep ─────────────────────────────────────────────────────── - const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; export function Spinner({ label }: { label: string }) { @@ -312,8 +355,6 @@ export function AutoStep({ label, run, onError }: AutoStepProps) { ); } -// ─── TargetsStep ────────────────────────────────────────────────────────────── - interface TargetsStepProps { stepNum: number; total: number; @@ -325,8 +366,7 @@ interface TargetsStepProps { } export function TargetsStep({ stepNum, total, id, files, initialTargets, onSubmit, title }: TargetsStepProps) { - const defaultTarget = (f: string) => - initialTargets?.[f] ?? (files.length === 1 ? `lib/${f}` : `lib/${id}/${f}`); + const defaultTarget = (f: string) => initialTargets?.[f] ?? (files.length === 1 ? `lib/${f}` : `lib/${id}/${f}`); const [index, setIndex] = useState(0); const [targets, setTargets] = useState>( @@ -339,8 +379,14 @@ export function TargetsStep({ stepNum, total, id, files, initialTargets, onSubmi const advance = () => { const val = textInput.value.trim(); - if (!val) { setError('Target path is required'); return; } - if (!val.endsWith('.lua')) { setError('Target path must end in .lua'); return; } + if (!val) { + setError('Target path is required'); + return; + } + if (!val.endsWith('.lua')) { + setError('Target path must end in .lua'); + return; + } const next = { ...targets, [file]: val }; setTargets(next); if (index + 1 < files.length) { @@ -354,7 +400,10 @@ export function TargetsStep({ stepNum, total, id, files, initialTargets, onSubmi }; useInput((char, key) => { - if (key.return) { advance(); return; } + if (key.return) { + advance(); + return; + } textInput.handleKey(char, key); setError(null); }); @@ -368,7 +417,14 @@ export function TargetsStep({ stepNum, total, id, files, initialTargets, onSubmi {' '} - {error && {' ✖ '}{error}} + {error && ( + + + {' ✖ '} + {error} + + + )} {' ←→ move · Backspace/Delete edit · Enter confirm'} @@ -376,8 +432,6 @@ export function TargetsStep({ stepNum, total, id, files, initialTargets, onSubmi ); } -// ─── ReviewStep ─────────────────────────────────────────────────────────────── - interface ReviewStepProps { stepNum: number; total: number; @@ -399,7 +453,10 @@ export function ReviewStep({ stepNum, total, json, onConfirm, onAbort, title }: {' '}Review generated package {json.split('\n').map((line, i) => ( - {' '}{line} + + {' '} + {line} + ))} @@ -409,8 +466,6 @@ export function ReviewStep({ stepNum, total, json, onConfirm, onAbort, title }: ); } -// ─── ChecksumStep ───────────────────────────────────────────────────────────── - interface ChecksumStepProps { files: Array<{ name: string; url: string }>; onDone: (checksums: Record) => void; @@ -447,11 +502,20 @@ export function ChecksumStep({ files, onDone, onError }: ChecksumStepProps) { return ( {sha ? ( - {' ✔ '}{f.name} + + {' ✔ '} + {f.name} + ) : isActive ? ( - {' '} + + {' '} + + ) : ( - {' ○ '}{f.name} + + {' ○ '} + {f.name} + )} ); @@ -460,8 +524,6 @@ export function ChecksumStep({ files, onDone, onError }: ChecksumStepProps) { ); } -// ─── GitHub API helpers ─────────────────────────────────────────────────────── - const GH_HEADERS = { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }; export interface RepoMeta { @@ -505,8 +567,6 @@ export async function fetchLuaFiles(repo: string, tag: string): Promise Date: Wed, 13 May 2026 18:17:56 -0400 Subject: [PATCH 09/68] package: add lua state machine pacakge --- cli/src/generated/registry.json | 28 ++++++++++++++++++++++++++++ packages/lua-state-machine.json | 28 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 packages/lua-state-machine.json diff --git a/cli/src/generated/registry.json b/cli/src/generated/registry.json index f98e45d8..4bf1807f 100644 --- a/cli/src/generated/registry.json +++ b/cli/src/generated/registry.json @@ -497,6 +497,34 @@ "require": "lib.inspect", "example": "local inspect = require('lib.inspect')" }, + "lua-state-machine": { + "type": "love2d-library", + "trust": "verified", + "description": "A finite state machine lua micro framework", + "tags": [ + "state", + "machine", + "finite" + ], + "homepage": "https://github.com/kyleconroy/lua-state-machine", + "license": "MIT", + "source": { + "repo": "kyleconroy/lua-state-machine", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/kyleconroy/lua-state-machine/master/" + }, + "install": { + "files": [ + { + "name": "statemachine.lua", + "sha256": "95affa1ad7a9e2a4629cce4b321fbf49cf521cce6e9be5e92f035f7e64f9275c", + "target": "lib/statemachine.lua" + } + ] + }, + "require": "lib.statemachine", + "example": "local lua_state_machine = require('lib.statemachine')" + }, "lume": { "type": "love2d-library", "trust": "verified", diff --git a/packages/lua-state-machine.json b/packages/lua-state-machine.json new file mode 100644 index 00000000..79bbfa22 --- /dev/null +++ b/packages/lua-state-machine.json @@ -0,0 +1,28 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "A finite state machine lua micro framework", + "tags": [ + "state", + "machine", + "finite" + ], + "homepage": "https://github.com/kyleconroy/lua-state-machine", + "license": "MIT", + "source": { + "repo": "kyleconroy/lua-state-machine", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/kyleconroy/lua-state-machine/master/" + }, + "install": { + "files": [ + { + "name": "statemachine.lua", + "sha256": "95affa1ad7a9e2a4629cce4b321fbf49cf521cce6e9be5e92f035f7e64f9275c", + "target": "lib/statemachine.lua" + } + ] + }, + "require": "lib.statemachine", + "example": "local lua_state_machine = require('lib.statemachine')" +} From 656fb024465bfeaf4f33cc9d9567e6d3962d2694 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Wed, 13 May 2026 18:29:34 -0400 Subject: [PATCH 10/68] package: add SYSL-text --- cli/src/generated/registry.json | 45 +++++++++++++++++++++++++++++++++ packages/README.md | 2 +- packages/sysl-text.json | 45 +++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 packages/sysl-text.json diff --git a/cli/src/generated/registry.json b/cli/src/generated/registry.json index 4bf1807f..f3fad773 100644 --- a/cli/src/generated/registry.json +++ b/cli/src/generated/registry.json @@ -696,6 +696,51 @@ }, "require": "lib.sti", "example": "local sti = require('lib.sti')" + }, + "sysl-text": { + "type": "love2d-library", + "trust": "verified", + "description": "Text rendering with tag support", + "tags": [ + "text", + "draw", + "love2d", + "textboxes", + "sprite-text" + ], + "homepage": "https://github.com/sysl-dev/SYSL-Text", + "license": "MIT", + "source": { + "repo": "sysl-dev/SYSL-Text", + "tag": "v2.2", + "baseUrl": "https://raw.githubusercontent.com/sysl-dev/SYSL-Text/v2.2/" + }, + "install": { + "files": [ + { + "name": "example/library/slog-frame.lua", + "sha256": "d37119014d865d4e829a2757d8ec2974c3991afdcbcafe558decafb651066c5e", + "target": "lib/sysl-text/slog-frame.lua" + }, + { + "name": "example/library/slog-icon.lua", + "sha256": "bf48f58f25930d20a367361ef5decceb4e8e377aed27093e3ea9f5a2c34dfc1a", + "target": "lib/sysl-text/slog-icon.lua" + }, + { + "name": "example/library/slog-pixel.lua", + "sha256": "c95586e30a45ea9b5b49dfca1089c61c9ec1a59981e9526febdc56ae5354e925", + "target": "lib/sysl-text/slog-pixel.lua" + }, + { + "name": "example/library/slog-text.lua", + "sha256": "10134b45499d925de5b177ae13874fd026dfc476f1eae5ba1b64874a49ec4ad9", + "target": "lib/sysl-text/slog-text.lua" + } + ] + }, + "require": "lib.sysl-text.slog-text", + "example": "local sysl_text = require('lib.sysl-text.slog-text')" } } } diff --git a/packages/README.md b/packages/README.md index 57d6e580..2f99a8b7 100644 --- a/packages/README.md +++ b/packages/README.md @@ -189,7 +189,7 @@ Auditing 4 installed packages… --- -## Available Packages +## Some Available Packages | Package | Trust | Description | | ---------------- | -------- | ----------------------------------------------- | diff --git a/packages/sysl-text.json b/packages/sysl-text.json new file mode 100644 index 00000000..6b864f5e --- /dev/null +++ b/packages/sysl-text.json @@ -0,0 +1,45 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "Text rendering with tag support", + "tags": [ + "text", + "draw", + "love2d", + "textboxes", + "sprite-text" + ], + "homepage": "https://github.com/sysl-dev/SYSL-Text", + "license": "MIT", + "source": { + "repo": "sysl-dev/SYSL-Text", + "tag": "v2.2", + "baseUrl": "https://raw.githubusercontent.com/sysl-dev/SYSL-Text/v2.2/" + }, + "install": { + "files": [ + { + "name": "example/library/slog-frame.lua", + "sha256": "d37119014d865d4e829a2757d8ec2974c3991afdcbcafe558decafb651066c5e", + "target": "lib/sysl-text/slog-frame.lua" + }, + { + "name": "example/library/slog-icon.lua", + "sha256": "bf48f58f25930d20a367361ef5decceb4e8e377aed27093e3ea9f5a2c34dfc1a", + "target": "lib/sysl-text/slog-icon.lua" + }, + { + "name": "example/library/slog-pixel.lua", + "sha256": "c95586e30a45ea9b5b49dfca1089c61c9ec1a59981e9526febdc56ae5354e925", + "target": "lib/sysl-text/slog-pixel.lua" + }, + { + "name": "example/library/slog-text.lua", + "sha256": "10134b45499d925de5b177ae13874fd026dfc476f1eae5ba1b64874a49ec4ad9", + "target": "lib/sysl-text/slog-text.lua" + } + ] + }, + "require": "lib.sysl-text.slog-text", + "example": "local sysl_text = require('lib.sysl-text.slog-text')" +} From 6cf92d300fc7bcc8f75561cf9cbf4b8068de5063 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Wed, 13 May 2026 18:34:23 -0400 Subject: [PATCH 11/68] package: add love dialogue package --- cli/src/generated/registry.json | 95 +++++++++++++++++++++++++++++++++ packages/love-dialogue.json | 95 +++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 packages/love-dialogue.json diff --git a/cli/src/generated/registry.json b/cli/src/generated/registry.json index f3fad773..44864b52 100644 --- a/cli/src/generated/registry.json +++ b/cli/src/generated/registry.json @@ -497,6 +497,101 @@ "require": "lib.inspect", "example": "local inspect = require('lib.inspect')" }, + "love-dialogue": { + "type": "love2d-library", + "trust": "verified", + "description": "Dialogue based Game Engine for Love2d with custom scripting language for dialogues", + "tags": [ + "text", + "dialogue", + "system", + "tag", + "talk" + ], + "homepage": "https://github.com/Miisan-png/Love-Dialogue", + "license": "unknown", + "source": { + "repo": "Miisan-png/Love-Dialogue", + "tag": "main", + "baseUrl": "https://raw.githubusercontent.com/Miisan-png/Love-Dialogue/main/" + }, + "install": { + "files": [ + { + "name": "LoveDialogue/9patch.lua", + "sha256": "dd882490cab2a6850b4b5f95a3fc1845b090a37773fcd4347d5bb496efe2839b", + "target": "lib/love-dialogue/9patch.lua" + }, + { + "name": "LoveDialogue/DialogueConstants.lua", + "sha256": "9335210bfd61d9e39fcfdbd7ca6c7e55f5897565848260dffd3080e62ee9d0c5", + "target": "lib/love-dialogue/DialogueConstants.lua" + }, + { + "name": "LoveDialogue/Logic.lua", + "sha256": "85701aa89d7892f5a55095b70a992036a241504e3470caeb58caae7f886b434f", + "target": "lib/love-dialogue/Logic.lua" + }, + { + "name": "LoveDialogue/LoveCharacter.lua", + "sha256": "b606202a72f9b9c349bcee793bd6bfc7a36cc64059480a5a4071f81ba442d7d6", + "target": "lib/love-dialogue/LoveCharacter.lua" + }, + { + "name": "LoveDialogue/LoveDialogue.lua", + "sha256": "9bea894af34bca1db4f04e94c38fd8800a36093bac8730f5f32c5b963a9b7043", + "target": "lib/love-dialogue/LoveDialogue.lua" + }, + { + "name": "LoveDialogue/LoveDialogueParser.lua", + "sha256": "35548075414928e077b9d8136186444fbcbfb11c89c8b92eb92487e1cc78bddc", + "target": "lib/love-dialogue/LoveDialogueParser.lua" + }, + { + "name": "LoveDialogue/PluginManager.lua", + "sha256": "c66684888259a4eea25546369ffe9c8540f778b4e16fa5adad2a5fe3cda1dc8c", + "target": "lib/love-dialogue/PluginManager.lua" + }, + { + "name": "LoveDialogue/ResourceManager.lua", + "sha256": "5366b1bdb8a033fc1c2bfd787c326f1031aa9e285ff73c8c4695ac8e97dd32bb", + "target": "lib/love-dialogue/ResourceManager.lua" + }, + { + "name": "LoveDialogue/TextEffects.lua", + "sha256": "399aacf0ff2e8f8615692ef86a184f5e2b730f0ab1fc5961e24ee8a8564f11d6", + "target": "lib/love-dialogue/TextEffects.lua" + }, + { + "name": "LoveDialogue/ThemeParser.lua", + "sha256": "1303f4057eb47c9606c66560207308b7cc6b2ca12d4bcb98ee9c023d29e03af9", + "target": "lib/love-dialogue/ThemeParser.lua" + }, + { + "name": "LoveDialogue/Transition.lua", + "sha256": "296a3c8f33d184387487d46e6f5b22205a565293cdbb2226eaab6052e2ef5d84", + "target": "lib/love-dialogue/Transition.lua" + }, + { + "name": "LoveDialogue/Tween.lua", + "sha256": "2d5be30a9a4d3132373505c3ebde532ffe3009db2e7f8b9ce45680f01f5fef0f", + "target": "lib/love-dialogue/Tween.lua" + }, + { + "name": "LoveDialogue/init.lua", + "sha256": "23f6c1bf3ac8e9d9d99443caa750acee158a415242208e93f8e4f21893b5df79", + "target": "lib/love-dialogue/init.lua" + }, + { + "name": "LoveDialogue/plugins/DebugPlugin.lua", + "sha256": "5dc1d9f7a01d9f2e92aff9647ba43742cebb4095c17a4a48a2d698b9c638cebd", + "target": "lib/love-dialogue/plugins/DebugPlugin.lua" + } + ] + }, + "require": "lib.love-dialogue", + "example": "local love_dialogue = require('lib.love-dialogue')" + }, "lua-state-machine": { "type": "love2d-library", "trust": "verified", diff --git a/packages/love-dialogue.json b/packages/love-dialogue.json new file mode 100644 index 00000000..428b7f90 --- /dev/null +++ b/packages/love-dialogue.json @@ -0,0 +1,95 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "Dialogue based Game Engine for Love2d with custom scripting language for dialogues", + "tags": [ + "text", + "dialogue", + "system", + "tag", + "talk" + ], + "homepage": "https://github.com/Miisan-png/Love-Dialogue", + "license": "unknown", + "source": { + "repo": "Miisan-png/Love-Dialogue", + "tag": "main", + "baseUrl": "https://raw.githubusercontent.com/Miisan-png/Love-Dialogue/main/" + }, + "install": { + "files": [ + { + "name": "LoveDialogue/9patch.lua", + "sha256": "dd882490cab2a6850b4b5f95a3fc1845b090a37773fcd4347d5bb496efe2839b", + "target": "lib/love-dialogue/9patch.lua" + }, + { + "name": "LoveDialogue/DialogueConstants.lua", + "sha256": "9335210bfd61d9e39fcfdbd7ca6c7e55f5897565848260dffd3080e62ee9d0c5", + "target": "lib/love-dialogue/DialogueConstants.lua" + }, + { + "name": "LoveDialogue/Logic.lua", + "sha256": "85701aa89d7892f5a55095b70a992036a241504e3470caeb58caae7f886b434f", + "target": "lib/love-dialogue/Logic.lua" + }, + { + "name": "LoveDialogue/LoveCharacter.lua", + "sha256": "b606202a72f9b9c349bcee793bd6bfc7a36cc64059480a5a4071f81ba442d7d6", + "target": "lib/love-dialogue/LoveCharacter.lua" + }, + { + "name": "LoveDialogue/LoveDialogue.lua", + "sha256": "9bea894af34bca1db4f04e94c38fd8800a36093bac8730f5f32c5b963a9b7043", + "target": "lib/love-dialogue/LoveDialogue.lua" + }, + { + "name": "LoveDialogue/LoveDialogueParser.lua", + "sha256": "35548075414928e077b9d8136186444fbcbfb11c89c8b92eb92487e1cc78bddc", + "target": "lib/love-dialogue/LoveDialogueParser.lua" + }, + { + "name": "LoveDialogue/PluginManager.lua", + "sha256": "c66684888259a4eea25546369ffe9c8540f778b4e16fa5adad2a5fe3cda1dc8c", + "target": "lib/love-dialogue/PluginManager.lua" + }, + { + "name": "LoveDialogue/ResourceManager.lua", + "sha256": "5366b1bdb8a033fc1c2bfd787c326f1031aa9e285ff73c8c4695ac8e97dd32bb", + "target": "lib/love-dialogue/ResourceManager.lua" + }, + { + "name": "LoveDialogue/TextEffects.lua", + "sha256": "399aacf0ff2e8f8615692ef86a184f5e2b730f0ab1fc5961e24ee8a8564f11d6", + "target": "lib/love-dialogue/TextEffects.lua" + }, + { + "name": "LoveDialogue/ThemeParser.lua", + "sha256": "1303f4057eb47c9606c66560207308b7cc6b2ca12d4bcb98ee9c023d29e03af9", + "target": "lib/love-dialogue/ThemeParser.lua" + }, + { + "name": "LoveDialogue/Transition.lua", + "sha256": "296a3c8f33d184387487d46e6f5b22205a565293cdbb2226eaab6052e2ef5d84", + "target": "lib/love-dialogue/Transition.lua" + }, + { + "name": "LoveDialogue/Tween.lua", + "sha256": "2d5be30a9a4d3132373505c3ebde532ffe3009db2e7f8b9ce45680f01f5fef0f", + "target": "lib/love-dialogue/Tween.lua" + }, + { + "name": "LoveDialogue/init.lua", + "sha256": "23f6c1bf3ac8e9d9d99443caa750acee158a415242208e93f8e4f21893b5df79", + "target": "lib/love-dialogue/init.lua" + }, + { + "name": "LoveDialogue/plugins/DebugPlugin.lua", + "sha256": "5dc1d9f7a01d9f2e92aff9647ba43742cebb4095c17a4a48a2d698b9c638cebd", + "target": "lib/love-dialogue/plugins/DebugPlugin.lua" + } + ] + }, + "require": "lib.love-dialogue", + "example": "local love_dialogue = require('lib.love-dialogue')" +} From afd9212698edd9ff8cb693b55cafa0e1831a3cf4 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Wed, 13 May 2026 18:53:28 -0400 Subject: [PATCH 12/68] package: add submodules option --- cli/scripts/add-package.tsx | 21 +++- cli/scripts/update-package.tsx | 31 +++++- cli/scripts/wizard-shared.tsx | 196 ++++++++++++++++++++++++++++++++- 3 files changed, 241 insertions(+), 7 deletions(-) diff --git a/cli/scripts/add-package.tsx b/cli/scripts/add-package.tsx index cca83a8a..545f40ce 100644 --- a/cli/scripts/add-package.tsx +++ b/cli/scripts/add-package.tsx @@ -21,6 +21,7 @@ import { MultiSelectStep, AutoStep, TargetsStep, + SubpackagesStep, ReviewStep, ChecksumStep, fetchRepoMeta, @@ -40,6 +41,7 @@ type Step = | 'files' | 'targets' | 'require' + | 'subpkgs' | 'fetch-checksums' | 'review' | 'write' @@ -47,7 +49,7 @@ type Step = | 'error'; const TITLE = 'feather package:add'; -const TOTAL = 11; +const TOTAL = 12; function DoneStep({ id, onExit }: { id: string; onExit: () => void }) { useEffect(() => { @@ -288,6 +290,21 @@ function Wizard() { onSubmit={(req) => { const example = `local ${data.id!.replace(/[.-]/g, '_')} = require('${req}')`; setData((d) => ({ ...d, require: req, example })); + setStep('subpkgs'); + }} + /> + ); + } + + if (step === 'subpkgs') { + return ( + { + setData((d) => ({ ...d, subpackages })); setStep('fetch-checksums'); }} /> @@ -318,7 +335,7 @@ function Wizard() { if (step === 'review') { return ( }; require?: string; example?: string; + subpackages?: Record; } const TITLE = 'feather package:update'; -const TOTAL = 11; +const TOTAL = 12; function DoneStep({ id, onExit }: { id: string; onExit: () => void }) { useEffect(() => { @@ -100,6 +103,7 @@ function Wizard() { const [initialTagIndex, setInitialTagIndex] = useState(0); const [initialFileSelected, setInitialFileSelected] = useState | undefined>(undefined); const [initialTargets, setInitialTargets] = useState | undefined>(undefined); + const [initialSubpkgs, setInitialSubpkgs] = useState | undefined>(undefined); const handleError = (msg: string) => { setErrorMsg(msg); @@ -124,6 +128,7 @@ function Wizard() { const raw: RawPackage = JSON.parse(readFileSync(join(packagesDir, `${id}.json`), 'utf8')); const existingFiles = raw.install?.files ?? []; setInitialTargets(Object.fromEntries(existingFiles.map((f) => [f.name, f.target]))); + setInitialSubpkgs(raw.subpackages ?? undefined); setData({ id, repo: raw.source?.repo ?? '', @@ -138,6 +143,7 @@ function Wizard() { targetMap: Object.fromEntries(existingFiles.map((f) => [f.name, f.target])), require: raw.require ?? '', example: raw.example ?? '', + subpackages: raw.subpackages, }); setStep('repo'); }} @@ -349,13 +355,30 @@ function Wizard() { onSubmit={(req) => { const example = `local ${data.id!.replace(/[.-]/g, '_')} = require('${req}')`; setData((d) => ({ ...d, require: req, example })); + setStep('subpkgs'); + }} + /> + ); + } + + // Step 12: submodules + if (step === 'subpkgs') { + return ( + { + setData((d) => ({ ...d, subpackages })); setStep('fetch-checksums'); }} /> ); } - // Step 12: compute checksums (always fresh — tag may have changed) + // Step 13: compute checksums (always fresh — tag may have changed) if (step === 'fetch-checksums') { const fileList = (data.selectedFiles ?? []).map((name) => ({ name, url: data.baseUrl! + name })); return ( @@ -377,11 +400,11 @@ function Wizard() { ); } - // Step 13: review + // Step 14: review if (step === 'review') { return ( ; } export type InkKey = Parameters[0]>[1]; @@ -524,6 +525,195 @@ export function ChecksumStep({ files, onDone, onError }: ChecksumStepProps) { ); } +type SubPkgPhase = 'ask' | 'id' | 'files' | 'require' | 'add-more'; + +interface SubpackagesStepProps { + stepNum: number; + total: number; + title?: string; + selectedFiles: string[]; + initialSubpackages?: Record; + onSubmit: (subpackages: Record) => void; +} + +export function SubpackagesStep({ + stepNum, + total, + title, + selectedFiles, + initialSubpackages, + onSubmit, +}: SubpackagesStepProps) { + const hasInitial = Object.keys(initialSubpackages ?? {}).length > 0; + const [phase, setPhase] = useState(hasInitial ? 'add-more' : 'ask'); + const [accumulated, setAccumulated] = useState>( + initialSubpackages ?? {}, + ); + const [currentId, setCurrentId] = useState(''); + const [currentFiles, setCurrentFiles] = useState([]); + const idInput = useTextInput(''); + const reqInput = useTextInput(''); + const [fileCursor, setFileCursor] = useState(0); + const [fileSelected, setFileSelected] = useState>(new Set()); + const [idError, setIdError] = useState(null); + const [reqError, setReqError] = useState(null); + + useInput((input, key) => { + if (phase === 'ask' || phase === 'add-more') { + if (input === 'y' || input === 'Y') { + idInput.reset(''); + setIdError(null); + setPhase('id'); + } else if (input === 'n' || input === 'N' || key.return || key.escape) { + onSubmit(accumulated); + } + } else if (phase === 'id') { + if (key.return) { + const val = idInput.value.trim(); + if (!val) { setIdError('Required'); return; } + setCurrentId(val); + setFileSelected(new Set()); + setFileCursor(0); + setPhase('files'); + return; + } + if (idInput.handleKey(input, key)) setIdError(null); + } else if (phase === 'files') { + if (key.upArrow) setFileCursor((c) => Math.max(0, c - 1)); + if (key.downArrow) setFileCursor((c) => Math.min(selectedFiles.length - 1, c + 1)); + if (input === ' ') { + setFileSelected((s) => { + const next = new Set(s); + next.has(fileCursor) ? next.delete(fileCursor) : next.add(fileCursor); + return next; + }); + } + if (key.return) { + const chosen = selectedFiles.filter((_, i) => fileSelected.has(i)); + if (chosen.length === 0) return; + setCurrentFiles(chosen); + const suggested = chosen[0]!.replace(/\.lua$/, '').replace(/\//g, '.'); + reqInput.reset(suggested); + setReqError(null); + setPhase('require'); + } + } else if (phase === 'require') { + if (key.return) { + const val = reqInput.value.trim(); + if (!val) { setReqError('Required'); return; } + setAccumulated((a) => ({ ...a, [currentId]: { files: currentFiles, require: val } })); + setPhase('add-more'); + return; + } + if (reqInput.handleKey(input, key)) setReqError(null); + } + }); + + const accKeys = Object.keys(accumulated); + + if (phase === 'ask') { + return ( + +
+ {' '}Define submodules? + Like hump.camera, hump.timer — named subsets of the package files + + {' y = define submodules · n/Enter = skip'} + + + ); + } + + if (phase === 'add-more') { + return ( + +
+ {' '}Add another submodule? + + {accKeys.map((k) => ( + + {' ✔ '} + {k} + {' '} + ({accumulated[k]!.files.length} file{accumulated[k]!.files.length === 1 ? '' : 's'}) + + ))} + + + {' y = add another · n/Enter = done'} + + + ); + } + + if (phase === 'id') { + return ( + +
+ {' '}Submodule ID + e.g. hump.camera, hump.timer + + {' '} + + + {idError && ( + + {' ✖ '}{idError} + + )} + + {' ←→ move · Backspace/Delete edit · Enter confirm'} + + + ); + } + + if (phase === 'files') { + return ( + +
+ {' '}Files for {currentId} + Select which files belong to this submodule + + {selectedFiles.map((f, i) => ( + + + {' '} + {i === fileCursor ? '❯ ' : ' '} + {fileSelected.has(i) ? '◉ ' : '○ '} + {f} + + + ))} + + + {' ↑↓ navigate · Space toggle · Enter confirm'} + + + ); + } + + return ( + +
+ {' '}Require path for {currentId} + e.g. lib.hump.camera + + {' '} + + + {reqError && ( + + {' ✖ '}{reqError} + + )} + + {' ←→ move · Backspace/Delete edit · Enter confirm'} + + + ); +} + const GH_HEADERS = { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }; export interface RepoMeta { @@ -568,7 +758,7 @@ export async function fetchLuaFiles(repo: string, tag: string): Promise = { type: 'love2d-library', trust: data.trust, description: data.description, @@ -582,4 +772,8 @@ export function buildPackageJson(data: FormData): object { require: data.require, example: data.example, }; + if (data.subpackages && Object.keys(data.subpackages).length > 0) { + obj.subpackages = data.subpackages; + } + return obj; } From 8aed3d95bcdd8b836e4fd71757a6f9a059a1c678 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Wed, 13 May 2026 18:53:47 -0400 Subject: [PATCH 13/68] package: add knife package --- cli/src/generated/registry.json | 588 ++++++++++++++++++++++++++++++++ packages/knife.json | 144 ++++++++ 2 files changed, 732 insertions(+) create mode 100644 packages/knife.json diff --git a/cli/src/generated/registry.json b/cli/src/generated/registry.json index 44864b52..062393c1 100644 --- a/cli/src/generated/registry.json +++ b/cli/src/generated/registry.json @@ -497,6 +497,594 @@ "require": "lib.inspect", "example": "local inspect = require('lib.inspect')" }, + "knife.base": { + "parent": "knife", + "type": "love2d-library", + "trust": "known", + "description": "knife — base module", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + }, + "install": { + "files": [ + { + "name": "knife/base.lua", + "sha256": "9cdc89da1a37296aa0f57c1f28c4123be6052604ca2b60306a98e926666e2fd1", + "target": "lib/knife/base.lua" + } + ] + }, + "require": "lib.knife.base", + "example": "local base = require('lib.knife.base')" + }, + "knife.behavior": { + "parent": "knife", + "type": "love2d-library", + "trust": "known", + "description": "knife — behavior module", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + }, + "install": { + "files": [ + { + "name": "knife/behavior.lua", + "sha256": "9ee69bf0b7966061e75716811727138772dc766ba8d03ed6a5d98df3491967e4", + "target": "lib/knife/behavior.lua" + } + ] + }, + "require": "lib.knife.behavior", + "example": "local behavior = require('lib.knife.behavior')" + }, + "knife.bind": { + "parent": "knife", + "type": "love2d-library", + "trust": "known", + "description": "knife — bind module", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + }, + "install": { + "files": [ + { + "name": "knife/bind.lua", + "sha256": "01923de7538a127d4bdc23ec6dccea9c791928e88dcd61dd4cd7cc97287c639a", + "target": "lib/knife/bind.lua" + } + ] + }, + "require": "lib.knife.bind", + "example": "local bind = require('lib.knife.bind')" + }, + "knife.chain": { + "parent": "knife", + "type": "love2d-library", + "trust": "known", + "description": "knife — chain module", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + }, + "install": { + "files": [ + { + "name": "knife/chain.lua", + "sha256": "70143d8618f7be18025da8cc073714fe1c3c3a2d173c2d04f415e9d0d85bd964", + "target": "lib/knife/chain.lua" + } + ] + }, + "require": "lib.knife.chain", + "example": "local chain = require('lib.knife.chain')" + }, + "knife.convoke": { + "parent": "knife", + "type": "love2d-library", + "trust": "known", + "description": "knife — convoke module", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + }, + "install": { + "files": [ + { + "name": "knife/convoke.lua", + "sha256": "76c1862861999b8523cb060c964fded1986dbcd04c08610e9547bddcef751776", + "target": "lib/knife/convoke.lua" + } + ] + }, + "require": "lib.knife.convoke", + "example": "local convoke = require('lib.knife.convoke')" + }, + "knife.event": { + "parent": "knife", + "type": "love2d-library", + "trust": "known", + "description": "knife — event module", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + }, + "install": { + "files": [ + { + "name": "knife/event.lua", + "sha256": "962e2f6b47fc6a81cdc07d28d5fef6cd40132dcb28fd3d34c834616cd15be74f", + "target": "lib/knife/event.lua" + } + ] + }, + "require": "lib.knife.event", + "example": "local event = require('lib.knife.event')" + }, + "knife.gun": { + "parent": "knife", + "type": "love2d-library", + "trust": "known", + "description": "knife — gun module", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + }, + "install": { + "files": [ + { + "name": "knife/gun.lua", + "sha256": "9f0754e9a11fddb7c376b4e92d9d583a1720dc038e080957a598c43093a4064b", + "target": "lib/knife/gun.lua" + } + ] + }, + "require": "lib.knife.gun", + "example": "local gun = require('lib.knife.gun')" + }, + "knife.memoize": { + "parent": "knife", + "type": "love2d-library", + "trust": "known", + "description": "knife — memoize module", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + }, + "install": { + "files": [ + { + "name": "knife/memoize.lua", + "sha256": "45c857737cf3b3c4e7bd3994c882a142761153932ba4695b57aa4eb9198ccfd0", + "target": "lib/knife/memoize.lua" + } + ] + }, + "require": "lib.knife.memoize", + "example": "local memoize = require('lib.knife.memoize')" + }, + "knife.serialize": { + "parent": "knife", + "type": "love2d-library", + "trust": "known", + "description": "knife — serialize module", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + }, + "install": { + "files": [ + { + "name": "knife/serialize.lua", + "sha256": "472901fa35c502af4bfddc2ee0cc723dc91fb5ffbd06ddde22fa512bd94c4870", + "target": "lib/knife/serialize.lua" + } + ] + }, + "require": "lib.knife.serialize", + "example": "local serialize = require('lib.knife.serialize')" + }, + "knife.system": { + "parent": "knife", + "type": "love2d-library", + "trust": "known", + "description": "knife — system module", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + }, + "install": { + "files": [ + { + "name": "knife/system.lua", + "sha256": "bc9d8981679bdb752f4e1b2031f42b6e08a40f411981f23f6f148588edb23ea2", + "target": "lib/knife/system.lua" + } + ] + }, + "require": "lib.knife.system", + "example": "local system = require('lib.knife.system')" + }, + "knife.test": { + "parent": "knife", + "type": "love2d-library", + "trust": "known", + "description": "knife — test module", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + }, + "install": { + "files": [ + { + "name": "knife/test.lua", + "sha256": "581c6c679cf5b711068880b36c242da9a4648c1525a9d615bbaeed0f79b4dbcd", + "target": "lib/knife/test.lua" + } + ] + }, + "require": "lib.knife.test", + "example": "local test = require('lib.knife.test')" + }, + "knife.timer": { + "parent": "knife", + "type": "love2d-library", + "trust": "known", + "description": "knife — timer module", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + }, + "install": { + "files": [ + { + "name": "knife/timer.lua", + "sha256": "6c0a1f59404f7c1e3612afc80fb443b543546a5aa14a1d200a679e8684b5aaac", + "target": "lib/knife/timer.lua" + } + ] + }, + "require": "lib.knife.timer", + "example": "local timer = require('lib.knife.timer')" + }, + "knife": { + "type": "love2d-library", + "trust": "known", + "description": "A collection of useful micro-modules for Lua.", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + }, + "install": { + "files": [ + { + "name": "knife/base.lua", + "sha256": "9cdc89da1a37296aa0f57c1f28c4123be6052604ca2b60306a98e926666e2fd1", + "target": "lib/knife/base.lua" + }, + { + "name": "knife/behavior.lua", + "sha256": "9ee69bf0b7966061e75716811727138772dc766ba8d03ed6a5d98df3491967e4", + "target": "lib/knife/behavior.lua" + }, + { + "name": "knife/bind.lua", + "sha256": "01923de7538a127d4bdc23ec6dccea9c791928e88dcd61dd4cd7cc97287c639a", + "target": "lib/knife/bind.lua" + }, + { + "name": "knife/chain.lua", + "sha256": "70143d8618f7be18025da8cc073714fe1c3c3a2d173c2d04f415e9d0d85bd964", + "target": "lib/knife/chain.lua" + }, + { + "name": "knife/convoke.lua", + "sha256": "76c1862861999b8523cb060c964fded1986dbcd04c08610e9547bddcef751776", + "target": "lib/knife/convoke.lua" + }, + { + "name": "knife/event.lua", + "sha256": "962e2f6b47fc6a81cdc07d28d5fef6cd40132dcb28fd3d34c834616cd15be74f", + "target": "lib/knife/event.lua" + }, + { + "name": "knife/gun.lua", + "sha256": "9f0754e9a11fddb7c376b4e92d9d583a1720dc038e080957a598c43093a4064b", + "target": "lib/knife/gun.lua" + }, + { + "name": "knife/memoize.lua", + "sha256": "45c857737cf3b3c4e7bd3994c882a142761153932ba4695b57aa4eb9198ccfd0", + "target": "lib/knife/memoize.lua" + }, + { + "name": "knife/serialize.lua", + "sha256": "472901fa35c502af4bfddc2ee0cc723dc91fb5ffbd06ddde22fa512bd94c4870", + "target": "lib/knife/serialize.lua" + }, + { + "name": "knife/system.lua", + "sha256": "bc9d8981679bdb752f4e1b2031f42b6e08a40f411981f23f6f148588edb23ea2", + "target": "lib/knife/system.lua" + }, + { + "name": "knife/test.lua", + "sha256": "581c6c679cf5b711068880b36c242da9a4648c1525a9d615bbaeed0f79b4dbcd", + "target": "lib/knife/test.lua" + }, + { + "name": "knife/timer.lua", + "sha256": "6c0a1f59404f7c1e3612afc80fb443b543546a5aa14a1d200a679e8684b5aaac", + "target": "lib/knife/timer.lua" + } + ] + }, + "subpackages": [ + "knife.base", + "knife.behavior", + "knife.bind", + "knife.chain", + "knife.convoke", + "knife.event", + "knife.gun", + "knife.memoize", + "knife.serialize", + "knife.system", + "knife.test", + "knife.timer" + ], + "require": "lib.knife.base", + "example": "local knife = require('lib.knife.base')" + }, "love-dialogue": { "type": "love2d-library", "trust": "verified", diff --git a/packages/knife.json b/packages/knife.json new file mode 100644 index 00000000..471646c0 --- /dev/null +++ b/packages/knife.json @@ -0,0 +1,144 @@ +{ + "type": "love2d-library", + "trust": "known", + "description": "A collection of useful micro-modules for Lua.", + "tags": [ + "class", + "oop", + "behavior", + "ai", + "state", + "machine", + "bind", + "chain", + "coroutines", + "event", + "memoize", + "entity", + "test", + "timer" + ], + "homepage": "https://github.com/airstruck/knife", + "license": "MIT", + "source": { + "repo": "airstruck/knife", + "tag": "master", + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + }, + "install": { + "files": [ + { + "name": "knife/base.lua", + "sha256": "9cdc89da1a37296aa0f57c1f28c4123be6052604ca2b60306a98e926666e2fd1", + "target": "lib/knife/base.lua" + }, + { + "name": "knife/behavior.lua", + "sha256": "9ee69bf0b7966061e75716811727138772dc766ba8d03ed6a5d98df3491967e4", + "target": "lib/knife/behavior.lua" + }, + { + "name": "knife/bind.lua", + "sha256": "01923de7538a127d4bdc23ec6dccea9c791928e88dcd61dd4cd7cc97287c639a", + "target": "lib/knife/bind.lua" + }, + { + "name": "knife/chain.lua", + "sha256": "70143d8618f7be18025da8cc073714fe1c3c3a2d173c2d04f415e9d0d85bd964", + "target": "lib/knife/chain.lua" + }, + { + "name": "knife/convoke.lua", + "sha256": "76c1862861999b8523cb060c964fded1986dbcd04c08610e9547bddcef751776", + "target": "lib/knife/convoke.lua" + }, + { + "name": "knife/event.lua", + "sha256": "962e2f6b47fc6a81cdc07d28d5fef6cd40132dcb28fd3d34c834616cd15be74f", + "target": "lib/knife/event.lua" + }, + { + "name": "knife/gun.lua", + "sha256": "9f0754e9a11fddb7c376b4e92d9d583a1720dc038e080957a598c43093a4064b", + "target": "lib/knife/gun.lua" + }, + { + "name": "knife/memoize.lua", + "sha256": "45c857737cf3b3c4e7bd3994c882a142761153932ba4695b57aa4eb9198ccfd0", + "target": "lib/knife/memoize.lua" + }, + { + "name": "knife/serialize.lua", + "sha256": "472901fa35c502af4bfddc2ee0cc723dc91fb5ffbd06ddde22fa512bd94c4870", + "target": "lib/knife/serialize.lua" + }, + { + "name": "knife/system.lua", + "sha256": "bc9d8981679bdb752f4e1b2031f42b6e08a40f411981f23f6f148588edb23ea2", + "target": "lib/knife/system.lua" + }, + { + "name": "knife/test.lua", + "sha256": "581c6c679cf5b711068880b36c242da9a4648c1525a9d615bbaeed0f79b4dbcd", + "target": "lib/knife/test.lua" + }, + { + "name": "knife/timer.lua", + "sha256": "6c0a1f59404f7c1e3612afc80fb443b543546a5aa14a1d200a679e8684b5aaac", + "target": "lib/knife/timer.lua" + } + ] + }, + "require": "lib.knife.base", + "example": "local knife = require('lib.knife.base')", + "subpackages": { + "knife.base": { + "files": ["knife/base.lua"], + "require": "lib.knife.base" + }, + "knife.behavior": { + "files": ["knife/behavior.lua"], + "require": "lib.knife.behavior" + }, + "knife.bind": { + "files": ["knife/bind.lua"], + "require": "lib.knife.bind" + }, + "knife.chain": { + "files": ["knife/chain.lua"], + "require": "lib.knife.chain" + }, + "knife.convoke": { + "files": ["knife/convoke.lua"], + "require": "lib.knife.convoke" + }, + "knife.event": { + "files": ["knife/event.lua"], + "require": "lib.knife.event" + }, + "knife.gun": { + "files": ["knife/gun.lua"], + "require": "lib.knife.gun" + }, + "knife.memoize": { + "files": ["knife/memoize.lua"], + "require": "lib.knife.memoize" + }, + "knife.serialize": { + "files": ["knife/serialize.lua"], + "require": "lib.knife.serialize" + }, + "knife.system": { + "files": ["knife/system.lua"], + "require": "lib.knife.system" + }, + "knife.test": { + "files": ["knife/test.lua"], + "require": "lib.knife.test" + }, + "knife.timer": { + "files": ["knife/timer.lua"], + "require": "lib.knife.timer" + } + } +} From 6b63233af2f2e5c749c3be7fefd89224ccf5d966 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Wed, 13 May 2026 19:02:01 -0400 Subject: [PATCH 14/68] package: use commit hash instead of tag or branch --- cli/scripts/add-package.tsx | 22 ++++++- cli/scripts/update-package.tsx | 25 ++++++- cli/scripts/wizard-shared.tsx | 17 ++++- cli/src/generated/registry.json | 108 ++++++++++++++++++++----------- cli/src/lib/package/registry.ts | 1 + packages/anim8.json | 8 ++- packages/baton.json | 3 +- packages/beehive.json | 3 +- packages/bump.json | 8 ++- packages/cargo.json | 3 +- packages/classic.json | 8 ++- packages/flux.json | 8 ++- packages/hump.json | 35 +++++++--- packages/inspect.json | 8 ++- packages/knife.json | 51 +++++++++++---- packages/love-dialogue.json | 3 +- packages/lua-state-machine.json | 3 +- packages/lume.json | 8 ++- packages/middleclass.json | 8 ++- packages/push.json | 8 ++- packages/shove.json | 3 +- packages/sti.json | 3 +- packages/sysl-text.json | 3 +- scripts/backfill-commit-shas.mjs | 85 ++++++++++++++++++++++++ 24 files changed, 347 insertions(+), 85 deletions(-) create mode 100644 scripts/backfill-commit-shas.mjs diff --git a/cli/scripts/add-package.tsx b/cli/scripts/add-package.tsx index 545f40ce..274aa909 100644 --- a/cli/scripts/add-package.tsx +++ b/cli/scripts/add-package.tsx @@ -25,6 +25,7 @@ import { ReviewStep, ChecksumStep, fetchRepoMeta, + fetchCommitSha, fetchLuaFiles, buildPackageJson, } from './wizard-shared.js'; @@ -34,6 +35,7 @@ type Step = | 'repo' | 'fetch-tags' | 'tag' + | 'resolve-commit' | 'trust' | 'description' | 'pkg-tags' @@ -166,9 +168,27 @@ function Wizard() { options={fetchedTags} labels={fetchedLabels} onSelect={(tag) => { - setData((d) => ({ ...d, tag, baseUrl: `https://raw.githubusercontent.com/${d.repo}/${tag}/` })); + setData((d) => ({ ...d, tag })); + setStep('resolve-commit'); + }} + /> + ); + } + + if (step === 'resolve-commit') { + return ( + { + const commitSha = await fetchCommitSha(data.repo!, data.tag!); + setData((d) => ({ + ...d, + commitSha, + baseUrl: `https://raw.githubusercontent.com/${d.repo}/${commitSha}/`, + })); setStep('trust'); }} + onError={handleError} /> ); } diff --git a/cli/scripts/update-package.tsx b/cli/scripts/update-package.tsx index f99acc2d..2709605f 100644 --- a/cli/scripts/update-package.tsx +++ b/cli/scripts/update-package.tsx @@ -28,6 +28,7 @@ import { ReviewStep, ChecksumStep, fetchRepoMeta, + fetchCommitSha, fetchLuaFiles, buildPackageJson, } from './wizard-shared.js'; @@ -37,6 +38,7 @@ type Step = | 'repo' | 'fetch-tags' | 'tag' + | 'resolve-commit' | 'trust' | 'description' | 'pkg-tags' @@ -57,7 +59,7 @@ interface RawPackage { tags?: string[]; homepage?: string; license?: string; - source?: { repo?: string; tag?: string; baseUrl?: string }; + source?: { repo?: string; tag?: string; commitSha?: string; baseUrl?: string }; install?: { files?: Array<{ name: string; sha256: string; target: string }> }; require?: string; example?: string; @@ -135,6 +137,7 @@ function Wizard() { homepage: raw.homepage ?? `https://github.com/${raw.source?.repo ?? ''}`, license: raw.license ?? '', tag: raw.source?.tag ?? '', + commitSha: raw.source?.commitSha ?? '', baseUrl: raw.source?.baseUrl ?? '', trust: (raw.trust as FormData['trust']) ?? 'known', description: raw.description ?? '', @@ -211,9 +214,27 @@ function Wizard() { labels={fetchedLabels} initialIndex={initialTagIndex} onSelect={(tag) => { - setData((d) => ({ ...d, tag, baseUrl: `https://raw.githubusercontent.com/${d.repo}/${tag}/` })); + setData((d) => ({ ...d, tag })); + setStep('resolve-commit'); + }} + /> + ); + } + + if (step === 'resolve-commit') { + return ( + { + const commitSha = await fetchCommitSha(data.repo!, data.tag!); + setData((d) => ({ + ...d, + commitSha, + baseUrl: `https://raw.githubusercontent.com/${d.repo}/${commitSha}/`, + })); setStep('trust'); }} + onError={handleError} /> ); } diff --git a/cli/scripts/wizard-shared.tsx b/cli/scripts/wizard-shared.tsx index 888e1e37..9dd892a1 100644 --- a/cli/scripts/wizard-shared.tsx +++ b/cli/scripts/wizard-shared.tsx @@ -24,6 +24,7 @@ export interface FormData { homepage: string; license: string; tag: string; + commitSha: string; baseUrl: string; trust: 'verified' | 'known'; description: string; @@ -745,6 +746,15 @@ export async function fetchRepoMeta(repo: string): Promise { }; } +export async function fetchCommitSha(repo: string, ref: string): Promise { + const res = await fetch(`https://api.github.com/repos/${repo}/commits/${encodeURIComponent(ref)}`, { + headers: GH_HEADERS, + }); + if (!res.ok) throw new Error(`GitHub API ${res.status} resolving ${repo}@${ref} to commit SHA`); + const data = (await res.json()) as { sha: string }; + return data.sha; +} + export async function fetchLuaFiles(repo: string, tag: string): Promise { const res = await fetch(`https://api.github.com/repos/${repo}/git/trees/${tag}?recursive=1`, { headers: GH_HEADERS, @@ -765,7 +775,12 @@ export function buildPackageJson(data: FormData): object { tags: data.tags, homepage: data.homepage, license: data.license, - source: { repo: data.repo, tag: data.tag, baseUrl: data.baseUrl }, + source: { + repo: data.repo, + tag: data.tag, + commitSha: data.commitSha, + baseUrl: `https://raw.githubusercontent.com/${data.repo}/${data.commitSha}/`, + }, install: { files: data.files.map((f) => ({ name: f.name, sha256: f.sha256, target: f.target })), }, diff --git a/cli/src/generated/registry.json b/cli/src/generated/registry.json index 062393c1..d0794279 100644 --- a/cli/src/generated/registry.json +++ b/cli/src/generated/registry.json @@ -15,7 +15,8 @@ "source": { "repo": "kikito/anim8", "tag": "v2.3.1", - "baseUrl": "https://raw.githubusercontent.com/kikito/anim8/v2.3.1/" + "baseUrl": "https://raw.githubusercontent.com/kikito/anim8/c1c12ec45fde28ffd2d6967675adcb3c3e501fa7/", + "commitSha": "c1c12ec45fde28ffd2d6967675adcb3c3e501fa7" }, "install": { "files": [ @@ -45,7 +46,8 @@ "source": { "repo": "tesselode/baton", "tag": "v1.0.2", - "baseUrl": "https://raw.githubusercontent.com/tesselode/baton/v1.0.2/" + "baseUrl": "https://raw.githubusercontent.com/tesselode/baton/3defe19f8f0da1a457ba86095e6f6a3c7ee6a8e9/", + "commitSha": "3defe19f8f0da1a457ba86095e6f6a3c7ee6a8e9" }, "install": { "files": [ @@ -73,7 +75,8 @@ "source": { "repo": "drhayes/beehive.lua", "tag": "main", - "baseUrl": "https://raw.githubusercontent.com/drhayes/beehive.lua/main/" + "baseUrl": "https://raw.githubusercontent.com/drhayes/beehive.lua/00877150688ce42fffbaeeb76d7a4cb87e88da60/", + "commitSha": "00877150688ce42fffbaeeb76d7a4cb87e88da60" }, "install": { "files": [ @@ -125,7 +128,8 @@ "source": { "repo": "kikito/bump.lua", "tag": "v3.1.7", - "baseUrl": "https://raw.githubusercontent.com/kikito/bump.lua/v3.1.7/" + "baseUrl": "https://raw.githubusercontent.com/kikito/bump.lua/72d6b13dfa525564f6e1a533d71eaa8235a5e96c/", + "commitSha": "72d6b13dfa525564f6e1a533d71eaa8235a5e96c" }, "install": { "files": [ @@ -153,7 +157,8 @@ "source": { "repo": "bjornbytes/cargo", "tag": "v0.1.1", - "baseUrl": "https://raw.githubusercontent.com/bjornbytes/cargo/v0.1.1/" + "baseUrl": "https://raw.githubusercontent.com/bjornbytes/cargo/8014bc0ad7d4639c112e5d00fac6c9ac68b0d696/", + "commitSha": "8014bc0ad7d4639c112e5d00fac6c9ac68b0d696" }, "install": { "files": [ @@ -180,7 +185,8 @@ "source": { "repo": "rxi/classic", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/rxi/classic/master/" + "baseUrl": "https://raw.githubusercontent.com/rxi/classic/e5610756c98ac2f8facd7ab90c94e1a097ecd2c6/", + "commitSha": "e5610756c98ac2f8facd7ab90c94e1a097ecd2c6" }, "install": { "files": [ @@ -207,7 +213,8 @@ "source": { "repo": "rxi/flux", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/rxi/flux/master/" + "baseUrl": "https://raw.githubusercontent.com/rxi/flux/bb330231b87eabf84fbd68322f13a6320db30a41/", + "commitSha": "bb330231b87eabf84fbd68322f13a6320db30a41" }, "install": { "files": [ @@ -238,7 +245,8 @@ "source": { "repo": "vrld/hump", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/vrld/hump/master/" + "baseUrl": "https://raw.githubusercontent.com/vrld/hump/08937cc0ecf72d1a964a8de6cd552c5e136bf0d4/", + "commitSha": "08937cc0ecf72d1a964a8de6cd552c5e136bf0d4" }, "install": { "files": [ @@ -269,7 +277,8 @@ "source": { "repo": "vrld/hump", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/vrld/hump/master/" + "baseUrl": "https://raw.githubusercontent.com/vrld/hump/08937cc0ecf72d1a964a8de6cd552c5e136bf0d4/", + "commitSha": "08937cc0ecf72d1a964a8de6cd552c5e136bf0d4" }, "install": { "files": [ @@ -300,7 +309,8 @@ "source": { "repo": "vrld/hump", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/vrld/hump/master/" + "baseUrl": "https://raw.githubusercontent.com/vrld/hump/08937cc0ecf72d1a964a8de6cd552c5e136bf0d4/", + "commitSha": "08937cc0ecf72d1a964a8de6cd552c5e136bf0d4" }, "install": { "files": [ @@ -331,7 +341,8 @@ "source": { "repo": "vrld/hump", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/vrld/hump/master/" + "baseUrl": "https://raw.githubusercontent.com/vrld/hump/08937cc0ecf72d1a964a8de6cd552c5e136bf0d4/", + "commitSha": "08937cc0ecf72d1a964a8de6cd552c5e136bf0d4" }, "install": { "files": [ @@ -362,7 +373,8 @@ "source": { "repo": "vrld/hump", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/vrld/hump/master/" + "baseUrl": "https://raw.githubusercontent.com/vrld/hump/08937cc0ecf72d1a964a8de6cd552c5e136bf0d4/", + "commitSha": "08937cc0ecf72d1a964a8de6cd552c5e136bf0d4" }, "install": { "files": [ @@ -393,7 +405,8 @@ "source": { "repo": "vrld/hump", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/vrld/hump/master/" + "baseUrl": "https://raw.githubusercontent.com/vrld/hump/08937cc0ecf72d1a964a8de6cd552c5e136bf0d4/", + "commitSha": "08937cc0ecf72d1a964a8de6cd552c5e136bf0d4" }, "install": { "files": [ @@ -423,7 +436,8 @@ "source": { "repo": "vrld/hump", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/vrld/hump/master/" + "baseUrl": "https://raw.githubusercontent.com/vrld/hump/08937cc0ecf72d1a964a8de6cd552c5e136bf0d4/", + "commitSha": "08937cc0ecf72d1a964a8de6cd552c5e136bf0d4" }, "install": { "files": [ @@ -483,7 +497,8 @@ "source": { "repo": "kikito/inspect.lua", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/kikito/inspect.lua/master/" + "baseUrl": "https://raw.githubusercontent.com/kikito/inspect.lua/a8ca3120dfec48801036eaeff9335ab7a096dd24/", + "commitSha": "a8ca3120dfec48801036eaeff9335ab7a096dd24" }, "install": { "files": [ @@ -523,7 +538,8 @@ "source": { "repo": "airstruck/knife", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" }, "install": { "files": [ @@ -563,7 +579,8 @@ "source": { "repo": "airstruck/knife", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" }, "install": { "files": [ @@ -603,7 +620,8 @@ "source": { "repo": "airstruck/knife", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" }, "install": { "files": [ @@ -643,7 +661,8 @@ "source": { "repo": "airstruck/knife", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" }, "install": { "files": [ @@ -683,7 +702,8 @@ "source": { "repo": "airstruck/knife", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" }, "install": { "files": [ @@ -723,7 +743,8 @@ "source": { "repo": "airstruck/knife", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" }, "install": { "files": [ @@ -763,7 +784,8 @@ "source": { "repo": "airstruck/knife", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" }, "install": { "files": [ @@ -803,7 +825,8 @@ "source": { "repo": "airstruck/knife", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" }, "install": { "files": [ @@ -843,7 +866,8 @@ "source": { "repo": "airstruck/knife", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" }, "install": { "files": [ @@ -883,7 +907,8 @@ "source": { "repo": "airstruck/knife", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" }, "install": { "files": [ @@ -923,7 +948,8 @@ "source": { "repo": "airstruck/knife", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" }, "install": { "files": [ @@ -963,7 +989,8 @@ "source": { "repo": "airstruck/knife", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" }, "install": { "files": [ @@ -1002,7 +1029,8 @@ "source": { "repo": "airstruck/knife", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" }, "install": { "files": [ @@ -1101,7 +1129,8 @@ "source": { "repo": "Miisan-png/Love-Dialogue", "tag": "main", - "baseUrl": "https://raw.githubusercontent.com/Miisan-png/Love-Dialogue/main/" + "baseUrl": "https://raw.githubusercontent.com/Miisan-png/Love-Dialogue/1d1cbd1f6730fbaf73fab18a2d33ee3525abfb75/", + "commitSha": "1d1cbd1f6730fbaf73fab18a2d33ee3525abfb75" }, "install": { "files": [ @@ -1194,7 +1223,8 @@ "source": { "repo": "kyleconroy/lua-state-machine", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/kyleconroy/lua-state-machine/master/" + "baseUrl": "https://raw.githubusercontent.com/kyleconroy/lua-state-machine/96fc10ee123479cf2e5fb8ffe9cdde2fd6df0877/", + "commitSha": "96fc10ee123479cf2e5fb8ffe9cdde2fd6df0877" }, "install": { "files": [ @@ -1221,7 +1251,8 @@ "source": { "repo": "rxi/lume", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/rxi/lume/master/" + "baseUrl": "https://raw.githubusercontent.com/rxi/lume/98847e7812cf28d3d64b289b03fad71dc704547d/", + "commitSha": "98847e7812cf28d3d64b289b03fad71dc704547d" }, "install": { "files": [ @@ -1248,7 +1279,8 @@ "source": { "repo": "kikito/middleclass", "tag": "v4.1.1", - "baseUrl": "https://raw.githubusercontent.com/kikito/middleclass/v4.1.1/" + "baseUrl": "https://raw.githubusercontent.com/kikito/middleclass/dc0fb3612da5d41866a2f06076a4ecea5614ef78/", + "commitSha": "dc0fb3612da5d41866a2f06076a4ecea5614ef78" }, "install": { "files": [ @@ -1275,7 +1307,8 @@ "source": { "repo": "Ulydev/push", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/Ulydev/push/master/" + "baseUrl": "https://raw.githubusercontent.com/Ulydev/push/9c165816a14c868339c3cd0d22eed65c313c8bf8/", + "commitSha": "9c165816a14c868339c3cd0d22eed65c313c8bf8" }, "install": { "files": [ @@ -1312,7 +1345,8 @@ "source": { "repo": "oval-tutu/shove", "tag": "1.0.6", - "baseUrl": "https://raw.githubusercontent.com/oval-tutu/shove/1.0.6/" + "baseUrl": "https://raw.githubusercontent.com/oval-tutu/shove/ee1dedbca78ca988589edd4c5314d7e662223973/", + "commitSha": "ee1dedbca78ca988589edd4c5314d7e662223973" }, "install": { "files": [ @@ -1346,7 +1380,8 @@ "source": { "repo": "karai17/Simple-Tiled-Implementation", "tag": "v1.2.3.0", - "baseUrl": "https://raw.githubusercontent.com/karai17/Simple-Tiled-Implementation/v1.2.3.0/" + "baseUrl": "https://raw.githubusercontent.com/karai17/Simple-Tiled-Implementation/69cdf8c7565f518c03967d0243cb54d1ee31da4e/", + "commitSha": "69cdf8c7565f518c03967d0243cb54d1ee31da4e" }, "install": { "files": [ @@ -1396,7 +1431,8 @@ "source": { "repo": "sysl-dev/SYSL-Text", "tag": "v2.2", - "baseUrl": "https://raw.githubusercontent.com/sysl-dev/SYSL-Text/v2.2/" + "baseUrl": "https://raw.githubusercontent.com/sysl-dev/SYSL-Text/6c9b95047cb8650344e90224cd56bd00c5132d97/", + "commitSha": "6c9b95047cb8650344e90224cd56bd00c5132d97" }, "install": { "files": [ diff --git a/cli/src/lib/package/registry.ts b/cli/src/lib/package/registry.ts index 0ce5ba2b..394fdd0e 100644 --- a/cli/src/lib/package/registry.ts +++ b/cli/src/lib/package/registry.ts @@ -26,6 +26,7 @@ export type RegistryEntry = { source: { repo: string; tag: string; + commitSha?: string; baseUrl: string; }; install: { files: PackageFile[] }; diff --git a/packages/anim8.json b/packages/anim8.json index eee7778e..c6a54557 100644 --- a/packages/anim8.json +++ b/packages/anim8.json @@ -2,13 +2,17 @@ "type": "love2d-library", "trust": "verified", "description": "A 2D animation library for LÖVE. Handles sprite sheets, frames, tags, and playback.", - "tags": ["animation", "sprites"], + "tags": [ + "animation", + "sprites" + ], "homepage": "https://github.com/kikito/anim8", "license": "MIT", "source": { "repo": "kikito/anim8", "tag": "v2.3.1", - "baseUrl": "https://raw.githubusercontent.com/kikito/anim8/v2.3.1/" + "baseUrl": "https://raw.githubusercontent.com/kikito/anim8/c1c12ec45fde28ffd2d6967675adcb3c3e501fa7/", + "commitSha": "c1c12ec45fde28ffd2d6967675adcb3c3e501fa7" }, "install": { "files": [ diff --git a/packages/baton.json b/packages/baton.json index 0a9658cf..f637a3eb 100644 --- a/packages/baton.json +++ b/packages/baton.json @@ -14,7 +14,8 @@ "source": { "repo": "tesselode/baton", "tag": "v1.0.2", - "baseUrl": "https://raw.githubusercontent.com/tesselode/baton/v1.0.2/" + "baseUrl": "https://raw.githubusercontent.com/tesselode/baton/3defe19f8f0da1a457ba86095e6f6a3c7ee6a8e9/", + "commitSha": "3defe19f8f0da1a457ba86095e6f6a3c7ee6a8e9" }, "install": { "files": [ diff --git a/packages/beehive.json b/packages/beehive.json index 2ac0f215..62ea9707 100644 --- a/packages/beehive.json +++ b/packages/beehive.json @@ -12,7 +12,8 @@ "source": { "repo": "drhayes/beehive.lua", "tag": "main", - "baseUrl": "https://raw.githubusercontent.com/drhayes/beehive.lua/main/" + "baseUrl": "https://raw.githubusercontent.com/drhayes/beehive.lua/00877150688ce42fffbaeeb76d7a4cb87e88da60/", + "commitSha": "00877150688ce42fffbaeeb76d7a4cb87e88da60" }, "install": { "files": [ diff --git a/packages/bump.json b/packages/bump.json index 646802e4..281d1b99 100644 --- a/packages/bump.json +++ b/packages/bump.json @@ -2,13 +2,17 @@ "type": "love2d-library", "trust": "verified", "description": "A Lua library for simple AABB collision detection and resolution.", - "tags": ["collision", "physics"], + "tags": [ + "collision", + "physics" + ], "homepage": "https://github.com/kikito/bump.lua", "license": "MIT", "source": { "repo": "kikito/bump.lua", "tag": "v3.1.7", - "baseUrl": "https://raw.githubusercontent.com/kikito/bump.lua/v3.1.7/" + "baseUrl": "https://raw.githubusercontent.com/kikito/bump.lua/72d6b13dfa525564f6e1a533d71eaa8235a5e96c/", + "commitSha": "72d6b13dfa525564f6e1a533d71eaa8235a5e96c" }, "install": { "files": [ diff --git a/packages/cargo.json b/packages/cargo.json index 264b50a5..551e0eee 100644 --- a/packages/cargo.json +++ b/packages/cargo.json @@ -12,7 +12,8 @@ "source": { "repo": "bjornbytes/cargo", "tag": "v0.1.1", - "baseUrl": "https://raw.githubusercontent.com/bjornbytes/cargo/v0.1.1/" + "baseUrl": "https://raw.githubusercontent.com/bjornbytes/cargo/8014bc0ad7d4639c112e5d00fac6c9ac68b0d696/", + "commitSha": "8014bc0ad7d4639c112e5d00fac6c9ac68b0d696" }, "install": { "files": [ diff --git a/packages/classic.json b/packages/classic.json index ddaa3c62..d8847495 100644 --- a/packages/classic.json +++ b/packages/classic.json @@ -2,13 +2,17 @@ "type": "love2d-library", "trust": "verified", "description": "A tiny class module for Lua. Minimal OOP with inheritance.", - "tags": ["oop", "utilities"], + "tags": [ + "oop", + "utilities" + ], "homepage": "https://github.com/rxi/classic", "license": "MIT", "source": { "repo": "rxi/classic", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/rxi/classic/master/" + "baseUrl": "https://raw.githubusercontent.com/rxi/classic/e5610756c98ac2f8facd7ab90c94e1a097ecd2c6/", + "commitSha": "e5610756c98ac2f8facd7ab90c94e1a097ecd2c6" }, "install": { "files": [ diff --git a/packages/flux.json b/packages/flux.json index dcd67de7..3ab0a477 100644 --- a/packages/flux.json +++ b/packages/flux.json @@ -2,13 +2,17 @@ "type": "love2d-library", "trust": "verified", "description": "A fast, lightweight tweening library for Lua with chainable API and easing functions.", - "tags": ["animation", "tweening"], + "tags": [ + "animation", + "tweening" + ], "homepage": "https://github.com/rxi/flux", "license": "MIT", "source": { "repo": "rxi/flux", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/rxi/flux/master/" + "baseUrl": "https://raw.githubusercontent.com/rxi/flux/bb330231b87eabf84fbd68322f13a6320db30a41/", + "commitSha": "bb330231b87eabf84fbd68322f13a6320db30a41" }, "install": { "files": [ diff --git a/packages/hump.json b/packages/hump.json index 054c01a5..81e6f7dc 100644 --- a/packages/hump.json +++ b/packages/hump.json @@ -2,13 +2,20 @@ "type": "love2d-library", "trust": "verified", "description": "Helper utilities for LÖVE: camera, timer, signal, class, vector, gamestate.", - "tags": ["utilities", "camera", "timer", "oop", "math"], + "tags": [ + "utilities", + "camera", + "timer", + "oop", + "math" + ], "homepage": "https://github.com/vrld/hump", "license": "MIT", "source": { "repo": "vrld/hump", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/vrld/hump/master/" + "baseUrl": "https://raw.githubusercontent.com/vrld/hump/08937cc0ecf72d1a964a8de6cd552c5e136bf0d4/", + "commitSha": "08937cc0ecf72d1a964a8de6cd552c5e136bf0d4" }, "install": { "files": [ @@ -46,27 +53,39 @@ }, "subpackages": { "hump.camera": { - "files": ["camera.lua"], + "files": [ + "camera.lua" + ], "require": "lib.hump.camera" }, "hump.timer": { - "files": ["timer.lua"], + "files": [ + "timer.lua" + ], "require": "lib.hump.timer" }, "hump.signal": { - "files": ["signal.lua"], + "files": [ + "signal.lua" + ], "require": "lib.hump.signal" }, "hump.class": { - "files": ["class.lua"], + "files": [ + "class.lua" + ], "require": "lib.hump.class" }, "hump.vector": { - "files": ["vector.lua"], + "files": [ + "vector.lua" + ], "require": "lib.hump.vector" }, "hump.gamestate": { - "files": ["gamestate.lua"], + "files": [ + "gamestate.lua" + ], "require": "lib.hump.gamestate" } }, diff --git a/packages/inspect.json b/packages/inspect.json index 551623c9..f2c2f011 100644 --- a/packages/inspect.json +++ b/packages/inspect.json @@ -2,13 +2,17 @@ "type": "love2d-library", "trust": "verified", "description": "Human-readable table serializer and pretty-printer for Lua. Useful for debugging.", - "tags": ["debug", "utilities"], + "tags": [ + "debug", + "utilities" + ], "homepage": "https://github.com/kikito/inspect.lua", "license": "MIT", "source": { "repo": "kikito/inspect.lua", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/kikito/inspect.lua/master/" + "baseUrl": "https://raw.githubusercontent.com/kikito/inspect.lua/a8ca3120dfec48801036eaeff9335ab7a096dd24/", + "commitSha": "a8ca3120dfec48801036eaeff9335ab7a096dd24" }, "install": { "files": [ diff --git a/packages/knife.json b/packages/knife.json index 471646c0..3a3b41f5 100644 --- a/packages/knife.json +++ b/packages/knife.json @@ -23,7 +23,8 @@ "source": { "repo": "airstruck/knife", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/master/" + "baseUrl": "https://raw.githubusercontent.com/airstruck/knife/5ccd9b425f2c5363389534c7e12b9df05b18ad8e/", + "commitSha": "5ccd9b425f2c5363389534c7e12b9df05b18ad8e" }, "install": { "files": [ @@ -93,51 +94,75 @@ "example": "local knife = require('lib.knife.base')", "subpackages": { "knife.base": { - "files": ["knife/base.lua"], + "files": [ + "knife/base.lua" + ], "require": "lib.knife.base" }, "knife.behavior": { - "files": ["knife/behavior.lua"], + "files": [ + "knife/behavior.lua" + ], "require": "lib.knife.behavior" }, "knife.bind": { - "files": ["knife/bind.lua"], + "files": [ + "knife/bind.lua" + ], "require": "lib.knife.bind" }, "knife.chain": { - "files": ["knife/chain.lua"], + "files": [ + "knife/chain.lua" + ], "require": "lib.knife.chain" }, "knife.convoke": { - "files": ["knife/convoke.lua"], + "files": [ + "knife/convoke.lua" + ], "require": "lib.knife.convoke" }, "knife.event": { - "files": ["knife/event.lua"], + "files": [ + "knife/event.lua" + ], "require": "lib.knife.event" }, "knife.gun": { - "files": ["knife/gun.lua"], + "files": [ + "knife/gun.lua" + ], "require": "lib.knife.gun" }, "knife.memoize": { - "files": ["knife/memoize.lua"], + "files": [ + "knife/memoize.lua" + ], "require": "lib.knife.memoize" }, "knife.serialize": { - "files": ["knife/serialize.lua"], + "files": [ + "knife/serialize.lua" + ], "require": "lib.knife.serialize" }, "knife.system": { - "files": ["knife/system.lua"], + "files": [ + "knife/system.lua" + ], "require": "lib.knife.system" }, "knife.test": { - "files": ["knife/test.lua"], + "files": [ + "knife/test.lua" + ], "require": "lib.knife.test" }, "knife.timer": { - "files": ["knife/timer.lua"], + "files": [ + "knife/timer.lua" + ], "require": "lib.knife.timer" } } diff --git a/packages/love-dialogue.json b/packages/love-dialogue.json index 428b7f90..f8dc9d65 100644 --- a/packages/love-dialogue.json +++ b/packages/love-dialogue.json @@ -14,7 +14,8 @@ "source": { "repo": "Miisan-png/Love-Dialogue", "tag": "main", - "baseUrl": "https://raw.githubusercontent.com/Miisan-png/Love-Dialogue/main/" + "baseUrl": "https://raw.githubusercontent.com/Miisan-png/Love-Dialogue/1d1cbd1f6730fbaf73fab18a2d33ee3525abfb75/", + "commitSha": "1d1cbd1f6730fbaf73fab18a2d33ee3525abfb75" }, "install": { "files": [ diff --git a/packages/lua-state-machine.json b/packages/lua-state-machine.json index 79bbfa22..bca71349 100644 --- a/packages/lua-state-machine.json +++ b/packages/lua-state-machine.json @@ -12,7 +12,8 @@ "source": { "repo": "kyleconroy/lua-state-machine", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/kyleconroy/lua-state-machine/master/" + "baseUrl": "https://raw.githubusercontent.com/kyleconroy/lua-state-machine/96fc10ee123479cf2e5fb8ffe9cdde2fd6df0877/", + "commitSha": "96fc10ee123479cf2e5fb8ffe9cdde2fd6df0877" }, "install": { "files": [ diff --git a/packages/lume.json b/packages/lume.json index 67d6451e..a03c4427 100644 --- a/packages/lume.json +++ b/packages/lume.json @@ -2,13 +2,17 @@ "type": "love2d-library", "trust": "verified", "description": "A collection of Lua functions for gamedev: math, table manipulation, string utilities, and more.", - "tags": ["utilities", "math"], + "tags": [ + "utilities", + "math" + ], "homepage": "https://github.com/rxi/lume", "license": "MIT", "source": { "repo": "rxi/lume", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/rxi/lume/master/" + "baseUrl": "https://raw.githubusercontent.com/rxi/lume/98847e7812cf28d3d64b289b03fad71dc704547d/", + "commitSha": "98847e7812cf28d3d64b289b03fad71dc704547d" }, "install": { "files": [ diff --git a/packages/middleclass.json b/packages/middleclass.json index 16a14b57..feaa673a 100644 --- a/packages/middleclass.json +++ b/packages/middleclass.json @@ -2,13 +2,17 @@ "type": "love2d-library", "trust": "verified", "description": "A simple OOP library for Lua. Supports inheritance, mixins, and interfaces.", - "tags": ["oop", "utilities"], + "tags": [ + "oop", + "utilities" + ], "homepage": "https://github.com/kikito/middleclass", "license": "MIT", "source": { "repo": "kikito/middleclass", "tag": "v4.1.1", - "baseUrl": "https://raw.githubusercontent.com/kikito/middleclass/v4.1.1/" + "baseUrl": "https://raw.githubusercontent.com/kikito/middleclass/dc0fb3612da5d41866a2f06076a4ecea5614ef78/", + "commitSha": "dc0fb3612da5d41866a2f06076a4ecea5614ef78" }, "install": { "files": [ diff --git a/packages/push.json b/packages/push.json index 1ea4a789..b049f8f7 100644 --- a/packages/push.json +++ b/packages/push.json @@ -2,13 +2,17 @@ "type": "love2d-library", "trust": "verified", "description": "Resolution-independence library for LÖVE. Lets you draw at a fixed resolution and scale to any window size.", - "tags": ["rendering", "resolution"], + "tags": [ + "rendering", + "resolution" + ], "homepage": "https://github.com/Ulydev/push", "license": "MIT", "source": { "repo": "Ulydev/push", "tag": "master", - "baseUrl": "https://raw.githubusercontent.com/Ulydev/push/master/" + "baseUrl": "https://raw.githubusercontent.com/Ulydev/push/9c165816a14c868339c3cd0d22eed65c313c8bf8/", + "commitSha": "9c165816a14c868339c3cd0d22eed65c313c8bf8" }, "install": { "files": [ diff --git a/packages/shove.json b/packages/shove.json index b048faec..cae7ff18 100644 --- a/packages/shove.json +++ b/packages/shove.json @@ -21,7 +21,8 @@ "source": { "repo": "oval-tutu/shove", "tag": "1.0.6", - "baseUrl": "https://raw.githubusercontent.com/oval-tutu/shove/1.0.6/" + "baseUrl": "https://raw.githubusercontent.com/oval-tutu/shove/ee1dedbca78ca988589edd4c5314d7e662223973/", + "commitSha": "ee1dedbca78ca988589edd4c5314d7e662223973" }, "install": { "files": [ diff --git a/packages/sti.json b/packages/sti.json index 3bfcfbae..d330479b 100644 --- a/packages/sti.json +++ b/packages/sti.json @@ -13,7 +13,8 @@ "source": { "repo": "karai17/Simple-Tiled-Implementation", "tag": "v1.2.3.0", - "baseUrl": "https://raw.githubusercontent.com/karai17/Simple-Tiled-Implementation/v1.2.3.0/" + "baseUrl": "https://raw.githubusercontent.com/karai17/Simple-Tiled-Implementation/69cdf8c7565f518c03967d0243cb54d1ee31da4e/", + "commitSha": "69cdf8c7565f518c03967d0243cb54d1ee31da4e" }, "install": { "files": [ diff --git a/packages/sysl-text.json b/packages/sysl-text.json index 6b864f5e..27214e84 100644 --- a/packages/sysl-text.json +++ b/packages/sysl-text.json @@ -14,7 +14,8 @@ "source": { "repo": "sysl-dev/SYSL-Text", "tag": "v2.2", - "baseUrl": "https://raw.githubusercontent.com/sysl-dev/SYSL-Text/v2.2/" + "baseUrl": "https://raw.githubusercontent.com/sysl-dev/SYSL-Text/6c9b95047cb8650344e90224cd56bd00c5132d97/", + "commitSha": "6c9b95047cb8650344e90224cd56bd00c5132d97" }, "install": { "files": [ diff --git a/scripts/backfill-commit-shas.mjs b/scripts/backfill-commit-shas.mjs new file mode 100644 index 00000000..037253b6 --- /dev/null +++ b/scripts/backfill-commit-shas.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +/** + * One-shot: resolves each package's source.tag/branch to a commit SHA and + * rewrites source.commitSha + source.baseUrl in packages/*.json, then + * regenerates the registry. + * + * Skips packages that already have source.commitSha set. + * Uses --force to re-resolve and overwrite even if commitSha is already present. + * + * Usage: + * node scripts/backfill-commit-shas.mjs + * node scripts/backfill-commit-shas.mjs --force + */ + +import { readFileSync, writeFileSync, readdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '..'); +const packagesDir = join(root, 'packages'); + +const force = process.argv.includes('--force'); +const GH_HEADERS = { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }; + +async function resolveCommitSha(repo, ref) { + const url = `https://api.github.com/repos/${repo}/commits/${encodeURIComponent(ref)}`; + const res = await fetch(url, { headers: GH_HEADERS }); + if (!res.ok) throw new Error(`GitHub API ${res.status} for ${repo}@${ref}`); + const data = await res.json(); + return data.sha; +} + +const files = readdirSync(packagesDir).filter((f) => f.endsWith('.json')).sort(); +let updated = 0; +let skipped = 0; +let failed = 0; + +for (const file of files) { + const id = file.replace(/\.json$/, ''); + const path = join(packagesDir, file); + const pkg = JSON.parse(readFileSync(path, 'utf8')); + + if (!pkg.source?.repo || !pkg.source?.tag) { + console.log(` SKIP ${id}: missing source.repo or source.tag`); + skipped++; + continue; + } + + if (pkg.source.commitSha && !force) { + console.log(` SKIP ${id}: already has commitSha ${pkg.source.commitSha.slice(0, 7)}`); + skipped++; + continue; + } + + process.stdout.write(` ... ${id} (${pkg.source.repo}@${pkg.source.tag})`); + try { + const sha = await resolveCommitSha(pkg.source.repo, pkg.source.tag); + pkg.source.commitSha = sha; + pkg.source.baseUrl = `https://raw.githubusercontent.com/${pkg.source.repo}/${sha}/`; + writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n', 'utf8'); + process.stdout.write(` → ${sha.slice(0, 7)}\n`); + updated++; + } catch (err) { + process.stdout.write(`\n FAIL ${id}: ${err.message}\n`); + failed++; + } +} + +console.log(`\n${updated} updated, ${skipped} skipped, ${failed} failed`); + +if (updated > 0) { + process.stdout.write('\nRegenerating registry… '); + const result = spawnSync(process.execPath, [join(root, 'scripts', 'generate-registry.mjs')], { + cwd: root, + stdio: 'pipe', + encoding: 'utf8', + }); + if (result.status !== 0) { + console.error('FAILED\n' + result.stderr); + process.exit(1); + } + console.log('done'); +} From 442afe02527527fee6ff69ce6ac447ec5b1fc569 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Wed, 13 May 2026 21:18:52 -0400 Subject: [PATCH 15/68] package: add package add command to install custom packages --- cli/package.json | 1 + cli/scripts/add-package-url.tsx | 489 +++++++++++++++++++++++++++++++ cli/scripts/add-package.tsx | 4 +- cli/scripts/update-package.tsx | 4 +- cli/scripts/wizard-shared.tsx | 40 ++- cli/src/commands/package.ts | 11 + cli/src/index.ts | 9 + cli/src/lib/package/install.ts | 20 +- cli/src/lib/package/lockfile.ts | 2 +- cli/src/lib/package/registry.ts | 8 +- cli/src/ui/package-add-url.tsx | 491 ++++++++++++++++++++++++++++++++ cli/src/ui/package-progress.tsx | 2 +- cli/src/ui/package-workflow.tsx | 2 +- cli/test/package.test.mjs | 192 +++++++++++++ package.json | 1 + packages/README.md | 87 +++++- scripts/generate-registry.mjs | 2 +- 17 files changed, 1331 insertions(+), 34 deletions(-) create mode 100644 cli/scripts/add-package-url.tsx create mode 100644 cli/src/ui/package-add-url.tsx diff --git a/cli/package.json b/cli/package.json index 3ab5e93c..77cdea6b 100644 --- a/cli/package.json +++ b/cli/package.json @@ -38,6 +38,7 @@ "test": "node --test test/package.test.mjs", "test:e2e": "npm run build && node --test test/package.test.mjs", "package:add": "tsx --tsconfig scripts/tsconfig.json scripts/add-package.tsx", + "package:add-url": "tsx --tsconfig scripts/tsconfig.json scripts/add-package-url.tsx", "package:update": "tsx --tsconfig scripts/tsconfig.json scripts/update-package.tsx", "package:remove": "tsx --tsconfig scripts/tsconfig.json scripts/remove-package.tsx" }, diff --git a/cli/scripts/add-package-url.tsx b/cli/scripts/add-package-url.tsx new file mode 100644 index 00000000..5771b0a0 --- /dev/null +++ b/cli/scripts/add-package-url.tsx @@ -0,0 +1,489 @@ +#!/usr/bin/env node +/** + * Interactive wizard to add a new package from direct file URLs. + * Use this when there is no GitHub repo with tags (e.g. single-file libraries + * hosted elsewhere, or repos without versioned releases). + * + * Usage (from repo root): + * npm run package:add-url + */ + +import { render, Text, Box, useApp, useInput } from 'ink'; +import { useState, useEffect } from 'react'; +import { existsSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + root, + packagesDir, + TextInputStep, + SelectStep, + AutoStep, + SubpackagesStep, + YesNoStep, + ReviewStep, + Header, + Hint, + Spinner, +} from './wizard-shared.js'; + +interface UrlFile { + name: string; + url: string; + sha256: string; + target: string; +} + +interface FormData { + id: string; + trust: 'verified' | 'known'; + description: string; + tags: string[]; + homepage: string; + license: string; + require: string; + example: string; + subpackages?: Record; +} + +type Step = + | 'id' + | 'trust' + | 'description' + | 'pkg-tags' + | 'homepage' + | 'license' + | 'file-url' + | 'file-fetch' + | 'file-target' + | 'file-more' + | 'require' + | 'subpkgs' + | 'review' + | 'write' + | 'done' + | 'error'; + +const TITLE = 'feather package:add-url'; +const TOTAL = 10; + +function buildPackageJson( + data: FormData, + urlFiles: UrlFile[], +): object { + const obj: Record = { + type: 'love2d-library', + trust: data.trust, + description: data.description, + tags: data.tags, + homepage: data.homepage || undefined, + license: data.license || undefined, + source: { type: 'url' }, + install: { + files: urlFiles.map((f) => ({ name: f.name, url: f.url, sha256: f.sha256, target: f.target })), + }, + require: data.require, + example: data.example, + }; + if (data.subpackages && Object.keys(data.subpackages).length > 0) { + obj.subpackages = data.subpackages; + } + return obj; +} + +function fileNameFromUrl(url: string): string { + try { + const path = new URL(url).pathname; + return path.split('/').filter(Boolean).pop() ?? 'file.lua'; + } catch { + return url.split('/').pop() ?? 'file.lua'; + } +} + +function FileFetchStep({ + url, + onDone, + onError, +}: { + url: string; + onDone: (sha256: string) => void; + onError: (msg: string) => void; +}) { + const [status, setStatus] = useState<'running' | 'done'>('running'); + const [sha, setSha] = useState(''); + + useEffect(() => { + const run = async () => { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`); + const buf = await res.arrayBuffer(); + const hash = createHash('sha256').update(Buffer.from(buf)).digest('hex'); + setSha(hash); + setStatus('done'); + onDone(hash); + }; + run().catch((err: Error) => onError(err.message)); + }, []); + + return ( + + {status === 'running' ? ( + + ) : ( + ✔ sha256: {sha.slice(0, 16)}… + )} + + ); +} + +function FileMoreStep({ + stepNum, + urlFiles, + onYes, + onNo, +}: { + stepNum: number; + urlFiles: UrlFile[]; + onYes: () => void; + onNo: () => void; +}) { + useInput((input, key) => { + if (input === 'y' || input === 'Y') onYes(); + else if (input === 'n' || input === 'N' || key.return || key.escape) onNo(); + }); + + return ( + +
+ {' '}Add another file? + + {urlFiles.map((f) => ( + + {' ✔ '} + {f.name} + {' '} + → {f.target} + + ))} + + + {' y = add another · n/Enter = done'} + + + ); +} + +function DoneStep({ id, onExit }: { id: string; onExit: () => void }) { + useEffect(() => { + const t = setTimeout(onExit, 300); + return () => clearTimeout(t); + }, [onExit]); + + return ( + + + ✔ Done! + + {' '}packages/{id}.json written + {' '}Registry regenerated + + Commit packages/{id}.json and cli/src/generated/registry.json + + + ); +} + +function Wizard() { + const { exit } = useApp(); + const [step, _setStep] = useState('id'); + const setStep = (next: Step) => { process.stdout.write('\x1B[2J\x1B[H'); _setStep(next); }; + const [data, setData] = useState>({}); + const [urlFiles, setUrlFiles] = useState([]); + const [currentUrl, setCurrentUrl] = useState(''); + const [currentSha, setCurrentSha] = useState(''); + const [errorMsg, setErrorMsg] = useState(''); + const [outputJson, setOutputJson] = useState(''); + + const handleError = (msg: string) => { + setErrorMsg(msg); + setStep('error'); + }; + + if (step === 'id') { + return ( + { + if (!v) return 'Required'; + if (!/^[a-z0-9][a-z0-9.-]*$/.test(v)) return 'Use only a-z, 0-9, dots, hyphens'; + if (existsSync(join(packagesDir, `${v}.json`))) + return `packages/${v}.json already exists — use package:update to edit it`; + return null; + }} + onSubmit={(id) => { + setData((d) => ({ ...d, id })); + setStep('trust'); + }} + /> + ); + } + + if (step === 'trust') { + return ( + { + setData((d) => ({ ...d, trust: trust as FormData['trust'] })); + setStep('description'); + }} + /> + ); + } + + if (step === 'description') { + return ( + (v ? null : 'Required')} + onSubmit={(description) => { + setData((d) => ({ ...d, description })); + setStep('pkg-tags'); + }} + /> + ); + } + + if (step === 'pkg-tags') { + return ( + (v ? null : 'Required')} + onSubmit={(tagStr) => { + const tags = tagStr + .split(',') + .map((t) => t.trim()) + .filter(Boolean); + setData((d) => ({ ...d, tags })); + setStep('homepage'); + }} + /> + ); + } + + if (step === 'homepage') { + return ( + { + setData((d) => ({ ...d, homepage })); + setStep('license'); + }} + /> + ); + } + + if (step === 'license') { + return ( + { + setData((d) => ({ ...d, license })); + setStep('file-url'); + }} + /> + ); + } + + if (step === 'file-url') { + const n = urlFiles.length; + return ( + { + if (!v) return 'Required'; + try { + new URL(v); + } catch { + return 'Must be a valid URL'; + } + return null; + }} + onSubmit={(url) => { + setCurrentUrl(url); + setStep('file-fetch'); + }} + /> + ); + } + + if (step === 'file-fetch') { + return ( + { + setCurrentSha(sha256); + setStep('file-target'); + }} + onError={handleError} + /> + ); + } + + if (step === 'file-target') { + const name = fileNameFromUrl(currentUrl); + const suggested = + urlFiles.length === 0 + ? `lib/${name}` + : `lib/${data.id}/${name}`; + return ( + { + if (!v) return 'Required'; + if (!v.endsWith('.lua')) return 'Must end in .lua'; + return null; + }} + onSubmit={(target) => { + setUrlFiles((fs) => [ + ...fs, + { name, url: currentUrl, sha256: currentSha, target }, + ]); + setStep('file-more'); + }} + /> + ); + } + + if (step === 'file-more') { + return ( + setStep('file-url')} + onNo={() => setStep('require')} + /> + ); + } + + if (step === 'require') { + const firstName = urlFiles[0]?.name ?? `${data.id}.lua`; + const suggested = `lib.${firstName.replace(/\.lua$/, '').replace(/\//g, '.')}`; + return ( + (v ? null : 'Required')} + onSubmit={(req) => { + const example = `local ${data.id!.replace(/[.-]/g, '_')} = require('${req}')`; + setData((d) => ({ ...d, require: req, example })); + setStep('subpkgs'); + }} + /> + ); + } + + if (step === 'subpkgs') { + return ( + f.name)} + onSubmit={(subpackages) => { + setData((d) => ({ ...d, subpackages })); + const full = { ...data, subpackages } as FormData; + setOutputJson(JSON.stringify(buildPackageJson(full, urlFiles), null, 2)); + setStep('review'); + }} + /> + ); + } + + if (step === 'review') { + return ( + setStep('write')} + onAbort={() => { + setErrorMsg('Aborted.'); + setStep('error'); + }} + /> + ); + } + + if (step === 'write') { + return ( + { + writeFileSync(join(packagesDir, `${data.id}.json`), outputJson + '\n', 'utf8'); + const result = spawnSync(process.execPath, [join(root, 'scripts', 'generate-registry.mjs')], { + cwd: root, + stdio: 'pipe', + encoding: 'utf8', + }); + if (result.status !== 0) throw new Error(result.stderr || 'generate-registry.mjs failed'); + setStep('done'); + }} + onError={handleError} + /> + ); + } + + if (step === 'done') return ; + + return ( + + + ✖ Error + + {errorMsg} + + ); +} + +process.stdout.write('\x1B[2J\x1B[H'); +const { waitUntilExit } = render(); +await waitUntilExit(); diff --git a/cli/scripts/add-package.tsx b/cli/scripts/add-package.tsx index 274aa909..18a075fe 100644 --- a/cli/scripts/add-package.tsx +++ b/cli/scripts/add-package.tsx @@ -80,7 +80,8 @@ function DoneStep({ id, onExit }: { id: string; onExit: () => void }) { function Wizard() { const { exit } = useApp(); - const [step, setStep] = useState('id'); + const [step, _setStep] = useState('id'); + const setStep = (next: Step) => { process.stdout.write('\x1B[2J\x1B[H'); _setStep(next); }; const [data, setData] = useState>({}); const [fetchedTags, setFetchedTags] = useState([]); const [fetchedLabels, setFetchedLabels] = useState([]); @@ -399,5 +400,6 @@ function Wizard() { ); } +process.stdout.write('\x1B[2J\x1B[H'); const { waitUntilExit } = render(); await waitUntilExit(); diff --git a/cli/scripts/update-package.tsx b/cli/scripts/update-package.tsx index 2709605f..80b46ad1 100644 --- a/cli/scripts/update-package.tsx +++ b/cli/scripts/update-package.tsx @@ -93,7 +93,8 @@ function DoneStep({ id, onExit }: { id: string; onExit: () => void }) { function Wizard() { const { exit } = useApp(); - const [step, setStep] = useState('pick'); + const [step, _setStep] = useState('pick'); + const setStep = (next: Step) => { process.stdout.write('\x1B[2J\x1B[H'); _setStep(next); }; const [data, setData] = useState>({}); const [fetchedTags, setFetchedTags] = useState([]); const [fetchedFiles, setFetchedFiles] = useState([]); @@ -470,5 +471,6 @@ function Wizard() { ); } +process.stdout.write('\x1B[2J\x1B[H'); const { waitUntilExit } = render(); await waitUntilExit(); diff --git a/cli/scripts/wizard-shared.tsx b/cli/scripts/wizard-shared.tsx index 9dd892a1..9a1fde31 100644 --- a/cli/scripts/wizard-shared.tsx +++ b/cli/scripts/wizard-shared.tsx @@ -3,7 +3,7 @@ */ import { Text, Box, useInput } from 'ink'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, type ReactNode } from 'react'; import { createHash } from 'node:crypto'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -14,6 +14,7 @@ export const packagesDir = join(root, 'packages'); export interface FileEntry { name: string; + url?: string; target: string; sha256: string; } @@ -226,7 +227,7 @@ export function SelectStep({ {hint && {hint}} {options.map((opt, i) => ( - + {' '} {i === cursor ? '❯ ' : ' '} @@ -293,7 +294,7 @@ export function MultiSelectStep({ {hint && {hint}} {options.map((opt, i) => ( - + {' '} {i === cursor ? '❯ ' : ' '} @@ -526,6 +527,39 @@ export function ChecksumStep({ files, onDone, onError }: ChecksumStepProps) { ); } +interface YesNoStepProps { + stepNum: number; + total: number; + title?: string; + label: string; + hint?: string; + children?: ReactNode; + onYes: () => void; + onNo: () => void; +} + +export function YesNoStep({ stepNum, total, title, label, hint, children, onYes, onNo }: YesNoStepProps) { + useInput((input, key) => { + if (input === 'y' || input === 'Y') onYes(); + else if (input === 'n' || input === 'N' || key.return || key.escape) onNo(); + }); + + return ( + +
+ + {' '} + {label} + + {hint && {hint}} + {children} + + {' y = yes · n/Enter = no'} + + + ); +} + type SubPkgPhase = 'ask' | 'id' | 'files' | 'require' | 'add-more'; interface SubpackagesStepProps { diff --git a/cli/src/commands/package.ts b/cli/src/commands/package.ts index edb4892f..e86fa15d 100644 --- a/cli/src/commands/package.ts +++ b/cli/src/commands/package.ts @@ -9,6 +9,7 @@ import { installFromUrl, restorePackage } from '../lib/package/install.js'; import { auditLockfile } from '../lib/package/audit.js'; import { showPackageBrowser } from '../ui/package-workflow.js'; import { showInstallProgress } from '../ui/package-progress.js'; +import { showAddFromUrlWizard } from '../ui/package-add-url.js'; function findProjectDir(cwd = process.cwd()): string { if (existsSync(join(cwd, 'main.lua'))) return cwd; @@ -474,6 +475,16 @@ export async function packageRemoveCommand(name: string, opts: PackageRemoveOpti console.log(` ${chalk.bold(name)} removed.`); } +export type PackageAddOptions = { + dir?: string; +}; + +export async function packageAddCommand(opts: PackageAddOptions = {}): Promise { + const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); + const lockfile = readLockfile(projectDir); + await showAddFromUrlWizard({ projectDir, lockfile }); +} + export type PackageAuditOptions = { dir?: string; json?: boolean; diff --git a/cli/src/index.ts b/cli/src/index.ts index c983bdde..1f291516 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -20,6 +20,7 @@ import { packageUpdateCommand, packageRemoveCommand, packageAuditCommand, + packageAddCommand, } from './commands/package.js'; import type { InitMode } from './ui/init-mode.js'; @@ -223,6 +224,14 @@ plugin 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(async (opts) => { + await packageAddCommand({ dir: opts.dir as string | undefined }); + }); + pkg .command('search [query]') .description('Search the package catalog') diff --git a/cli/src/lib/package/install.ts b/cli/src/lib/package/install.ts index 3b8a23ee..dbb985cc 100644 --- a/cli/src/lib/package/install.ts +++ b/cli/src/lib/package/install.ts @@ -74,10 +74,11 @@ export async function installPackage( const lockedFiles: LockfileEntry["files"] = []; const src = pkg.entry.source; - const effectiveTag = pkg.versionOverride ?? src.tag; - const baseUrl = pkg.versionOverride - ? src.baseUrl.replace(src.tag, pkg.versionOverride) - : src.baseUrl; + const effectiveTag = pkg.versionOverride ?? src.tag ?? 'url'; + const baseUrl = + pkg.versionOverride && src.baseUrl && src.tag + ? src.baseUrl.replace(src.tag, pkg.versionOverride) + : (src.baseUrl ?? ''); for (const file of pkg.files) { const relTarget = targetOverride @@ -90,7 +91,7 @@ export async function installPackage( continue; } - const url = baseUrl + file.name; + const url = file.url ?? baseUrl + file.name; if (dryRun) { fileResults.push({ name: file.name, target: relTarget, sha256: file.sha256, ok: true }); @@ -135,7 +136,7 @@ export async function installPackage( parent: pkg.entry.parent, version: effectiveTag, trust: pkg.versionOverride ? "experimental" : pkg.entry.trust, - source: { repo: src.repo, tag: effectiveTag }, + source: { repo: src.repo ?? pkg.id, tag: effectiveTag }, files: lockedFiles, }); } @@ -232,9 +233,10 @@ export async function restorePackage( onFileStart?.(file.name); - const url = "url" in entry.source - ? entry.source.url - : `https://raw.githubusercontent.com/${entry.source.repo}/${entry.source.tag}/${file.name}`; + const url = file.url + ?? ("url" in entry.source + ? entry.source.url + : `https://raw.githubusercontent.com/${entry.source.repo}/${entry.source.tag}/${file.name}`); const result = await downloadVerified(url, file.sha256); if ("error" in result) { diff --git a/cli/src/lib/package/lockfile.ts b/cli/src/lib/package/lockfile.ts index bfa58b00..b6ce0c3d 100644 --- a/cli/src/lib/package/lockfile.ts +++ b/cli/src/lib/package/lockfile.ts @@ -8,7 +8,7 @@ export type LockfileEntry = { source: | { repo: string; tag: string } | { url: string }; - files: { name: string; target: string; sha256: string }[]; + files: { name: string; url?: string; target: string; sha256: string }[]; installedAt: string; }; diff --git a/cli/src/lib/package/registry.ts b/cli/src/lib/package/registry.ts index 394fdd0e..bcf906a7 100644 --- a/cli/src/lib/package/registry.ts +++ b/cli/src/lib/package/registry.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; export type PackageFile = { name: string; + url?: string; sha256: string; target: string; }; @@ -24,10 +25,11 @@ export type RegistryEntry = { homepage?: string; license?: string; source: { - repo: string; - tag: string; + type?: string; + repo?: string; + tag?: string; commitSha?: string; - baseUrl: string; + baseUrl?: string; }; install: { files: PackageFile[] }; subpackages?: string[]; diff --git a/cli/src/ui/package-add-url.tsx b/cli/src/ui/package-add-url.tsx new file mode 100644 index 00000000..094a507e --- /dev/null +++ b/cli/src/ui/package-add-url.tsx @@ -0,0 +1,491 @@ +import { render, Text, Box, useInput, useApp } from 'ink'; +import { useState, useEffect } from 'react'; +import { createHash } from 'node:crypto'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import type { Lockfile } from '../lib/package/lockfile.js'; +import { addToLockfile, writeLockfile } from '../lib/package/lockfile.js'; + +// ── Local ink primitives (mirrors wizard-shared for consistent UX) ────────── + +type InkKey = Parameters[0]>[1]; + +function useTextInput(initial: string) { + const [value, setValue] = useState(initial); + const [cursor, setCursor] = useState(initial.length); + + const reset = (next: string) => { setValue(next); setCursor(next.length); }; + + const handleKey = (input: string, key: InkKey) => { + if (key.leftArrow) { setCursor((c) => Math.max(0, c - 1)); return true; } + if (key.rightArrow) { setCursor((c) => Math.min(value.length, c + 1)); return true; } + if (key.backspace) { + if (cursor === 0) return true; + const pos = cursor; + setValue((v) => v.slice(0, pos - 1) + v.slice(pos)); + setCursor((c) => c - 1); + return true; + } + if (key.delete) { + const pos = cursor; + setValue((v) => v.slice(0, pos) + v.slice(pos + 1)); + return true; + } + if (!key.ctrl && !key.meta && input) { + const pos = cursor; + setValue((v) => v.slice(0, pos) + input + v.slice(pos)); + setCursor((c) => c + 1); + return true; + } + return false; + }; + + return { value, cursor, reset, handleKey, before: value.slice(0, cursor), at: value[cursor] ?? '', after: value.slice(cursor + 1) }; +} + +function CursorText({ before, at, after }: { before: string; at: string; after: string }) { + return ( + + {before} + {at || ' '} + {after} + + ); +} + +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +function Spinner({ label }: { label: string }) { + const [frame, setFrame] = useState(0); + useEffect(() => { + const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 80); + return () => clearInterval(id); + }, []); + return ( + + {SPINNER_FRAMES[frame]} + {label} + + ); +} + +function Header({ step, total }: { step: number; total: number }) { + return ( + + {' '}feather package add + {` Step ${step} of ${total}`} + + ); +} + +function Hint({ children }: { children: string }) { + return {' '}{children}; +} + +// ── Wizard types ───────────────────────────────────────────────────────────── + +interface UrlFile { + name: string; + url: string; + sha256: string; + target: string; + buffer: Buffer; +} + +type Step = 'id' | 'file-url' | 'file-fetch' | 'file-target' | 'file-more' | 'require' | 'confirm' | 'write' | 'done' | 'error'; + +const TOTAL = 5; + +function fileNameFromUrl(url: string): string { + try { + const path = new URL(url).pathname; + return path.split('/').filter(Boolean).pop() ?? 'file.lua'; + } catch { + return url.split('/').pop() ?? 'file.lua'; + } +} + +// ── Step components ────────────────────────────────────────────────────────── + +function TextInputStep({ + stepNum, + label, + hint, + defaultValue = '', + validate, + onSubmit, +}: { + stepNum: number; + label: string; + hint?: string; + defaultValue?: string; + validate?: (v: string) => string | null; + onSubmit: (value: string) => void; +}) { + const input = useTextInput(defaultValue); + const [error, setError] = useState(null); + + useInput((char, key) => { + if (key.return) { + const err = validate ? validate(input.value.trim()) : null; + if (err) { setError(err); return; } + onSubmit(input.value.trim()); + return; + } + input.handleKey(char, key); + setError(null); + }); + + return ( + +
+ {' '}{label} + {hint && {hint}} + {' '} + {error && {' ✖ '}{error}} + {' ←→ move · Backspace/Delete edit · Enter confirm'} + + ); +} + +function FileFetchStep({ + url, + onDone, + onError, +}: { + url: string; + onDone: (sha256: string, buffer: Buffer) => void; + onError: (msg: string) => void; +}) { + const [done, setDone] = useState(false); + const [sha, setSha] = useState(''); + + useEffect(() => { + const run = async () => { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`); + const buf = Buffer.from(await res.arrayBuffer()); + const hash = createHash('sha256').update(buf).digest('hex'); + setSha(hash); + setDone(true); + onDone(hash, buf); + }; + run().catch((err: Error) => onError(err.message)); + }, []); + + return ( + + {done + ? ✔ sha256: {sha.slice(0, 16)}… + : } + + ); +} + +function FileMoreStep({ + urlFiles, + onYes, + onNo, +}: { + urlFiles: UrlFile[]; + onYes: () => void; + onNo: () => void; +}) { + useInput((input, key) => { + if (input === 'y' || input === 'Y') onYes(); + else if (input === 'n' || input === 'N' || key.return || key.escape) onNo(); + }); + + return ( + +
+ {' '}Add another file? + + {urlFiles.map((f) => ( + {' ✔ '}{f.name}{' '}→ {f.target} + ))} + + {' y = add another · n/Enter = done'} + + ); +} + +function ConfirmStep({ + id, + urlFiles, + onConfirm, + onAbort, +}: { + id: string; + urlFiles: UrlFile[]; + onConfirm: () => void; + onAbort: () => void; +}) { + useInput((input, key) => { + if (input === 'y' || input === 'Y' || key.return) onConfirm(); + else if (input === 'n' || input === 'N' || key.escape) onAbort(); + }); + + return ( + +
+ {' '}Review before installing + + {' '}Package: {id} + {' '}Trust: experimental ⚠ + {' '}These files have NOT been reviewed by the Feather team. + + + {urlFiles.map((f) => ( + + {' '}{f.name} → {f.target} + {' sha256: '}{f.sha256.slice(0, 24)}… + + ))} + + {' y/Enter = install · n/Esc = abort'} + + ); +} + +function WriteStep({ + id, + urlFiles, + require: requirePath, + projectDir, + lockfile, + onDone, + onError, +}: { + id: string; + urlFiles: UrlFile[]; + require: string; + projectDir: string; + lockfile: Lockfile; + onDone: () => void; + onError: (msg: string) => void; +}) { + const [status, setStatus] = useState<'running' | 'done' | 'error'>('running'); + const [error, setError] = useState(''); + + useEffect(() => { + const run = async () => { + for (const f of urlFiles) { + const abs = join(projectDir, f.target); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, f.buffer); + } + addToLockfile(lockfile, id, { + version: 'url', + trust: 'experimental', + source: { url: urlFiles[0]!.url }, + files: urlFiles.map((f) => ({ name: f.name, url: f.url, target: f.target, sha256: f.sha256 })), + }); + writeLockfile(projectDir, lockfile); + setStatus('done'); + onDone(); + }; + run().catch((err: Error) => { + setError(err.message); + setStatus('error'); + onError(err.message); + }); + }, []); + + return ( + + {status === 'running' && } + {status === 'done' && ✔ Done} + {status === 'error' && ✖ {error}} + + ); +} + +function DoneStep({ + id, + urlFiles, + require: requirePath, + onExit, +}: { + id: string; + urlFiles: UrlFile[]; + require: string; + onExit: () => void; +}) { + useEffect(() => { + const t = setTimeout(onExit, 300); + return () => clearTimeout(t); + }, [onExit]); + + return ( + + ✔ Installed + {urlFiles.map((f) => {' '}{f.name} → {f.target})} + + + Usage: local {id.replace(/[.-]/g, '_')} = require('{requirePath}') + + + + Trust: experimental ⚠ — not reviewed by the Feather team + + + ); +} + +// ── Main wizard ────────────────────────────────────────────────────────────── + +function Wizard({ projectDir, lockfile }: { projectDir: string; lockfile: Lockfile }) { + const { exit } = useApp(); + const [step, _setStep] = useState('id'); + const setStep = (next: Step) => { process.stdout.write('\x1B[2J\x1B[H'); _setStep(next); }; + const [id, setId] = useState(''); + const [requirePath, setRequirePath] = useState(''); + const [urlFiles, setUrlFiles] = useState([]); + const [currentUrl, setCurrentUrl] = useState(''); + const [currentSha, setCurrentSha] = useState(''); + const [currentBuffer, setCurrentBuffer] = useState(Buffer.alloc(0)); + const [errorMsg, setErrorMsg] = useState(''); + + const handleError = (msg: string) => { setErrorMsg(msg); setStep('error'); }; + + if (step === 'id') { + return ( + { + if (!v) return 'Required'; + if (!/^[a-z0-9][a-z0-9.-]*$/.test(v)) return 'Use only a-z, 0-9, dots, hyphens'; + if (lockfile.packages[v]) return `"${v}" is already installed`; + return null; + }} + onSubmit={(v) => { setId(v); setStep('file-url'); }} + /> + ); + } + + if (step === 'file-url') { + const n = urlFiles.length; + return ( + { + if (!v) return 'Required'; + try { new URL(v); } catch { return 'Must be a valid URL'; } + return null; + }} + onSubmit={(url) => { setCurrentUrl(url); setStep('file-fetch'); }} + /> + ); + } + + if (step === 'file-fetch') { + return ( + { setCurrentSha(sha256); setCurrentBuffer(buffer); setStep('file-target'); }} + onError={handleError} + /> + ); + } + + if (step === 'file-target') { + const name = fileNameFromUrl(currentUrl); + const suggested = urlFiles.length === 0 ? `lib/${name}` : `lib/${id}/${name}`; + return ( + { + if (!v) return 'Required'; + if (!v.endsWith('.lua')) return 'Must end in .lua'; + return null; + }} + onSubmit={(target) => { + setUrlFiles((fs) => [...fs, { name, url: currentUrl, sha256: currentSha, target, buffer: currentBuffer }]); + setStep('file-more'); + }} + /> + ); + } + + if (step === 'file-more') { + return ( + setStep('file-url')} + onNo={() => setStep('require')} + /> + ); + } + + if (step === 'require') { + const firstName = urlFiles[0]?.name ?? `${id}.lua`; + const suggested = `lib.${firstName.replace(/\.lua$/, '').replace(/\//g, '.')}`; + return ( + (v ? null : 'Required')} + onSubmit={(v) => { setRequirePath(v); setStep('confirm'); }} + /> + ); + } + + if (step === 'confirm') { + return ( + setStep('write')} + onAbort={() => { setErrorMsg('Aborted.'); setStep('error'); }} + /> + ); + } + + if (step === 'write') { + return ( + setStep('done')} + onError={handleError} + /> + ); + } + + if (step === 'done') { + return ; + } + + return ( + + ✖ Error + {errorMsg} + + ); +} + +// ── Public API ─────────────────────────────────────────────────────────────── + +export async function showAddFromUrlWizard(opts: { + projectDir: string; + lockfile: Lockfile; +}): Promise { + process.stdout.write('\x1B[2J\x1B[H'); + const { waitUntilExit } = render( + , + ); + await waitUntilExit(); +} diff --git a/cli/src/ui/package-progress.tsx b/cli/src/ui/package-progress.tsx index 8093348c..59aa42b9 100644 --- a/cli/src/ui/package-progress.tsx +++ b/cli/src/ui/package-progress.tsx @@ -126,7 +126,7 @@ function InstallProgress({ const [states, setStates] = useState( packages.map((pkg) => ({ id: pkg.id, - version: pkg.versionOverride ?? pkg.entry.source.tag, + version: pkg.versionOverride ?? pkg.entry.source.tag ?? 'url', liveComputed: !!pkg.versionOverride, status: "pending", files: pkg.files.map((f) => ({ diff --git a/cli/src/ui/package-workflow.tsx b/cli/src/ui/package-workflow.tsx index 0a5766e4..ac3c74ba 100644 --- a/cli/src/ui/package-workflow.tsx +++ b/cli/src/ui/package-workflow.tsx @@ -236,7 +236,7 @@ function PackageWorkflow({ const prefix = active ? '>> ' : installed ? ' * ' : ' '; const nameColor = active ? 'cyan' : hasUpdate ? 'yellow' : installed ? 'green' : undefined; const nameText = pkg.id.padEnd(NAME_W); - const verText = entry.source.tag.padEnd(VER_W); + const verText = (entry.source.tag ?? 'url').padEnd(VER_W); const trustText = trustLabel(entry.trust).padEnd(TRUST_W); return ( diff --git a/cli/test/package.test.mjs b/cli/test/package.test.mjs index 834a81e2..891e056b 100644 --- a/cli/test/package.test.mjs +++ b/cli/test/package.test.mjs @@ -397,3 +397,195 @@ test('update: experimental packages are skipped', () => { assert.equal(exitCode, 0); assert.ok(stdout.includes('Skipping') || stdout.includes('experimental')); }); + +/** + * Write a lockfile entry as produced by `feather package add`: + * trust: experimental, source: { url }, per-file url in each file entry. + */ +function writeUrlLock(dir, id, files) { + writeLock(dir, { + [id]: { + version: 'url', + trust: 'experimental', + source: { url: files[0].url }, + files, + installedAt: new Date().toISOString(), + }, + }); +} + +test('list --installed: shows url package with experimental trust', () => { + const dir = makeTmp(); + writeUrlLock(dir, 'my-helper', [ + { name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: 'abc' }, + ]); + const { stdout, exitCode } = run(['package', 'list', '--installed', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('my-helper')); + assert.ok(stdout.includes('experimental')); +}); + +test('audit: url package with correct file reports verified', () => { + const dir = makeTmp(); + const content = 'return {}'; + const hash = sha256(content); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'helper.lua'), content); + writeUrlLock(dir, 'my-helper', [ + { name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: hash }, + ]); + const { stdout, exitCode } = run(['package', 'audit', '--dir', dir]); + assert.equal(exitCode, 0, `unexpected failure:\n${stdout}`); + assert.ok(stdout.includes('verified')); +}); + +test('audit: url package with missing file exits 1 and reports missing', () => { + const dir = makeTmp(); + writeUrlLock(dir, 'my-helper', [ + { name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: 'abc' }, + ]); + const { stdout, exitCode } = run(['package', 'audit', '--dir', dir]); + assert.equal(exitCode, 1); + assert.ok(stdout.includes('missing')); +}); + +test('audit: url package with tampered file exits 1 and reports MODIFIED', () => { + const dir = makeTmp(); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'helper.lua'), 'tampered'); + writeUrlLock(dir, 'my-helper', [ + { name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: sha256('original') }, + ]); + const { stdout, exitCode } = run(['package', 'audit', '--dir', dir]); + assert.equal(exitCode, 1); + assert.ok(stdout.includes('MODIFIED')); +}); + +test('audit: multi-file url package all verified', () => { + const dir = makeTmp(); + const c1 = 'return "a"'; + const c2 = 'return "b"'; + mkdirSync(join(dir, 'lib', 'mypkg'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'mypkg', 'a.lua'), c1); + writeFileSync(join(dir, 'lib', 'mypkg', 'b.lua'), c2); + writeUrlLock(dir, 'mypkg', [ + { name: 'a.lua', url: 'https://example.com/a.lua', target: 'lib/mypkg/a.lua', sha256: sha256(c1) }, + { name: 'b.lua', url: 'https://example.com/b.lua', target: 'lib/mypkg/b.lua', sha256: sha256(c2) }, + ]); + const { stdout, exitCode } = run(['package', 'audit', '--dir', dir]); + assert.equal(exitCode, 0, `unexpected failure:\n${stdout}`); + assert.equal((stdout.match(/✔/g) ?? []).length, 2, 'both files should be verified'); +}); + +test('install (no args): url package already on disk with correct hash is skipped', () => { + const dir = makeTmp(); + const content = 'return {}'; + const hash = sha256(content); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'helper.lua'), content); + writeUrlLock(dir, 'my-helper', [ + { name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: hash }, + ]); + const { stdout, exitCode } = run(['package', 'install', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('up to date')); +}); + +test('remove: url package removes file and lockfile entry', () => { + const dir = makeTmp(); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'helper.lua'), 'return {}'); + writeUrlLock(dir, 'my-helper', [ + { name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: 'any' }, + ]); + const { exitCode } = run(['package', 'remove', 'my-helper', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(!existsSync(join(dir, 'lib', 'helper.lua')), 'file should be deleted'); + const lock = JSON.parse(readFileSync(join(dir, 'feather.lock.json'), 'utf8')); + assert.ok(!lock.packages['my-helper'], 'lockfile entry should be removed'); +}); + +test('remove: url package with multiple files removes all of them', () => { + const dir = makeTmp(); + mkdirSync(join(dir, 'lib', 'mypkg'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'mypkg', 'a.lua'), 'return "a"'); + writeFileSync(join(dir, 'lib', 'mypkg', 'b.lua'), 'return "b"'); + writeUrlLock(dir, 'mypkg', [ + { name: 'a.lua', url: 'https://example.com/a.lua', target: 'lib/mypkg/a.lua', sha256: 'any' }, + { name: 'b.lua', url: 'https://example.com/b.lua', target: 'lib/mypkg/b.lua', sha256: 'any' }, + ]); + const { exitCode } = run(['package', 'remove', 'mypkg', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(!existsSync(join(dir, 'lib', 'mypkg', 'a.lua'))); + assert.ok(!existsSync(join(dir, 'lib', 'mypkg', 'b.lua'))); +}); + +test('update: url package is skipped with experimental message', () => { + const dir = makeTmp(); + writeUrlLock(dir, 'my-helper', [ + { name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: 'abc' }, + ]); + const { stdout, exitCode } = run(['package', 'update', '--offline', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('Skipping') || stdout.includes('experimental')); +}); + +test('install: subpackage dry-run installs only its files', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run(['package', 'install', 'hump.camera', '--dry-run', '--offline', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('camera.lua'), 'should list camera.lua'); + assert.ok(stdout.includes('Dry run')); + assert.ok(!stdout.includes('timer.lua'), 'must not include timer.lua'); + assert.ok(!stdout.includes('signal.lua'), 'must not include signal.lua'); +}); + +test('install: parent package dry-run includes all files', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run(['package', 'install', 'hump', '--dry-run', '--offline', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('camera.lua')); + assert.ok(stdout.includes('timer.lua')); + assert.ok(stdout.includes('Dry run')); +}); + +test('info: package with subpackages lists module names', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run(['package', 'info', 'hump', '--offline', '--dir', dir]); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('hump.camera')); + assert.ok(stdout.includes('hump.timer')); +}); + +test('search: matches package description', () => { + const { stdout, exitCode } = run(['package', 'search', 'sprite', '--offline']); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('anim8')); +}); + +test('search: subpackages not shown at top level', () => { + const { stdout, exitCode } = run(['package', 'search', '--offline']); + assert.equal(exitCode, 0); + // hump.camera should not appear as a top-level entry (it's a subpackage) + const lines = stdout.split('\n').filter((l) => l.trim().startsWith('hump')); + assert.ok(lines.length > 0, 'hump should appear'); + assert.ok(!lines.some((l) => l.startsWith(' hump.camera')), 'hump.camera should not be a top-level entry'); +}); + +test('audit --json: url package included in output', () => { + const dir = makeTmp(); + const content = 'return {}'; + const hash = sha256(content); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'helper.lua'), content); + writeUrlLock(dir, 'my-helper', [ + { name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: hash }, + ]); + const { stdout, exitCode } = run(['package', 'audit', '--json', '--dir', dir]); + assert.equal(exitCode, 0); + const parsed = JSON.parse(stdout); + assert.ok(Array.isArray(parsed)); + const entry = parsed.find((r) => r.id === 'my-helper'); + assert.ok(entry, 'my-helper should appear in audit output'); + assert.equal(entry.status, 'verified'); +}); diff --git a/package.json b/package.json index fd80e9c8..5fb54baa 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "generate:plugin-catalog": "node scripts/generate-plugin-catalog.mjs", "check:plugin-catalog": "npm run generate:plugin-catalog && git diff --exit-code cli/src/generated/plugin-catalog.ts", "package:add": "npm run package:add --workspace=cli", + "package:add-url": "npm run package:add-url --workspace=cli", "package:update": "npm run package:update --workspace=cli", "package:remove": "npm run package:remove --workspace=cli", "generate:registry": "node scripts/generate-registry.mjs", diff --git a/packages/README.md b/packages/README.md index 2f99a8b7..8e6357a0 100644 --- a/packages/README.md +++ b/packages/README.md @@ -10,7 +10,7 @@ Feather includes a curated package installer for LÖVE libraries. It is not a ge | -------------- | ----------------------------------------------------------------------------------------------------------------------- | | `verified` | Feather-reviewed. Pinned release + per-file SHA-256. | | `known` | Popular community library. Checksum-pinned but not audited in depth. | -| `experimental` | Installed via `--from-url` or a version override (`name@version`). SHA-256 computed live. Requires `--allow-untrusted`. | +| `experimental` | Installed via `feather package add` or `--from-url`, or a version override (`name@version`). SHA-256 computed live. Requires `--allow-untrusted` for the CLI flag form. | By default, `verified` and `known` packages install without extra flags. `experimental` requires `--allow-untrusted`. @@ -129,11 +129,31 @@ If the requested version does not exist on GitHub, the install aborts with a cle > [!NOTE] > To go back to the Feather-reviewed version, run `feather package install anim8` (no version pin). This re-installs the registry-pinned release and resets the lockfile entry to `trust: verified`. +### `feather package add` + +Install one or more Lua files from direct URLs into your project. Launches an interactive wizard that walks you through each step: + +1. Name the dependency — how it appears in `feather.lock.json` +2. Enter file URLs — each is downloaded immediately, SHA-256 computed, and you confirm the install path +3. Optionally add more files to the same named package +4. Enter the require path for the usage hint +5. Review the full summary (files, checksums, trust warning) before anything is written + +```sh +feather package add +feather package add --dir path/to/my-game +``` + +Files are written and `feather.lock.json` is updated only after you confirm at the review step. + +> [!CAUTION] +> Packages installed this way have `trust: experimental` — they have not been reviewed by the Feather team. Only install files from sources you trust. The SHA-256 of each file is recorded in the lockfile so future `feather package audit` runs will detect any tampering. + ### `feather package install --from-url --target ` -Install a single Lua file from an arbitrary URL. The SHA-256 is computed and shown before the file is written. The package is stored in the lockfile with `trust: experimental`. +Non-interactive equivalent of `feather package add`. Use this in scripts or CI where stdin is not a TTY. Installs a single Lua file from a URL. -**Requires `--allow-untrusted`** — this is intentional. The flag name makes the risk explicit. +**Requires `--allow-untrusted`** — the flag name makes the risk explicit. ```sh feather package install --from-url https://example.com/mylib.lua \ @@ -141,9 +161,6 @@ feather package install --from-url https://example.com/mylib.lua \ --allow-untrusted ``` -> [!CAUTION] -> Experimental packages have not been reviewed by the Feather team. Only install files from sources you trust. The SHA-256 shown at install time is what will be verified by future `feather package audit` runs. - ### `feather package update [name]` Update an installed package to the latest version in the registry. Omit the name to update all installed packages. @@ -154,7 +171,7 @@ feather package update # update all feather package update --dry-run ``` -`experimental` packages (installed via `--from-url`) cannot be updated — re-install with `--from-url` to replace them. +`experimental` packages (installed via `feather package add` or `--from-url`) cannot be updated through the registry — use `feather package add` again to replace them. ### `feather package remove ` @@ -218,11 +235,12 @@ Run `feather package list` for the most up-to-date listing including any newly a ## Security model - **SHA-256 is verified before any file is written.** For registry installs, the checksum is checked against the Feather-pinned value before anything touches disk. A mismatch aborts the install. -- **Version overrides and `--from-url` compute SHA-256 live.** The hash is shown at install time and stored in the lockfile, so future `audit` runs detect tampering — but the initial download is not checked against a known-good value. +- **Registry packages are pinned to an exact commit SHA.** Each package in the catalog records the specific Git commit the files were fetched from at the time of curation — not just a tag or branch name. This means downloads are reproducible: the same bytes are fetched on every install, even if a tag is later moved or a branch advances. +- **Version overrides and `feather package add` / `--from-url` compute SHA-256 live.** The hash is shown at install time and stored in the lockfile, so future `audit` runs detect tampering — but the initial download is not checked against a known-good value. - **No post-install scripts.** No code runs during install, update, or remove. - **Files stay inside your project.** Target paths are resolved and validated against the project root. Paths that would escape (e.g. `../../`) are rejected. - **The registry is fetched over HTTPS only.** -- **`experimental` packages are flagged everywhere** — in install output, `list --installed`, and `audit` results. This includes version overrides. +- **`experimental` packages are flagged everywhere** — in install output, `list --installed`, and `audit` results. This includes version overrides and all URL installs. --- @@ -231,15 +249,58 @@ Run `feather package list` for the most up-to-date listing including any newly a To propose adding a package to the Feather catalog, open an issue at [github.com/Kyonru/feather](https://github.com/Kyonru/feather) with: - The library name and GitHub repo -- The specific release tag or commit to pin +- The specific release tag or branch to pin - Whether it meets the criteria for `verified` or `known` trust -Maintainers will compute checksums, review the library, and add it to `packages/`. +Maintainers use interactive dev scripts to add, update, and remove catalog entries. All scripts open an ink TUI wizard and regenerate the registry automatically. + +### Adding a GitHub-hosted package + +```sh +npm run package:add # add from GitHub repo (tag or branch) +npm run package:add-url # add from direct file URL(s) +npm run package:update # edit an existing package entry +npm run package:remove # remove a package from the catalog +``` + +`package:add` fetches the repo's tags and branches from GitHub, lets you select one, resolves it to an exact commit SHA (for reproducible downloads), walks through trust level, description, tags, file selection, install targets, require path, and optional submodule definitions, then writes `packages/.json` and regenerates the registry. + +`package:add-url` follows the same wizard flow for libraries not hosted on GitHub or without versioned releases — you provide direct file URLs and the wizard computes checksums and handles everything else. -To compute checksums locally while developing: +### Verifying checksums ```sh -node scripts/compute-checksums.mjs --all # verify all package files +node scripts/compute-checksums.mjs --all # re-verify all package files against their stored SHA-256 node scripts/compute-checksums.mjs https://raw.githubusercontent.com/example/lib/main/lib.lua node scripts/compute-checksums.mjs --package packages/anim8.json ``` + +### Package format + +Each file in `packages/` is a standalone JSON manifest: + +```json +{ + "type": "love2d-library", + "trust": "verified", + "description": "A 2D animation library for LÖVE", + "tags": ["animation", "sprites"], + "homepage": "https://github.com/kikito/anim8", + "license": "MIT", + "source": { + "repo": "kikito/anim8", + "tag": "v2.3.1", + "commitSha": "c1c12ec...", + "baseUrl": "https://raw.githubusercontent.com/kikito/anim8/c1c12ec.../" + }, + "install": { + "files": [ + { "name": "anim8.lua", "sha256": "abc123...", "target": "lib/anim8.lua" } + ] + }, + "require": "lib.anim8", + "example": "local anim8 = require('lib.anim8')" +} +``` + +The `commitSha` field pins downloads to the exact commit SHA of the selected tag or branch at curation time. `baseUrl` uses this SHA so fetches are immutable even if the tag is later moved. `scripts/generate-registry.mjs` assembles all package files into `cli/src/generated/registry.json`, which is bundled into the published CLI. diff --git a/scripts/generate-registry.mjs b/scripts/generate-registry.mjs index 7b710fd8..711798e9 100644 --- a/scripts/generate-registry.mjs +++ b/scripts/generate-registry.mjs @@ -33,7 +33,7 @@ for (const file of packageFiles) { console.warn(` WARN ${id}: missing "trust" field`); warnings++; } - if (!pkg.source?.baseUrl) { + if (pkg.source?.type !== 'url' && !pkg.source?.baseUrl) { console.warn(` WARN ${id}: missing source.baseUrl`); warnings++; } From a2cc134aad9acf44fa4372fef9e2cdce0f92a6c3 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Wed, 13 May 2026 21:19:43 -0400 Subject: [PATCH 16/68] package: add g3d package --- cli/src/generated/registry.json | 60 ++++++++++++++++++++++++++++++++- packages/g3d.json | 58 +++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 packages/g3d.json diff --git a/cli/src/generated/registry.json b/cli/src/generated/registry.json index d0794279..e9c2c23b 100644 --- a/cli/src/generated/registry.json +++ b/cli/src/generated/registry.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-05-13", + "updatedAt": "2026-05-14", "packages": { "anim8": { "type": "love2d-library", @@ -228,6 +228,64 @@ "require": "lib.flux", "example": "local flux = require('lib.flux')" }, + "g3d": { + "type": "love2d-library", + "trust": "verified", + "description": "Simple and easy 3D engine for LÖVE.", + "tags": [ + "3d", + "engine" + ], + "homepage": "https://github.com/groverburger/g3d", + "license": "MIT", + "source": { + "repo": "groverburger/g3d", + "tag": "master", + "commitSha": "639120acd754dc5c34402e41eb1687b1a5a3ffa8", + "baseUrl": "https://raw.githubusercontent.com/groverburger/g3d/639120acd754dc5c34402e41eb1687b1a5a3ffa8/" + }, + "install": { + "files": [ + { + "name": "g3d/camera.lua", + "sha256": "4aa73b340ef09168ff22574bc2e9f5ea8c0f6f4561f38215ed9d0ae268897dc6", + "target": "lib/g3d/camera.lua" + }, + { + "name": "g3d/collisions.lua", + "sha256": "f93b0806e75f570a41e35d1a45978bf3217e7ca970d442ef9b18dea8aeef0850", + "target": "lib/g3d/collisions.lua" + }, + { + "name": "g3d/init.lua", + "sha256": "02f5c867264b8ca1d4501a4b7537aba35600d013950bc3d05e9b3b372e6a4dc6", + "target": "lib/g3d/init.lua" + }, + { + "name": "g3d/matrices.lua", + "sha256": "c6579f4c87032bfd0562a679ce261e51c6c42e4a6a69e05102de087e15625f9a", + "target": "lib/g3d/matrices.lua" + }, + { + "name": "g3d/model.lua", + "sha256": "c52054e0d5db5688417712449606bfd1eaedcdc85a5a4fc3eebf482f2d38360b", + "target": "lib/g3d/model.lua" + }, + { + "name": "g3d/objloader.lua", + "sha256": "208002fe5edd9ef05ebd710f0a07701f2fbadfc3cf74059cd701832f195c633e", + "target": "lib/g3d/objloader.lua" + }, + { + "name": "g3d/vectors.lua", + "sha256": "4d23c3334fa1e6b43fbf7bf4fe00969480ee6a4c82a7007f87b3e10df42c411e", + "target": "lib/g3d/vectors.lua" + } + ] + }, + "require": "lib.g3d", + "example": "local g3d = require('lib.g3d')" + }, "hump.camera": { "parent": "hump", "type": "love2d-library", diff --git a/packages/g3d.json b/packages/g3d.json new file mode 100644 index 00000000..b68b0a1d --- /dev/null +++ b/packages/g3d.json @@ -0,0 +1,58 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "Simple and easy 3D engine for LÖVE.", + "tags": [ + "3d", + "engine" + ], + "homepage": "https://github.com/groverburger/g3d", + "license": "MIT", + "source": { + "repo": "groverburger/g3d", + "tag": "master", + "commitSha": "639120acd754dc5c34402e41eb1687b1a5a3ffa8", + "baseUrl": "https://raw.githubusercontent.com/groverburger/g3d/639120acd754dc5c34402e41eb1687b1a5a3ffa8/" + }, + "install": { + "files": [ + { + "name": "g3d/camera.lua", + "sha256": "4aa73b340ef09168ff22574bc2e9f5ea8c0f6f4561f38215ed9d0ae268897dc6", + "target": "lib/g3d/camera.lua" + }, + { + "name": "g3d/collisions.lua", + "sha256": "f93b0806e75f570a41e35d1a45978bf3217e7ca970d442ef9b18dea8aeef0850", + "target": "lib/g3d/collisions.lua" + }, + { + "name": "g3d/init.lua", + "sha256": "02f5c867264b8ca1d4501a4b7537aba35600d013950bc3d05e9b3b372e6a4dc6", + "target": "lib/g3d/init.lua" + }, + { + "name": "g3d/matrices.lua", + "sha256": "c6579f4c87032bfd0562a679ce261e51c6c42e4a6a69e05102de087e15625f9a", + "target": "lib/g3d/matrices.lua" + }, + { + "name": "g3d/model.lua", + "sha256": "c52054e0d5db5688417712449606bfd1eaedcdc85a5a4fc3eebf482f2d38360b", + "target": "lib/g3d/model.lua" + }, + { + "name": "g3d/objloader.lua", + "sha256": "208002fe5edd9ef05ebd710f0a07701f2fbadfc3cf74059cd701832f195c633e", + "target": "lib/g3d/objloader.lua" + }, + { + "name": "g3d/vectors.lua", + "sha256": "4d23c3334fa1e6b43fbf7bf4fe00969480ee6a4c82a7007f87b3e10df42c411e", + "target": "lib/g3d/vectors.lua" + } + ] + }, + "require": "lib.g3d", + "example": "local g3d = require('lib.g3d')" +} From c7c449b4c647e82b869c170c490f828eb3fcaec2 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Wed, 13 May 2026 21:30:52 -0400 Subject: [PATCH 17/68] package: clean after input --- cli/scripts/add-package-url.tsx | 6 ++---- cli/scripts/add-package.tsx | 6 ++---- cli/scripts/update-package.tsx | 6 ++---- cli/src/ui/package-add-url.tsx | 15 ++++++++------- 4 files changed, 14 insertions(+), 19 deletions(-) diff --git a/cli/scripts/add-package-url.tsx b/cli/scripts/add-package-url.tsx index 5771b0a0..f2c33ddb 100644 --- a/cli/scripts/add-package-url.tsx +++ b/cli/scripts/add-package-url.tsx @@ -196,8 +196,7 @@ function DoneStep({ id, onExit }: { id: string; onExit: () => void }) { function Wizard() { const { exit } = useApp(); - const [step, _setStep] = useState('id'); - const setStep = (next: Step) => { process.stdout.write('\x1B[2J\x1B[H'); _setStep(next); }; + const [step, setStep] = useState('id'); const [data, setData] = useState>({}); const [urlFiles, setUrlFiles] = useState([]); const [currentUrl, setCurrentUrl] = useState(''); @@ -484,6 +483,5 @@ function Wizard() { ); } -process.stdout.write('\x1B[2J\x1B[H'); -const { waitUntilExit } = render(); +const { waitUntilExit } = render(, { alternateScreen: true }); await waitUntilExit(); diff --git a/cli/scripts/add-package.tsx b/cli/scripts/add-package.tsx index 18a075fe..c0808e64 100644 --- a/cli/scripts/add-package.tsx +++ b/cli/scripts/add-package.tsx @@ -80,8 +80,7 @@ function DoneStep({ id, onExit }: { id: string; onExit: () => void }) { function Wizard() { const { exit } = useApp(); - const [step, _setStep] = useState('id'); - const setStep = (next: Step) => { process.stdout.write('\x1B[2J\x1B[H'); _setStep(next); }; + const [step, setStep] = useState('id'); const [data, setData] = useState>({}); const [fetchedTags, setFetchedTags] = useState([]); const [fetchedLabels, setFetchedLabels] = useState([]); @@ -400,6 +399,5 @@ function Wizard() { ); } -process.stdout.write('\x1B[2J\x1B[H'); -const { waitUntilExit } = render(); +const { waitUntilExit } = render(, { alternateScreen: true }); await waitUntilExit(); diff --git a/cli/scripts/update-package.tsx b/cli/scripts/update-package.tsx index 80b46ad1..6f0bcc76 100644 --- a/cli/scripts/update-package.tsx +++ b/cli/scripts/update-package.tsx @@ -93,8 +93,7 @@ function DoneStep({ id, onExit }: { id: string; onExit: () => void }) { function Wizard() { const { exit } = useApp(); - const [step, _setStep] = useState('pick'); - const setStep = (next: Step) => { process.stdout.write('\x1B[2J\x1B[H'); _setStep(next); }; + const [step, setStep] = useState('pick'); const [data, setData] = useState>({}); const [fetchedTags, setFetchedTags] = useState([]); const [fetchedFiles, setFetchedFiles] = useState([]); @@ -471,6 +470,5 @@ function Wizard() { ); } -process.stdout.write('\x1B[2J\x1B[H'); -const { waitUntilExit } = render(); +const { waitUntilExit } = render(, { alternateScreen: true }); await waitUntilExit(); diff --git a/cli/src/ui/package-add-url.tsx b/cli/src/ui/package-add-url.tsx index 094a507e..37a4ad30 100644 --- a/cli/src/ui/package-add-url.tsx +++ b/cli/src/ui/package-add-url.tsx @@ -312,10 +312,9 @@ function DoneStep({ require: string; onExit: () => void; }) { - useEffect(() => { - const t = setTimeout(onExit, 300); - return () => clearTimeout(t); - }, [onExit]); + useInput((_, key) => { + if (key.return || key.escape) onExit(); + }); return ( @@ -329,6 +328,9 @@ function DoneStep({ Trust: experimental ⚠ — not reviewed by the Feather team + + Press Enter to exit + ); } @@ -337,8 +339,7 @@ function DoneStep({ function Wizard({ projectDir, lockfile }: { projectDir: string; lockfile: Lockfile }) { const { exit } = useApp(); - const [step, _setStep] = useState('id'); - const setStep = (next: Step) => { process.stdout.write('\x1B[2J\x1B[H'); _setStep(next); }; + const [step, setStep] = useState('id'); const [id, setId] = useState(''); const [requirePath, setRequirePath] = useState(''); const [urlFiles, setUrlFiles] = useState([]); @@ -483,9 +484,9 @@ export async function showAddFromUrlWizard(opts: { projectDir: string; lockfile: Lockfile; }): Promise { - process.stdout.write('\x1B[2J\x1B[H'); const { waitUntilExit } = render( , + { alternateScreen: true }, ); await waitUntilExit(); } From 0f6af31e447d1eab4ecec1920f1f017cf5f8d9e1 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Wed, 13 May 2026 21:31:17 -0400 Subject: [PATCH 18/68] package: add tiny ecs package --- cli/src/generated/registry.json | 35 +++++++++++++++++++++++++++++++++ packages/tiny-ecs.json | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 packages/tiny-ecs.json diff --git a/cli/src/generated/registry.json b/cli/src/generated/registry.json index e9c2c23b..ad592b43 100644 --- a/cli/src/generated/registry.json +++ b/cli/src/generated/registry.json @@ -1518,6 +1518,41 @@ }, "require": "lib.sysl-text.slog-text", "example": "local sysl_text = require('lib.sysl-text.slog-text')" + }, + "tiny-ecs": { + "type": "love2d-library", + "trust": "verified", + "description": "ECS for Lua", + "tags": [ + "ecs", + "entity", + "component", + "system" + ], + "homepage": "https://github.com/bakpakin/tiny-ecs", + "license": "MIT", + "source": { + "repo": "bakpakin/tiny-ecs", + "tag": "master", + "commitSha": "821914795db365d73dcb973b4a54741423650dc9", + "baseUrl": "https://raw.githubusercontent.com/bakpakin/tiny-ecs/821914795db365d73dcb973b4a54741423650dc9/" + }, + "install": { + "files": [ + { + "name": "init.lua", + "sha256": "73461b847b513ac10aeb0c65366de52940fd736d2c7026d06c0425ea78b2b175", + "target": "lib/tiny-ecs/init.lua" + }, + { + "name": "tiny.lua", + "sha256": "a766b6a35949546746d041e9f4806a4ea068605b9657e2c7dedbc636c2cdf805", + "target": "lib/tiny-ecs/tiny.lua" + } + ] + }, + "require": "lib.tiny-ecs", + "example": "local tiny_ecs = require('lib.tiny-ecs')" } } } diff --git a/packages/tiny-ecs.json b/packages/tiny-ecs.json new file mode 100644 index 00000000..8c1a976d --- /dev/null +++ b/packages/tiny-ecs.json @@ -0,0 +1,35 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "ECS for Lua", + "tags": [ + "ecs", + "entity", + "component", + "system" + ], + "homepage": "https://github.com/bakpakin/tiny-ecs", + "license": "MIT", + "source": { + "repo": "bakpakin/tiny-ecs", + "tag": "master", + "commitSha": "821914795db365d73dcb973b4a54741423650dc9", + "baseUrl": "https://raw.githubusercontent.com/bakpakin/tiny-ecs/821914795db365d73dcb973b4a54741423650dc9/" + }, + "install": { + "files": [ + { + "name": "init.lua", + "sha256": "73461b847b513ac10aeb0c65366de52940fd736d2c7026d06c0425ea78b2b175", + "target": "lib/tiny-ecs/init.lua" + }, + { + "name": "tiny.lua", + "sha256": "a766b6a35949546746d041e9f4806a4ea068605b9657e2c7dedbc636c2cdf805", + "target": "lib/tiny-ecs/tiny.lua" + } + ] + }, + "require": "lib.tiny-ecs", + "example": "local tiny_ecs = require('lib.tiny-ecs')" +} From a3e2217d53f228d75e7be9e84ca61c4ea82be155 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Fri, 15 May 2026 23:29:13 -0400 Subject: [PATCH 19/68] cli: codebase refactoring --- cli/scripts/add-package-url.tsx | 12 +- cli/scripts/wizard-shared.tsx | 477 +++------------------- cli/src/commands/doctor.ts | 34 +- cli/src/commands/init.ts | 19 +- cli/src/commands/package.ts | 17 +- cli/src/commands/plugin.ts | 56 +-- cli/src/commands/remove.ts | 9 +- cli/src/commands/update.ts | 20 +- cli/src/hooks/use-text-input.tsx | 56 +++ cli/src/lib/github.ts | 24 ++ cli/src/lib/paths.ts | 25 ++ cli/src/lib/plugin-utils.ts | 30 ++ cli/src/lib/trust.ts | 19 + cli/src/lib/url.ts | 7 + cli/src/ui/components.tsx | 493 ++++++++++++++++++++++ cli/src/ui/init-mode.tsx | 12 +- cli/src/ui/package-add-url.tsx | 323 +++++---------- cli/src/ui/package-add.tsx | 681 +++++++++++++++++++++++++++++++ cli/src/ui/package-progress.tsx | 7 +- cli/src/ui/package-workflow.tsx | 12 +- cli/src/ui/plugin-workflow.tsx | 305 +++++--------- cli/src/ui/remove-workflow.tsx | 42 +- cli/src/ui/update-workflow.tsx | 107 ++--- packages/README.md | 35 +- 24 files changed, 1693 insertions(+), 1129 deletions(-) create mode 100644 cli/src/hooks/use-text-input.tsx create mode 100644 cli/src/lib/github.ts create mode 100644 cli/src/lib/paths.ts create mode 100644 cli/src/lib/plugin-utils.ts create mode 100644 cli/src/lib/trust.ts create mode 100644 cli/src/lib/url.ts create mode 100644 cli/src/ui/components.tsx create mode 100644 cli/src/ui/package-add.tsx diff --git a/cli/scripts/add-package-url.tsx b/cli/scripts/add-package-url.tsx index f2c33ddb..21c5d1c8 100644 --- a/cli/scripts/add-package-url.tsx +++ b/cli/scripts/add-package-url.tsx @@ -27,6 +27,9 @@ import { Hint, Spinner, } from './wizard-shared.js'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import { fileNameFromUrl } from '../src/lib/url.js'; interface UrlFile { name: string; @@ -92,15 +95,6 @@ function buildPackageJson( return obj; } -function fileNameFromUrl(url: string): string { - try { - const path = new URL(url).pathname; - return path.split('/').filter(Boolean).pop() ?? 'file.lua'; - } catch { - return url.split('/').pop() ?? 'file.lua'; - } -} - function FileFetchStep({ url, onDone, diff --git a/cli/scripts/wizard-shared.tsx b/cli/scripts/wizard-shared.tsx index 9a1fde31..6cb52f26 100644 --- a/cli/scripts/wizard-shared.tsx +++ b/cli/scripts/wizard-shared.tsx @@ -8,6 +8,29 @@ import { createHash } from 'node:crypto'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import { useTextInput } from '../src/hooks/use-text-input.js'; +import { + CursorText, + Spinner, + Header, + Hint, + TextInputStep, + SelectStep, + MultiSelectStep, + AutoStep, + TargetsStep, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore +} from '../src/ui/components.js'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import { GH_HEADERS, fetchCommitSha, fetchLuaFiles } from '../src/lib/github.js'; + +export { CursorText, Spinner, Header, Hint, TextInputStep, SelectStep, MultiSelectStep, AutoStep, TargetsStep }; +export { GH_HEADERS, fetchCommitSha, fetchLuaFiles }; + export const __dirname = dirname(fileURLToPath(import.meta.url)); export const root = resolve(__dirname, '../..'); export const packagesDir = join(root, 'packages'); @@ -40,401 +63,6 @@ export interface FormData { export type InkKey = Parameters[0]>[1]; -export function Header({ step, total, title }: { step: number; total: number; title?: string }) { - return ( - - - {' '} - {title ?? 'feather package:add'} - - {` Step ${step} of ${total}`} - - ); -} - -export function Hint({ children }: { children: string }) { - return ( - - {' '} - {children} - - ); -} - -export function useTextInput(initial: string) { - const [value, setValue] = useState(initial); - const [cursor, setCursor] = useState(initial.length); - - const reset = (next: string) => { - setValue(next); - setCursor(next.length); - }; - - const handleKey = (input: string, key: InkKey) => { - if (key.leftArrow) { - setCursor((c) => Math.max(0, c - 1)); - return true; - } - if (key.rightArrow) { - setCursor((c) => Math.min(value.length, c + 1)); - return true; - } - if (key.backspace) { - if (cursor === 0) return true; - const pos = cursor; - setValue((v) => v.slice(0, pos - 1) + v.slice(pos)); - setCursor((c) => c - 1); - return true; - } - if (key.delete) { - const pos = cursor; - setValue((v) => v.slice(0, pos) + v.slice(pos + 1)); - return true; - } - if (!key.ctrl && !key.meta && input) { - const pos = cursor; - setValue((v) => v.slice(0, pos) + input + v.slice(pos)); - setCursor((c) => c + 1); - return true; - } - return false; - }; - - return { - value, - cursor, - reset, - handleKey, - before: value.slice(0, cursor), - at: value[cursor] ?? '', - after: value.slice(cursor + 1), - }; -} - -export function CursorText({ before, at, after }: { before: string; at: string; after: string }) { - return ( - - {before} - {at || ' '} - {after} - - ); -} - -interface TextInputStepProps { - stepNum: number; - total: number; - label: string; - hint?: string; - defaultValue?: string; - validate?: (v: string) => string | null; - onSubmit: (value: string) => void; - title?: string; -} - -export function TextInputStep({ - stepNum, - total, - label, - hint, - defaultValue = '', - validate, - onSubmit, - title, -}: TextInputStepProps) { - const input = useTextInput(defaultValue); - const [error, setError] = useState(null); - - useInput((char, key) => { - if (key.return) { - const err = validate ? validate(input.value.trim()) : null; - if (err) { - setError(err); - return; - } - onSubmit(input.value.trim()); - return; - } - input.handleKey(char, key); - setError(null); - }); - - return ( - -
- - {' '} - {label} - - {hint && {hint}} - - {' '} - - - {error && ( - - - {' ✖ '} - {error} - - - )} - - {' ←→ move · Backspace/Delete edit · Enter confirm'} - - - ); -} - -interface SelectStepProps { - stepNum: number; - total: number; - label: string; - hint?: string; - options: string[]; - labels?: string[]; // display labels parallel to options; falls back to option value - initialIndex?: number; - onSelect: (value: string) => void; - title?: string; -} - -export function SelectStep({ - stepNum, - total, - label, - hint, - options, - labels, - initialIndex = 0, - onSelect, - title, -}: SelectStepProps) { - const [cursor, setCursor] = useState(initialIndex); - - useInput((_, key) => { - if (key.upArrow) setCursor((c) => Math.max(0, c - 1)); - if (key.downArrow) setCursor((c) => Math.min(options.length - 1, c + 1)); - if (key.return) onSelect(options[cursor]!); - }); - - return ( - -
- - {' '} - {label} - - {hint && {hint}} - - {options.map((opt, i) => ( - - - {' '} - {i === cursor ? '❯ ' : ' '} - {labels?.[i] ?? opt} - - - ))} - - - {' ↑↓ navigate · Enter to select'} - - - ); -} - -interface MultiSelectStepProps { - stepNum: number; - total: number; - label: string; - hint?: string; - options: string[]; - initialSelected?: Set; - onSubmit: (selected: string[]) => void; - title?: string; -} - -export function MultiSelectStep({ - stepNum, - total, - label, - hint, - options, - initialSelected, - onSubmit, - title, -}: MultiSelectStepProps) { - const [cursor, setCursor] = useState(0); - const [selected, setSelected] = useState>(initialSelected ?? new Set(options.map((_, i) => i))); - - useInput((input, key) => { - if (key.upArrow) setCursor((c) => Math.max(0, c - 1)); - if (key.downArrow) setCursor((c) => Math.min(options.length - 1, c + 1)); - if (input === ' ') { - setSelected((s) => { - const next = new Set(s); - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - next.has(cursor) ? next.delete(cursor) : next.add(cursor); - return next; - }); - } - if (key.return) { - const chosen = options.filter((_, i) => selected.has(i)); - if (chosen.length > 0) onSubmit(chosen); - } - }); - - return ( - -
- - {' '} - {label} - - {hint && {hint}} - - {options.map((opt, i) => ( - - - {' '} - {i === cursor ? '❯ ' : ' '} - {selected.has(i) ? '◉ ' : '○ '} - {opt} - - - ))} - - - {' ↑↓ navigate · Space toggle · Enter confirm'} - - - ); -} - -const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - -export function Spinner({ label }: { label: string }) { - const [frame, setFrame] = useState(0); - - useEffect(() => { - const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 80); - return () => clearInterval(id); - }, []); - - return ( - - {SPINNER_FRAMES[frame]} - {label} - - ); -} - -interface AutoStepProps { - label: string; - run: () => Promise; - onError: (msg: string) => void; -} - -export function AutoStep({ label, run, onError }: AutoStepProps) { - const [status, setStatus] = useState<'running' | 'done' | 'error'>('running'); - const [error, setError] = useState(''); - - useEffect(() => { - run() - .then(() => setStatus('done')) - .catch((err: Error) => { - setError(err.message); - setStatus('error'); - onError(err.message); - }); - }, []); - - return ( - - {status === 'running' && } - {status === 'done' && ✔ {label}} - {status === 'error' && ✖ {error}} - - ); -} - -interface TargetsStepProps { - stepNum: number; - total: number; - id: string; - files: string[]; - initialTargets?: Record; - onSubmit: (targets: Record) => void; - title?: string; -} - -export function TargetsStep({ stepNum, total, id, files, initialTargets, onSubmit, title }: TargetsStepProps) { - const defaultTarget = (f: string) => initialTargets?.[f] ?? (files.length === 1 ? `lib/${f}` : `lib/${id}/${f}`); - - const [index, setIndex] = useState(0); - const [targets, setTargets] = useState>( - Object.fromEntries(files.map((f) => [f, defaultTarget(f)])), - ); - const textInput = useTextInput(defaultTarget(files[0]!)); - const [error, setError] = useState(null); - - const file = files[index]!; - - const advance = () => { - const val = textInput.value.trim(); - if (!val) { - setError('Target path is required'); - return; - } - if (!val.endsWith('.lua')) { - setError('Target path must end in .lua'); - return; - } - const next = { ...targets, [file]: val }; - setTargets(next); - if (index + 1 < files.length) { - const nextFile = files[index + 1]!; - setIndex(index + 1); - textInput.reset(next[nextFile] ?? defaultTarget(nextFile)); - setError(null); - } else { - onSubmit(next); - } - }; - - useInput((char, key) => { - if (key.return) { - advance(); - return; - } - textInput.handleKey(char, key); - setError(null); - }); - - return ( - -
- {' '}Install target paths - {`File ${index + 1} of ${files.length}: ${file}`} - - {' '} - - - {error && ( - - - {' ✖ '} - {error} - - - )} - - {' ←→ move · Backspace/Delete edit · Enter confirm'} - - - ); -} - interface ReviewStepProps { stepNum: number; total: number; @@ -605,7 +233,10 @@ export function SubpackagesStep({ } else if (phase === 'id') { if (key.return) { const val = idInput.value.trim(); - if (!val) { setIdError('Required'); return; } + if (!val) { + setIdError('Required'); + return; + } setCurrentId(val); setFileSelected(new Set()); setFileCursor(0); @@ -619,6 +250,7 @@ export function SubpackagesStep({ if (input === ' ') { setFileSelected((s) => { const next = new Set(s); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions next.has(fileCursor) ? next.delete(fileCursor) : next.add(fileCursor); return next; }); @@ -635,7 +267,10 @@ export function SubpackagesStep({ } else if (phase === 'require') { if (key.return) { const val = reqInput.value.trim(); - if (!val) { setReqError('Required'); return; } + if (!val) { + setReqError('Required'); + return; + } setAccumulated((a) => ({ ...a, [currentId]: { files: currentFiles, require: val } })); setPhase('add-more'); return; @@ -668,9 +303,10 @@ export function SubpackagesStep({ {accKeys.map((k) => ( {' ✔ '} - {k} - {' '} - ({accumulated[k]!.files.length} file{accumulated[k]!.files.length === 1 ? '' : 's'}) + {k}{' '} + + ({accumulated[k]!.files.length} file{accumulated[k]!.files.length === 1 ? '' : 's'}) + ))} @@ -693,7 +329,10 @@ export function SubpackagesStep({ {idError && ( - {' ✖ '}{idError} + + {' ✖ '} + {idError} + )} @@ -707,7 +346,9 @@ export function SubpackagesStep({ return (
- {' '}Files for {currentId} + + {' '}Files for {currentId} + Select which files belong to this submodule {selectedFiles.map((f, i) => ( @@ -731,7 +372,9 @@ export function SubpackagesStep({ return (
- {' '}Require path for {currentId} + + {' '}Require path for {currentId} + e.g. lib.hump.camera {' '} @@ -739,7 +382,10 @@ export function SubpackagesStep({ {reqError && ( - {' ✖ '}{reqError} + + {' ✖ '} + {reqError} + )} @@ -749,8 +395,6 @@ export function SubpackagesStep({ ); } -const GH_HEADERS = { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }; - export interface RepoMeta { tags: string[]; branches: string[]; @@ -780,27 +424,6 @@ export async function fetchRepoMeta(repo: string): Promise { }; } -export async function fetchCommitSha(repo: string, ref: string): Promise { - const res = await fetch(`https://api.github.com/repos/${repo}/commits/${encodeURIComponent(ref)}`, { - headers: GH_HEADERS, - }); - if (!res.ok) throw new Error(`GitHub API ${res.status} resolving ${repo}@${ref} to commit SHA`); - const data = (await res.json()) as { sha: string }; - return data.sha; -} - -export async function fetchLuaFiles(repo: string, tag: string): Promise { - const res = await fetch(`https://api.github.com/repos/${repo}/git/trees/${tag}?recursive=1`, { - headers: GH_HEADERS, - }); - if (!res.ok) throw new Error(`GitHub API ${res.status} for ${repo}@${tag}`); - const data = (await res.json()) as { tree: Array<{ path: string; type: string }> }; - return data.tree - .filter((node) => node.type === 'blob' && node.path.endsWith('.lua')) - .map((node) => node.path) - .sort(); -} - export function buildPackageJson(data: FormData): object { const obj: Record = { type: 'love2d-library', diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts index 2abe0aeb..84e8fb09 100644 --- a/cli/src/commands/doctor.ts +++ b/cli/src/commands/doctor.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { createConnection } from "node:net"; import { spawnSync } from "node:child_process"; @@ -6,6 +6,7 @@ import chalk from "chalk"; import { findLoveBinary, getLoveVersion } from "../lib/love.js"; import { loadConfig } from "../lib/config.js"; import { normalizeInstallDir } from "../lib/install.js"; +import { parseManagedValue, findInstalledPluginDirs, readPluginManifest } from "../lib/plugin-utils.js"; type Severity = "pass" | "warn" | "fail" | "info"; @@ -93,10 +94,6 @@ function uncommentedLua(src: string): string { .join("\n"); } -function parseManagedValue(src: string, key: string): string | null { - return src.match(new RegExp(`^--\\s*${key}:\\s*(.+)$`, "m"))?.[1]?.trim() ?? null; -} - function luaBoolEnabled(src: string, key: string): boolean { return new RegExp(`${key}\\s*=\\s*true\\b`).test(src); } @@ -110,27 +107,6 @@ function isWeakApiKey(value: unknown): boolean { return typeof value !== "string" || value.trim().length < 24 || value === "change-me" || value === "dev"; } -function findInstalledPluginDirs(root: string): string[] { - if (!existsSync(root)) return []; - - const found: string[] = []; - for (const entry of readdirSync(root, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const dir = join(root, entry.name); - if (existsSync(join(dir, "manifest.lua"))) { - found.push(dir); - } else { - found.push(...findInstalledPluginDirs(dir)); - } - } - return found; -} - -function readPluginId(pluginDir: string): string | null { - const src = readIfExists(join(pluginDir, "manifest.lua")); - return src?.match(/id\s*=\s*"([^"]+)"/)?.[1] ?? null; -} - function renderReport(checks: DoctorCheck[], projectDir: string): void { console.log(chalk.bold("\nFeather doctor\n")); console.log(chalk.dim(`Project: ${projectDir}\n`)); @@ -292,7 +268,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) existsSync(pluginRoot) ? undefined : "Core-only installs are valid; run `feather plugin` to manage plugins.", ); add(checks, "Plugins", "Installed plugins", pluginDirs.length > 0 ? "pass" : "info", `${pluginDirs.length}`); - const malformed = pluginDirs.filter((dir) => !readPluginId(dir)); + const malformed = pluginDirs.filter((dir) => !readPluginManifest(dir)?.id); if (malformed.length > 0) { add(checks, "Plugins", "Plugin manifests", "warn", `${malformed.length} missing id`, "Reinstall affected plugins with `feather plugin update`."); } else if (pluginDirs.length > 0) { @@ -334,7 +310,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) ); const hotReloadEnabled = /hotReload\s*=\s*\{[\s\S]*?enabled\s*=\s*true/.test(activeConfigSource); - const hotReloadPluginIncluded = hasConfigArrayValue(activeConfigSource, "include", "hot-reload") || pluginDirs.some((dir) => readPluginId(dir) === "hot-reload"); + const hotReloadPluginIncluded = hasConfigArrayValue(activeConfigSource, "include", "hot-reload") || pluginDirs.some((dir) => readPluginManifest(dir)?.id === "hot-reload"); const persistToDisk = /hotReload\s*=\s*\{[\s\S]*?persistToDisk\s*=\s*true/.test(activeConfigSource); const broadHotReload = /allow\s*=\s*\{[\s\S]*["'][^"']+\.\*["']/.test(activeConfigSource); add( @@ -362,7 +338,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) add(checks, "Safety", "Hot reload persistence", "warn", "persistToDisk=true", "Persisted patches survive app restarts until restored or cleared."); } - const consoleIncluded = hasConfigArrayValue(activeConfigSource, "include", "console") || pluginDirs.some((dir) => readPluginId(dir) === "console"); + const consoleIncluded = hasConfigArrayValue(activeConfigSource, "include", "console") || pluginDirs.some((dir) => readPluginManifest(dir)?.id === "console"); if (consoleIncluded) { const apiKey = config?.apiKey; add( diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index c5e722c0..6e6a9b21 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -1,6 +1,5 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import { basename, dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { basename, join, resolve } from 'node:path'; import chalk from 'chalk'; import ora from 'ora'; import { @@ -16,6 +15,7 @@ import { import { configTemplate, luaKey, luaValue } from '../lib/config.js'; import { chooseInitMode, type InitMode, type InitSetup } from '../ui/init-mode.js'; import { pluginCatalog } from '../generated/plugin-catalog.js'; +import { resolveLocalLuaRoot } from '../lib/paths.js'; export interface InitOptions { branch?: string; @@ -29,7 +29,6 @@ export interface InitOptions { } const knownPlugins = pluginCatalog.map((plugin) => plugin.id); -const __dirname = dirname(fileURLToPath(import.meta.url)); const toLocalName = (id: string) => id @@ -75,20 +74,6 @@ function patchMainLuaForManual(mainPath: string): boolean { return true; } -function bundledLuaRoot(): string { - return resolve(__dirname, '../../lua'); -} - -function repoLuaRoot(): string | null { - const candidate = resolve(__dirname, '../../../src-lua'); - return existsSync(join(candidate, 'feather', 'init.lua')) ? candidate : null; -} - -function resolveLocalLuaRoot(opts: InitOptions): string { - if (opts.localSrc) return resolve(opts.localSrc); - return repoLuaRoot() ?? bundledLuaRoot(); -} - export async function initCommand(dir: string, opts: InitOptions): Promise { const target = resolve(dir); diff --git a/cli/src/commands/package.ts b/cli/src/commands/package.ts index e86fa15d..2edcb674 100644 --- a/cli/src/commands/package.ts +++ b/cli/src/commands/package.ts @@ -9,18 +9,9 @@ import { installFromUrl, restorePackage } from '../lib/package/install.js'; import { auditLockfile } from '../lib/package/audit.js'; import { showPackageBrowser } from '../ui/package-workflow.js'; import { showInstallProgress } from '../ui/package-progress.js'; -import { showAddFromUrlWizard } from '../ui/package-add-url.js'; - -function findProjectDir(cwd = process.cwd()): string { - if (existsSync(join(cwd, 'main.lua'))) return cwd; - return cwd; -} - -function trustBadge(trust: string): string { - if (trust === 'verified') return chalk.green('[verified]'); - if (trust === 'known') return chalk.yellow('[known]'); - return chalk.red('[experimental]'); -} +import { showAddWizard } from '../ui/package-add.js'; +import { findProjectDir } from '../lib/paths.js'; +import { trustBadge } from '../lib/trust.js'; export type PackageSearchOptions = { offline?: boolean; @@ -482,7 +473,7 @@ export type PackageAddOptions = { export async function packageAddCommand(opts: PackageAddOptions = {}): Promise { const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); const lockfile = readLockfile(projectDir); - await showAddFromUrlWizard({ projectDir, lockfile }); + await showAddWizard({ projectDir, lockfile }); } export type PackageAuditOptions = { diff --git a/cli/src/commands/plugin.ts b/cli/src/commands/plugin.ts index e696a272..7d07f767 100644 --- a/cli/src/commands/plugin.ts +++ b/cli/src/commands/plugin.ts @@ -1,6 +1,5 @@ -import { existsSync, readFileSync, rmSync, readdirSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { existsSync, rmSync } from "node:fs"; +import { join, resolve } from "node:path"; import chalk from "chalk"; import ora from "ora"; import { @@ -12,62 +11,19 @@ import { normalizeInstallDir, } from "../lib/install.js"; import { choosePluginUpdateWorkflow, choosePluginWorkflow } from "../ui/plugin-workflow.js"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -function findProjectDir(cwd = process.cwd()): string { - if (existsSync(join(cwd, "feather", "init.lua"))) return cwd; - if (existsSync(join(cwd, "main.lua"))) return cwd; - return cwd; -} +import { findProjectDir, resolveLocalLuaRoot } from "../lib/paths.js"; +import { findInstalledPluginDirs, readPluginManifest } from "../lib/plugin-utils.js"; function pluginsDir(projectDir: string, installDir = "feather"): string { return join(projectDir, normalizeInstallDir(installDir), "plugins"); } -function bundledLuaRoot(): string { - return resolve(__dirname, "../../lua"); -} - -function repoLuaRoot(): string | null { - const candidate = resolve(__dirname, "../../../src-lua"); - return existsSync(join(candidate, "feather", "init.lua")) ? candidate : null; -} - -function resolveLocalLuaRoot(opts: { localSrc?: string }): string { - if (opts.localSrc) return resolve(opts.localSrc); - return repoLuaRoot() ?? bundledLuaRoot(); -} - -function readManifest(pluginDir: string): Record | null { - // Try to extract id/name/version from manifest.lua via simple regex - const manifestPath = join(pluginDir, "manifest.lua"); - if (!existsSync(manifestPath)) return null; - const src = readFileSync(manifestPath, "utf8"); - const get = (key: string) => src.match(new RegExp(`${key}\\s*=\\s*"([^"]+)"`))?.[1] ?? ""; - return { id: get("id"), name: get("name"), version: get("version") }; -} - -function findInstalledPluginDirs(root: string): string[] { - const found: string[] = []; - for (const entry of readdirSync(root, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const dir = join(root, entry.name); - if (existsSync(join(dir, "manifest.lua"))) { - found.push(dir); - } else { - found.push(...findInstalledPluginDirs(dir)); - } - } - return found; -} - function getInstalledPluginIds(projectDir: string, installDir = "feather"): string[] { const dirPath = pluginsDir(projectDir, installDir); if (!existsSync(dirPath)) return []; return findInstalledPluginDirs(dirPath) - .map((dir) => readManifest(dir)?.id) + .map((dir) => readPluginManifest(dir)?.id) .filter((id): id is string => Boolean(id)) .sort(); } @@ -90,7 +46,7 @@ export async function pluginListCommand(dir?: string, installDir = "feather"): P console.log(chalk.bold(`\nInstalled plugins (${dirs.length})\n`)); for (const dir of dirs) { - const meta = readManifest(dir); + const meta = readPluginManifest(dir); if (meta) { console.log(` ${chalk.cyan(meta.id.padEnd(24))} ${chalk.dim(meta.version.padEnd(8))} ${meta.name}`); } else { diff --git a/cli/src/commands/remove.ts b/cli/src/commands/remove.ts index ad43efd9..eca93c7b 100644 --- a/cli/src/commands/remove.ts +++ b/cli/src/commands/remove.ts @@ -3,6 +3,7 @@ import { join, resolve } from "node:path"; import chalk from "chalk"; import { normalizeInstallDir } from "../lib/install.js"; import { chooseRemoveWorkflow, type RemoveTarget } from "../ui/remove-workflow.js"; +import { parseManagedValue } from "../lib/plugin-utils.js"; export interface RemoveOptions { yes?: boolean; @@ -22,16 +23,12 @@ type RemoveContext = { manualEntrypoint: string; }; -function metadataValue(src: string, key: string): string | null { - return src.match(new RegExp(`^--\\s*${key}:\\s*(.+)$`, "m"))?.[1]?.trim() ?? null; -} - function resolveContext(dir: string, opts: RemoveOptions): RemoveContext { const projectDir = resolve(dir); const configPath = join(projectDir, "feather.config.lua"); const configSrc = existsSync(configPath) ? readFileSync(configPath, "utf8") : ""; - const installDir = normalizeInstallDir(opts.installDir ?? metadataValue(configSrc, "installDir") ?? "feather"); - const manualEntrypoint = metadataValue(configSrc, "manualEntrypoint") ?? "feather.debugger.lua"; + const installDir = normalizeInstallDir(opts.installDir ?? parseManagedValue(configSrc, "installDir") ?? "feather"); + const manualEntrypoint = parseManagedValue(configSrc, "manualEntrypoint") ?? "feather.debugger.lua"; return { projectDir, diff --git a/cli/src/commands/update.ts b/cli/src/commands/update.ts index 459921d8..e9fc012b 100644 --- a/cli/src/commands/update.ts +++ b/cli/src/commands/update.ts @@ -1,26 +1,10 @@ import { existsSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { join, resolve } from "node:path"; import chalk from "chalk"; import ora from "ora"; import { fetchManifest, installCore, installCoreFromLocal, normalizeInstallDir } from "../lib/install.js"; import { chooseCoreUpdateWorkflow } from "../ui/update-workflow.js"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -function bundledLuaRoot(): string { - return resolve(__dirname, "../../lua"); -} - -function repoLuaRoot(): string | null { - const candidate = resolve(__dirname, "../../../src-lua"); - return existsSync(join(candidate, "feather", "init.lua")) ? candidate : null; -} - -function resolveLocalLuaRoot(opts: { localSrc?: string }): string { - if (opts.localSrc) return resolve(opts.localSrc); - return repoLuaRoot() ?? bundledLuaRoot(); -} +import { resolveLocalLuaRoot } from "../lib/paths.js"; export async function updateCommand( dir: string, diff --git a/cli/src/hooks/use-text-input.tsx b/cli/src/hooks/use-text-input.tsx new file mode 100644 index 00000000..d4cb9004 --- /dev/null +++ b/cli/src/hooks/use-text-input.tsx @@ -0,0 +1,56 @@ +import { useInput } from 'ink'; +import { useState } from 'react'; + +type InkKey = Parameters[0]>[1]; + +function useTextInput(initial: string) { + const [value, setValue] = useState(initial); + const [cursor, setCursor] = useState(initial.length); + + const reset = (next: string) => { + setValue(next); + setCursor(next.length); + }; + + const handleKey = (input: string, key: InkKey) => { + if (key.leftArrow) { + setCursor((c) => Math.max(0, c - 1)); + return true; + } + if (key.rightArrow) { + setCursor((c) => Math.min(value.length, c + 1)); + return true; + } + if (key.backspace) { + if (cursor === 0) return true; + const pos = cursor; + setValue((v) => v.slice(0, pos - 1) + v.slice(pos)); + setCursor((c) => c - 1); + return true; + } + if (key.delete) { + const pos = cursor; + setValue((v) => v.slice(0, pos) + v.slice(pos + 1)); + return true; + } + if (!key.ctrl && !key.meta && input) { + const pos = cursor; + setValue((v) => v.slice(0, pos) + input + v.slice(pos)); + setCursor((c) => c + 1); + return true; + } + return false; + }; + + return { + value, + cursor, + reset, + handleKey, + before: value.slice(0, cursor), + at: value[cursor] ?? '', + after: value.slice(cursor + 1), + }; +} + +export { useTextInput }; diff --git a/cli/src/lib/github.ts b/cli/src/lib/github.ts new file mode 100644 index 00000000..1546c259 --- /dev/null +++ b/cli/src/lib/github.ts @@ -0,0 +1,24 @@ +export const GH_HEADERS: Record = { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', +}; + +export async function fetchCommitSha(repo: string, ref: string): Promise { + const res = await fetch(`https://api.github.com/repos/${repo}/commits/${encodeURIComponent(ref)}`, { + headers: GH_HEADERS, + }); + if (!res.ok) throw new Error(`GitHub API ${res.status} resolving ${repo}@${ref} to commit SHA`); + return ((await res.json()) as { sha: string }).sha; +} + +export async function fetchLuaFiles(repo: string, ref: string): Promise { + const res = await fetch(`https://api.github.com/repos/${repo}/git/trees/${ref}?recursive=1`, { + headers: GH_HEADERS, + }); + if (!res.ok) throw new Error(`GitHub API ${res.status} fetching file tree for ${repo}@${ref}`); + const data = (await res.json()) as { tree: Array<{ path: string; type: string }> }; + return data.tree + .filter((n) => n.type === 'blob' && n.path.endsWith('.lua')) + .map((n) => n.path) + .sort(); +} diff --git a/cli/src/lib/paths.ts b/cli/src/lib/paths.ts new file mode 100644 index 00000000..fe0a8a19 --- /dev/null +++ b/cli/src/lib/paths.ts @@ -0,0 +1,25 @@ +import { existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export function bundledLuaRoot(): string { + return resolve(__dirname, '../../lua'); +} + +export function repoLuaRoot(): string | null { + const candidate = resolve(__dirname, '../../../src-lua'); + return existsSync(join(candidate, 'feather', 'init.lua')) ? candidate : null; +} + +export function resolveLocalLuaRoot(opts: { localSrc?: string }): string { + if (opts.localSrc) return resolve(opts.localSrc); + return repoLuaRoot() ?? bundledLuaRoot(); +} + +export function findProjectDir(cwd = process.cwd()): string { + if (existsSync(join(cwd, 'feather', 'init.lua'))) return cwd; + if (existsSync(join(cwd, 'main.lua'))) return cwd; + return cwd; +} diff --git a/cli/src/lib/plugin-utils.ts b/cli/src/lib/plugin-utils.ts new file mode 100644 index 00000000..1fda888f --- /dev/null +++ b/cli/src/lib/plugin-utils.ts @@ -0,0 +1,30 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +export function parseManagedValue(src: string, key: string): string | null { + return src.match(new RegExp(`^--\\s*${key}:\\s*(.+)$`, 'm'))?.[1]?.trim() ?? null; +} + +export function findInstalledPluginDirs(root: string): string[] { + if (!existsSync(root)) return []; + + const found: string[] = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const dir = join(root, entry.name); + if (existsSync(join(dir, 'manifest.lua'))) { + found.push(dir); + } else { + found.push(...findInstalledPluginDirs(dir)); + } + } + return found; +} + +export function readPluginManifest(dir: string): { id: string; name: string; version: string } | null { + const manifestPath = join(dir, 'manifest.lua'); + if (!existsSync(manifestPath)) return null; + const src = readFileSync(manifestPath, 'utf8'); + const get = (key: string) => src.match(new RegExp(`${key}\\s*=\\s*"([^"]+)"`))?.[1] ?? ''; + return { id: get('id'), name: get('name'), version: get('version') }; +} diff --git a/cli/src/lib/trust.ts b/cli/src/lib/trust.ts new file mode 100644 index 00000000..fc5d9a44 --- /dev/null +++ b/cli/src/lib/trust.ts @@ -0,0 +1,19 @@ +import chalk from 'chalk'; + +export function trustBadge(trust: string): string { + if (trust === 'verified') return chalk.green('[verified]'); + if (trust === 'known') return chalk.yellow('[known]'); + return chalk.red('[experimental]'); +} + +export function trustLabel(trust: string): string { + if (trust === 'verified') return 'verified'; + if (trust === 'known') return 'known'; + return 'experimental'; +} + +export function trustColor(trust: string): string { + if (trust === 'verified') return 'green'; + if (trust === 'known') return 'yellow'; + return 'red'; +} diff --git a/cli/src/lib/url.ts b/cli/src/lib/url.ts new file mode 100644 index 00000000..00ab5a6e --- /dev/null +++ b/cli/src/lib/url.ts @@ -0,0 +1,7 @@ +export function fileNameFromUrl(url: string): string { + try { + return new URL(url).pathname.split('/').filter(Boolean).pop() ?? 'file.lua'; + } catch { + return url.split('/').pop() ?? 'file.lua'; + } +} diff --git a/cli/src/ui/components.tsx b/cli/src/ui/components.tsx new file mode 100644 index 00000000..fbaf5741 --- /dev/null +++ b/cli/src/ui/components.tsx @@ -0,0 +1,493 @@ +import React, { useState, useEffect, type ReactNode } from 'react'; +import { Text, Box, useInput } from 'ink'; +import { createHash } from 'node:crypto'; +import { useTextInput } from '../hooks/use-text-input.js'; + +export interface UrlFile { + name: string; + url: string; + sha256: string; + target: string; + buffer: Buffer; +} + +export function CursorText({ before, at, after }: { before: string; at: string; after: string }) { + return ( + + {before} + {at || ' '} + {after} + + ); +} + +export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +export function Spinner({ label }: { label: string }) { + const [frame, setFrame] = useState(0); + useEffect(() => { + const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 80); + return () => clearInterval(id); + }, []); + return ( + + {SPINNER_FRAMES[frame]} + {label} + + ); +} + +export function Header({ step, total, title }: { step?: number; total?: number; title?: string }) { + return ( + + + {' '} + {title ?? 'feather package add'} + + {step !== undefined && total !== undefined && {` Step ${step} of ${total}`}} + + ); +} + +export function Hint({ children }: { children: string }) { + return ( + + {' '} + {children} + + ); +} + +export function TextInputStep({ + stepNum, + total, + label, + hint, + defaultValue = '', + validate, + onSubmit, + title, +}: { + stepNum?: number; + total?: number; + label: string; + hint?: string; + defaultValue?: string; + validate?: (v: string) => string | null; + onSubmit: (value: string) => void; + title?: string; +}) { + const inp = useTextInput(defaultValue); + const [error, setError] = useState(null); + + useInput((char, key) => { + if (key.return) { + const err = validate ? validate(inp.value.trim()) : null; + if (err) { + setError(err); + return; + } + onSubmit(inp.value.trim()); + return; + } + inp.handleKey(char, key); + setError(null); + }); + + return ( + +
+ + {' '} + {label} + + {hint && {hint}} + + {' '} + + + {error && ( + + + {' ✖ '} + {error} + + + )} + + {' ←→ move · Backspace/Delete edit · Enter confirm'} + + + ); +} + +export function SelectStep({ + stepNum, + total, + label, + hint, + options, + labels, + descriptions, + initialIndex = 0, + onSelect, + onCancel, + title, +}: { + stepNum?: number; + total?: number; + label: string; + hint?: string; + options: string[]; + labels?: string[]; + descriptions?: string[]; + initialIndex?: number; + onSelect: (value: string) => void; + onCancel?: () => void; + title?: string; +}) { + const [cursor, setCursor] = useState(initialIndex); + + useInput((input, key) => { + if (key.escape) { onCancel?.(); return; } + if (key.upArrow || input === 'k') setCursor((c) => Math.max(0, c - 1)); + else if (key.downArrow || input === 'j') setCursor((c) => Math.min(options.length - 1, c + 1)); + else { + const n = Number(input); + if (n >= 1 && n <= options.length) { setCursor(n - 1); return; } + } + if (key.return) onSelect(options[cursor]!); + }); + + return ( + +
+ + {' '} + {label} + + {hint && {hint}} + + {options.map((opt, i) => ( + + + {' '} + {i === cursor ? '❯ ' : ' '} + {labels?.[i] ?? opt} + + {descriptions?.[i] && {' '}{descriptions[i]}} + + ))} + + + {' ↑↓ or j/k navigate · Enter select'} + + + ); +} + +export function MultiSelectStep({ + stepNum, + total, + label, + hint, + options, + labels, + descriptions, + initialSelected, + onSubmit, + onCancel, + title, +}: { + stepNum?: number; + total?: number; + label: string; + hint?: string; + options: string[]; + labels?: string[]; + descriptions?: string[]; + initialSelected?: Set; + onSubmit: (selected: string[]) => void; + onCancel?: () => void; + title?: string; +}) { + const [cursor, setCursor] = useState(0); + const [selected, setSelected] = useState>(initialSelected ?? new Set(options.map((_, i) => i))); + + useInput((input, key) => { + if (key.escape) { onCancel?.(); return; } + if (key.upArrow || input === 'k') setCursor((c) => Math.max(0, c - 1)); + else if (key.downArrow || input === 'j') setCursor((c) => Math.min(options.length - 1, c + 1)); + else if (input === 'a') setSelected(new Set(options.map((_, i) => i))); + else if (input === ' ') { + setSelected((s) => { + const next = new Set(s); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + next.has(cursor) ? next.delete(cursor) : next.add(cursor); + return next; + }); + } + if (key.return) { + const chosen = options.filter((_, i) => selected.has(i)); + if (chosen.length > 0) onSubmit(chosen); + } + }); + + return ( + +
+ + {' '} + {label} + + {hint && {hint}} + + {options.map((opt, i) => ( + + + {' '} + {i === cursor ? '❯ ' : ' '} + {selected.has(i) ? '◉ ' : '○ '} + {labels?.[i] ?? opt} + + {descriptions?.[i] && {' '}{descriptions[i]}} + + ))} + + + {' ↑↓ or j/k navigate · Space toggle · a select all · Enter confirm'} + + + ); +} + +export function BooleanStep({ + stepNum, + total, + title, + label, + hint, + children, + defaultYes = true, + onConfirm, + onCancel, +}: { + stepNum?: number; + total?: number; + title?: string; + label: string; + hint?: string; + children?: ReactNode; + defaultYes?: boolean; + onConfirm: () => void; + onCancel: () => void; +}) { + const [yes, setYes] = useState(defaultYes); + + useInput((input, key) => { + if (key.escape) { onCancel(); return; } + if (input === 'y' || input === 'Y' || key.leftArrow) setYes(true); + else if (input === 'n' || input === 'N' || key.rightArrow) setYes(false); + else if (key.return) { + if (yes) onConfirm(); + else onCancel(); + } + }); + + return ( + +
+ {' '}{label} + {hint && {hint}} + {children} + + {' '} + Yes + / + No + + + {' y/← = yes · n/→ = no · Enter confirm'} + + + ); +} + +export function AutoStep({ + label, + run, + onError, +}: { + label: string; + run: () => Promise; + onError: (msg: string) => void; +}) { + const [status, setStatus] = useState<'running' | 'done' | 'error'>('running'); + const [error, setError] = useState(''); + + useEffect(() => { + run() + .then(() => setStatus('done')) + .catch((err: Error) => { + setError(err.message); + setStatus('error'); + onError(err.message); + }); + }, []); + + return ( + + {status === 'running' && } + {status === 'done' && ✔ {label}} + {status === 'error' && ✖ {error}} + + ); +} + +export function TargetsStep({ + stepNum, + total, + id, + files, + initialTargets, + onSubmit, + title, +}: { + stepNum?: number; + total?: number; + id: string; + files: string[]; + initialTargets?: Record; + onSubmit: (targets: Record) => void; + title?: string; +}) { + const defaultTarget = (f: string) => initialTargets?.[f] ?? (files.length === 1 ? `lib/${f}` : `lib/${id}/${f}`); + const [index, setIndex] = useState(0); + const [targets, setTargets] = useState>( + Object.fromEntries(files.map((f) => [f, defaultTarget(f)])), + ); + const inp = useTextInput(defaultTarget(files[0]!)); + const [error, setError] = useState(null); + const file = files[index]!; + + useInput((char, key) => { + if (key.return) { + const val = inp.value.trim(); + if (!val) { + setError('Required'); + return; + } + if (!val.endsWith('.lua')) { + setError('Must end in .lua'); + return; + } + const next = { ...targets, [file]: val }; + setTargets(next); + if (index + 1 < files.length) { + const nextFile = files[index + 1]!; + setIndex(index + 1); + inp.reset(next[nextFile] ?? defaultTarget(nextFile)); + setError(null); + } else { + onSubmit(next); + } + return; + } + inp.handleKey(char, key); + setError(null); + }); + + return ( + +
+ {' '}Install target paths + {`File ${index + 1} of ${files.length}: ${file}`} + + {' '} + + + {error && ( + + + {' ✖ '} + {error} + + + )} + + {' ←→ move · Backspace/Delete edit · Enter confirm'} + + + ); +} + +export function FileFetchStep({ + url, + onDone, + onError, +}: { + url: string; + onDone: (sha256: string, buffer: Buffer) => void; + onError: (msg: string) => void; +}) { + const [done, setDone] = useState(false); + const [sha, setSha] = useState(''); + + useEffect(() => { + const run = async () => { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`); + const buf = Buffer.from(await res.arrayBuffer()); + const hash = createHash('sha256').update(buf).digest('hex'); + setSha(hash); + setDone(true); + onDone(hash, buf); + }; + run().catch((err: Error) => onError(err.message)); + }, []); + + return ( + + {done ? ✔ sha256: {sha.slice(0, 16)}… : } + + ); +} + +export function FileMoreStep({ + urlFiles, + step = 2, + total, + onYes, + onNo, +}: { + urlFiles: UrlFile[]; + step?: number; + total?: number; + onYes: () => void; + onNo: () => void; +}) { + useInput((input, key) => { + if (input === 'y' || input === 'Y') onYes(); + else if (input === 'n' || input === 'N' || key.return || key.escape) onNo(); + }); + + return ( + +
+ {' '}Add another file? + + {urlFiles.map((f) => ( + + {' ✔ '} + {f.name} + {' '} + → {f.target} + + ))} + + + {' y = add another · n/Enter = done'} + + + ); +} diff --git a/cli/src/ui/init-mode.tsx b/cli/src/ui/init-mode.tsx index c0521240..8b62bc9c 100644 --- a/cli/src/ui/init-mode.tsx +++ b/cli/src/ui/init-mode.tsx @@ -168,7 +168,7 @@ function cursorLine(active: boolean, text: string, description?: string) { return ( - {active ? "›" : " "} {text} + {active ? "❯" : " "} {text} {description ? {description} : null} @@ -195,7 +195,7 @@ function TextInputPrompt({ {shown || placeholder || " "} - {error ? {error} : Enter to continue. Backspace edits.} + {error ? {error} : ←→ move · Backspace delete · Enter confirm} ); } @@ -217,7 +217,7 @@ function SingleSelect({ {cursorLine(index === selected, `${index + 1}. ${option.label}`, option.description)} ))} - Use ↑/↓, j/k, 1-{options.length}, then Enter. + ↑↓ or j/k navigate · 1-{options.length} jump · Enter select ); } @@ -227,7 +227,7 @@ function MultiSelect({ options, selected, cursor, - hint = "Space toggles, Enter continues.", + hint = "↑↓ or j/k navigate · Space toggle · a select all · Enter confirm", }: { title: string; options: Option[]; @@ -243,7 +243,7 @@ function MultiSelect({ {options.map((option, index) => ( - {index === cursor ? "›" : " "} {selected.has(option.value) ? "●" : "○"} {option.label} + {index === cursor ? "❯" : " "} {selected.has(option.value) ? "◉" : "○"} {option.label} {option.description ? {option.description} : null} @@ -263,7 +263,7 @@ function ConfirmPrompt({ title, value }: { title: string; value: boolean }) { / No - Use y/n, ←/→, then Enter. + y/← = yes · n/→ = no · Enter confirm ); } diff --git a/cli/src/ui/package-add-url.tsx b/cli/src/ui/package-add-url.tsx index 37a4ad30..816e8a4e 100644 --- a/cli/src/ui/package-add-url.tsx +++ b/cli/src/ui/package-add-url.tsx @@ -1,98 +1,28 @@ import { render, Text, Box, useInput, useApp } from 'ink'; import { useState, useEffect } from 'react'; -import { createHash } from 'node:crypto'; import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import type { Lockfile } from '../lib/package/lockfile.js'; import { addToLockfile, writeLockfile } from '../lib/package/lockfile.js'; - -// ── Local ink primitives (mirrors wizard-shared for consistent UX) ────────── - -type InkKey = Parameters[0]>[1]; - -function useTextInput(initial: string) { - const [value, setValue] = useState(initial); - const [cursor, setCursor] = useState(initial.length); - - const reset = (next: string) => { setValue(next); setCursor(next.length); }; - - const handleKey = (input: string, key: InkKey) => { - if (key.leftArrow) { setCursor((c) => Math.max(0, c - 1)); return true; } - if (key.rightArrow) { setCursor((c) => Math.min(value.length, c + 1)); return true; } - if (key.backspace) { - if (cursor === 0) return true; - const pos = cursor; - setValue((v) => v.slice(0, pos - 1) + v.slice(pos)); - setCursor((c) => c - 1); - return true; - } - if (key.delete) { - const pos = cursor; - setValue((v) => v.slice(0, pos) + v.slice(pos + 1)); - return true; - } - if (!key.ctrl && !key.meta && input) { - const pos = cursor; - setValue((v) => v.slice(0, pos) + input + v.slice(pos)); - setCursor((c) => c + 1); - return true; - } - return false; - }; - - return { value, cursor, reset, handleKey, before: value.slice(0, cursor), at: value[cursor] ?? '', after: value.slice(cursor + 1) }; -} - -function CursorText({ before, at, after }: { before: string; at: string; after: string }) { - return ( - - {before} - {at || ' '} - {after} - - ); -} - -const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - -function Spinner({ label }: { label: string }) { - const [frame, setFrame] = useState(0); - useEffect(() => { - const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 80); - return () => clearInterval(id); - }, []); - return ( - - {SPINNER_FRAMES[frame]} - {label} - - ); -} - -function Header({ step, total }: { step: number; total: number }) { - return ( - - {' '}feather package add - {` Step ${step} of ${total}`} - - ); -} - -function Hint({ children }: { children: string }) { - return {' '}{children}; -} - -// ── Wizard types ───────────────────────────────────────────────────────────── - -interface UrlFile { - name: string; - url: string; - sha256: string; - target: string; - buffer: Buffer; -} - -type Step = 'id' | 'file-url' | 'file-fetch' | 'file-target' | 'file-more' | 'require' | 'confirm' | 'write' | 'done' | 'error'; +import { + type UrlFile, + Header, + TextInputStep, + FileFetchStep, + FileMoreStep, +} from './components.js'; + +type Step = + | 'id' + | 'file-url' + | 'file-fetch' + | 'file-target' + | 'file-more' + | 'require' + | 'confirm' + | 'write' + | 'done' + | 'error'; const TOTAL = 5; @@ -105,111 +35,6 @@ function fileNameFromUrl(url: string): string { } } -// ── Step components ────────────────────────────────────────────────────────── - -function TextInputStep({ - stepNum, - label, - hint, - defaultValue = '', - validate, - onSubmit, -}: { - stepNum: number; - label: string; - hint?: string; - defaultValue?: string; - validate?: (v: string) => string | null; - onSubmit: (value: string) => void; -}) { - const input = useTextInput(defaultValue); - const [error, setError] = useState(null); - - useInput((char, key) => { - if (key.return) { - const err = validate ? validate(input.value.trim()) : null; - if (err) { setError(err); return; } - onSubmit(input.value.trim()); - return; - } - input.handleKey(char, key); - setError(null); - }); - - return ( - -
- {' '}{label} - {hint && {hint}} - {' '} - {error && {' ✖ '}{error}} - {' ←→ move · Backspace/Delete edit · Enter confirm'} - - ); -} - -function FileFetchStep({ - url, - onDone, - onError, -}: { - url: string; - onDone: (sha256: string, buffer: Buffer) => void; - onError: (msg: string) => void; -}) { - const [done, setDone] = useState(false); - const [sha, setSha] = useState(''); - - useEffect(() => { - const run = async () => { - const res = await fetch(url); - if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`); - const buf = Buffer.from(await res.arrayBuffer()); - const hash = createHash('sha256').update(buf).digest('hex'); - setSha(hash); - setDone(true); - onDone(hash, buf); - }; - run().catch((err: Error) => onError(err.message)); - }, []); - - return ( - - {done - ? ✔ sha256: {sha.slice(0, 16)}… - : } - - ); -} - -function FileMoreStep({ - urlFiles, - onYes, - onNo, -}: { - urlFiles: UrlFile[]; - onYes: () => void; - onNo: () => void; -}) { - useInput((input, key) => { - if (input === 'y' || input === 'Y') onYes(); - else if (input === 'n' || input === 'N' || key.return || key.escape) onNo(); - }); - - return ( - -
- {' '}Add another file? - - {urlFiles.map((f) => ( - {' ✔ '}{f.name}{' '}→ {f.target} - ))} - - {' y = add another · n/Enter = done'} - - ); -} - function ConfirmStep({ id, urlFiles, @@ -231,19 +56,31 @@ function ConfirmStep({
{' '}Review before installing - {' '}Package: {id} - {' '}Trust: experimental ⚠ + + {' '}Package: {id} + + + {' '}Trust: experimental ⚠ + {' '}These files have NOT been reviewed by the Feather team. {urlFiles.map((f) => ( - {' '}{f.name} → {f.target} - {' sha256: '}{f.sha256.slice(0, 24)}… + + {' '} + {f.name} → {f.target} + + + {' sha256: '} + {f.sha256.slice(0, 24)}… + ))} - {' y/Enter = install · n/Esc = abort'} + + {' y/Enter = install · n/Esc = abort'} + ); } @@ -251,7 +88,6 @@ function ConfirmStep({ function WriteStep({ id, urlFiles, - require: requirePath, projectDir, lockfile, onDone, @@ -259,7 +95,6 @@ function WriteStep({ }: { id: string; urlFiles: UrlFile[]; - require: string; projectDir: string; lockfile: Lockfile; onDone: () => void; @@ -294,7 +129,7 @@ function WriteStep({ return ( - {status === 'running' && } + {status === 'running' && Installing…} {status === 'done' && ✔ Done} {status === 'error' && ✖ {error}} @@ -318,25 +153,33 @@ function DoneStep({ return ( - ✔ Installed - {urlFiles.map((f) => {' '}{f.name} → {f.target})} + + ✔ Installed + + {urlFiles.map((f) => ( + + {' '} + {f.name} → {f.target} + + ))} - Usage: local {id.replace(/[.-]/g, '_')} = require('{requirePath}') + Usage:{' '} + + local {id.replace(/[.-]/g, '_')} = require('{requirePath}') + - Trust: experimental ⚠ — not reviewed by the Feather team + Trust: experimental ⚠ — not reviewed by the Feather team - Press Enter to exit + Press Enter to exit ); } -// ── Main wizard ────────────────────────────────────────────────────────────── - function Wizard({ projectDir, lockfile }: { projectDir: string; lockfile: Lockfile }) { const { exit } = useApp(); const [step, setStep] = useState('id'); @@ -348,12 +191,16 @@ function Wizard({ projectDir, lockfile }: { projectDir: string; lockfile: Lockfi const [currentBuffer, setCurrentBuffer] = useState(Buffer.alloc(0)); const [errorMsg, setErrorMsg] = useState(''); - const handleError = (msg: string) => { setErrorMsg(msg); setStep('error'); }; + const handleError = (msg: string) => { + setErrorMsg(msg); + setStep('error'); + }; if (step === 'id') { return ( { @@ -362,7 +209,10 @@ function Wizard({ projectDir, lockfile }: { projectDir: string; lockfile: Lockfi if (lockfile.packages[v]) return `"${v}" is already installed`; return null; }} - onSubmit={(v) => { setId(v); setStep('file-url'); }} + onSubmit={(v) => { + setId(v); + setStep('file-url'); + }} /> ); } @@ -372,14 +222,22 @@ function Wizard({ projectDir, lockfile }: { projectDir: string; lockfile: Lockfi return ( { if (!v) return 'Required'; - try { new URL(v); } catch { return 'Must be a valid URL'; } + try { + new URL(v); + } catch { + return 'Must be a valid URL'; + } return null; }} - onSubmit={(url) => { setCurrentUrl(url); setStep('file-fetch'); }} + onSubmit={(url) => { + setCurrentUrl(url); + setStep('file-fetch'); + }} /> ); } @@ -388,7 +246,11 @@ function Wizard({ projectDir, lockfile }: { projectDir: string; lockfile: Lockfi return ( { setCurrentSha(sha256); setCurrentBuffer(buffer); setStep('file-target'); }} + onDone={(sha256, buffer) => { + setCurrentSha(sha256); + setCurrentBuffer(buffer); + setStep('file-target'); + }} onError={handleError} /> ); @@ -400,6 +262,7 @@ function Wizard({ projectDir, lockfile }: { projectDir: string; lockfile: Lockfi return ( setStep('file-url')} onNo={() => setStep('require')} /> @@ -432,11 +297,15 @@ function Wizard({ projectDir, lockfile }: { projectDir: string; lockfile: Lockfi return ( (v ? null : 'Required')} - onSubmit={(v) => { setRequirePath(v); setStep('confirm'); }} + onSubmit={(v) => { + setRequirePath(v); + setStep('confirm'); + }} /> ); } @@ -447,7 +316,10 @@ function Wizard({ projectDir, lockfile }: { projectDir: string; lockfile: Lockfi id={id} urlFiles={urlFiles} onConfirm={() => setStep('write')} - onAbort={() => { setErrorMsg('Aborted.'); setStep('error'); }} + onAbort={() => { + setErrorMsg('Aborted.'); + setStep('error'); + }} /> ); } @@ -457,7 +329,6 @@ function Wizard({ projectDir, lockfile }: { projectDir: string; lockfile: Lockfi setStep('done')} @@ -472,21 +343,17 @@ function Wizard({ projectDir, lockfile }: { projectDir: string; lockfile: Lockfi return ( - ✖ Error + + ✖ Error + {errorMsg} ); } -// ── Public API ─────────────────────────────────────────────────────────────── - -export async function showAddFromUrlWizard(opts: { - projectDir: string; - lockfile: Lockfile; -}): Promise { - const { waitUntilExit } = render( - , - { alternateScreen: true }, - ); +export async function showAddFromUrlWizard(opts: { projectDir: string; lockfile: Lockfile }): Promise { + const { waitUntilExit } = render(, { + alternateScreen: true, + }); await waitUntilExit(); } diff --git a/cli/src/ui/package-add.tsx b/cli/src/ui/package-add.tsx new file mode 100644 index 00000000..a66237d6 --- /dev/null +++ b/cli/src/ui/package-add.tsx @@ -0,0 +1,681 @@ +import { render, Text, Box, useInput, useApp } from 'ink'; +import { useState, useEffect } from 'react'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { sha256Buffer } from '../lib/package/checksum.js'; +import type { Lockfile } from '../lib/package/lockfile.js'; +import { addToLockfile, writeLockfile } from '../lib/package/lockfile.js'; +import { + type UrlFile, + TextInputStep, + SelectStep, + MultiSelectStep, + AutoStep, + TargetsStep, + FileFetchStep, + FileMoreStep, +} from './components.js'; +import { GH_HEADERS, fetchCommitSha, fetchLuaFiles } from '../lib/github.js'; +import { fileNameFromUrl } from '../lib/url.js'; + +type Step = + | 'choose' + | 'id' + | 'repo' + | 'fetch-tags' + | 'tag' + | 'resolve-commit' + | 'fetch-files' + | 'files' + | 'targets' + | 'file-url' + | 'file-fetch' + | 'file-target' + | 'file-more' + | 'require' + | 'confirm' + | 'install' + | 'write' + | 'done' + | 'error'; + +const REPO_TOTAL = 7; +const URL_TOTAL = 4; + +async function fetchRepoMeta(repo: string): Promise<{ values: string[]; labels: string[] }> { + const [tagsRes, repoRes, branchesRes] = await Promise.all([ + fetch(`https://api.github.com/repos/${repo}/tags?per_page=20`, { headers: GH_HEADERS }), + fetch(`https://api.github.com/repos/${repo}`, { headers: GH_HEADERS }), + fetch(`https://api.github.com/repos/${repo}/branches?per_page=30`, { headers: GH_HEADERS }), + ]); + if (!tagsRes.ok) throw new Error(`GitHub API ${tagsRes.status} fetching tags for ${repo}`); + if (!repoRes.ok) throw new Error(`GitHub API ${repoRes.status} fetching repo info for ${repo}`); + if (!branchesRes.ok) throw new Error(`GitHub API ${branchesRes.status} fetching branches for ${repo}`); + const [tagsData, repoData, branchesData] = await Promise.all([ + tagsRes.json() as Promise>, + repoRes.json() as Promise<{ default_branch?: string }>, + branchesRes.json() as Promise>, + ]); + const tags = tagsData.map((t) => t.name); + const defaultBranch = repoData.default_branch ?? 'main'; + const branches = branchesData.map((b) => b.name); + const orderedBranches = [defaultBranch, ...branches.filter((b) => b !== defaultBranch)]; + const values = [...tags, ...orderedBranches]; + const labels = [...tags, ...orderedBranches.map((b) => `⎇ ${b}`)]; + return { values, labels }; +} + + +function RepoConfirmStep({ + id, + repoName, + tag, + selectedFiles, + targetMap, + onConfirm, + onAbort, +}: { + id: string; + repoName: string; + tag: string; + selectedFiles: string[]; + targetMap: Record; + onConfirm: () => void; + onAbort: () => void; +}) { + useInput((input, key) => { + if (input === 'y' || input === 'Y' || key.return) onConfirm(); + else if (input === 'n' || input === 'N' || key.escape) onAbort(); + }); + return ( + + + + {' '}feather package add + + {` Step ${REPO_TOTAL} of ${REPO_TOTAL}`} + + {' '}Review before installing + + + {' '}Package: {id} + + + {' '}Source: github.com/{repoName} + + + {' '}Version: {tag} (commit SHA pinned) + + + {' '}Trust: experimental ⚠ + + {' '}Not reviewed by the Feather team. + + + {selectedFiles.map((f) => ( + + {' '} + {f} → {targetMap[f]} + + ))} + + + {' y/Enter = install · n/Esc = abort'} + + + ); +} + +function UrlConfirmStep({ + id, + urlFiles, + onConfirm, + onAbort, +}: { + id: string; + urlFiles: UrlFile[]; + onConfirm: () => void; + onAbort: () => void; +}) { + useInput((input, key) => { + if (input === 'y' || input === 'Y' || key.return) onConfirm(); + else if (input === 'n' || input === 'N' || key.escape) onAbort(); + }); + return ( + + + + {' '}feather package add + + {` Step ${URL_TOTAL} of ${URL_TOTAL}`} + + {' '}Review before installing + + + {' '}Package: {id} + + + {' '}Trust: experimental ⚠ + + {' '}Not reviewed by the Feather team. + + + {urlFiles.map((f) => ( + + + {' '} + {f.name} → {f.target} + + + {' sha256: '} + {f.sha256.slice(0, 24)}… + + + ))} + + + {' y/Enter = install · n/Esc = abort'} + + + ); +} + +function InstallStep({ + id, + repoName, + tag, + baseUrl, + selectedFiles, + targetMap, + projectDir, + lockfile, + onDone, + onError, +}: { + id: string; + repoName: string; + tag: string; + baseUrl: string; + selectedFiles: string[]; + targetMap: Record; + projectDir: string; + lockfile: Lockfile; + onDone: () => void; + onError: (msg: string) => void; +}) { + const [current, setCurrent] = useState(''); + useEffect(() => { + const run = async () => { + const lockedFiles: { name: string; url: string; target: string; sha256: string }[] = []; + for (const name of selectedFiles) { + setCurrent(name); + const url = baseUrl + name; + const res = await fetch(url, { signal: AbortSignal.timeout(30_000) }); + if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`); + const buf = Buffer.from(await res.arrayBuffer()); + const hash = sha256Buffer(buf); + const target = targetMap[name]!; + mkdirSync(dirname(join(projectDir, target)), { recursive: true }); + writeFileSync(join(projectDir, target), buf); + lockedFiles.push({ name, url, target, sha256: hash }); + } + addToLockfile(lockfile, id, { + version: tag, + trust: 'experimental', + source: { repo: repoName, tag }, + files: lockedFiles, + }); + writeLockfile(projectDir, lockfile); + onDone(); + }; + run().catch((err: Error) => onError(err.message)); + }, []); + return ( + + {current ? `Installing ${current}…` : 'Installing…'} + + ); +} + +function WriteStep({ + id, + urlFiles, + projectDir, + lockfile, + onDone, + onError, +}: { + id: string; + urlFiles: UrlFile[]; + projectDir: string; + lockfile: Lockfile; + onDone: () => void; + onError: (msg: string) => void; +}) { + useEffect(() => { + const run = async () => { + for (const f of urlFiles) { + const abs = join(projectDir, f.target); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, f.buffer); + } + addToLockfile(lockfile, id, { + version: 'url', + trust: 'experimental', + source: { url: urlFiles[0]!.url }, + files: urlFiles.map((f) => ({ name: f.name, url: f.url, target: f.target, sha256: f.sha256 })), + }); + writeLockfile(projectDir, lockfile); + onDone(); + }; + run().catch((err: Error) => onError(err.message)); + }, []); + return ( + + Installing… + + ); +} + +function DoneStep({ + id, + files, + requirePath, + onExit, +}: { + id: string; + files: Array<{ name: string; target: string }>; + requirePath: string; + onExit: () => void; +}) { + useInput((_, key) => { + if (key.return || key.escape) onExit(); + }); + return ( + + + ✔ Installed + + {files.map((f) => ( + + {' '} + {f.name} → {f.target} + + ))} + + + Usage:{' '} + + local {id.replace(/[.-]/g, '_')} = require('{requirePath}') + + + + + Trust: experimental ⚠ — not reviewed by the Feather team + + + Press Enter to exit + + + ); +} + +function Wizard({ projectDir, lockfile }: { projectDir: string; lockfile: Lockfile }) { + const { exit } = useApp(); + const [step, setStep] = useState('choose'); + const [mode, setMode] = useState<'repo' | 'url' | null>(null); + + // Shared + const [id, setId] = useState(''); + const [requirePath, setRequirePath] = useState(''); + + // Repo flow + const [repoName, setRepoName] = useState(''); + const [tag, setTag] = useState(''); + const [baseUrl, setBaseUrl] = useState(''); + const [tagOptions, setTagOptions] = useState([]); + const [tagLabels, setTagLabels] = useState([]); + const [luaFiles, setLuaFiles] = useState([]); + const [selectedFiles, setSelectedFiles] = useState([]); + const [targetMap, setTargetMap] = useState>({}); + + // URL flow + const [urlFiles, setUrlFiles] = useState([]); + const [currentUrl, setCurrentUrl] = useState(''); + const [currentSha, setCurrentSha] = useState(''); + const [currentBuffer, setCurrentBuffer] = useState(Buffer.alloc(0)); + + const [errorMsg, setErrorMsg] = useState(''); + + const handleError = (msg: string) => { + setErrorMsg(msg); + setStep('error'); + }; + + const doneFiles = + mode === 'repo' + ? selectedFiles.map((f) => ({ name: f, target: targetMap[f] ?? f })) + : urlFiles.map((f) => ({ name: f.name, target: f.target })); + + if (step === 'choose') { + return ( + { + setMode(v as 'repo' | 'url'); + setStep('id'); + }} + /> + ); + } + + if (step === 'id') { + return ( + { + if (!v) return 'Required'; + if (!/^[a-z0-9][a-z0-9.-]*$/.test(v)) return 'Use only a-z, 0-9, dots, hyphens'; + if (lockfile.packages[v]) return `"${v}" is already installed`; + return null; + }} + onSubmit={(v) => { + setId(v); + setStep(mode === 'repo' ? 'repo' : 'file-url'); + }} + /> + ); + } + + if (step === 'repo') { + return ( + { + if (!v) return 'Required'; + if (!/^[^/]+\/[^/]+$/.test(v)) return 'Must be owner/repo format'; + return null; + }} + onSubmit={(v) => { + setRepoName(v); + setStep('fetch-tags'); + }} + /> + ); + } + + if (step === 'fetch-tags') { + return ( + { + const { values, labels } = await fetchRepoMeta(repoName); + if (values.length === 0) throw new Error('No tags or branches found.'); + setTagOptions(values); + setTagLabels(labels); + setStep('tag'); + }} + onError={handleError} + /> + ); + } + + if (step === 'tag') { + return ( + { + setTag(v); + setStep('resolve-commit'); + }} + /> + ); + } + + if (step === 'resolve-commit') { + return ( + { + const sha = await fetchCommitSha(repoName, tag); + setBaseUrl(`https://raw.githubusercontent.com/${repoName}/${sha}/`); + setStep('fetch-files'); + }} + onError={handleError} + /> + ); + } + + if (step === 'fetch-files') { + return ( + { + const files = await fetchLuaFiles(repoName, tag); + if (files.length === 0) throw new Error('No .lua files found at this ref.'); + setLuaFiles(files); + setStep('files'); + }} + onError={handleError} + /> + ); + } + + if (step === 'files') { + return ( + { + setSelectedFiles(chosen); + setStep('targets'); + }} + /> + ); + } + + if (step === 'targets') { + return ( + { + setTargetMap(map); + setStep('require'); + }} + /> + ); + } + + if (step === 'require') { + const firstTarget = + mode === 'repo' ? (Object.values(targetMap)[0] ?? `lib/${id}.lua`) : (urlFiles[0]?.target ?? `lib/${id}.lua`); + const suggested = firstTarget.replace(/\.lua$/, '').replace(/\//g, '.'); + return ( + (v ? null : 'Required')} + onSubmit={(v) => { + setRequirePath(v); + setStep('confirm'); + }} + /> + ); + } + + if (step === 'confirm') { + if (mode === 'repo') { + return ( + setStep('install')} + onAbort={() => { + setErrorMsg('Aborted.'); + setStep('error'); + }} + /> + ); + } + return ( + setStep('write')} + onAbort={() => { + setErrorMsg('Aborted.'); + setStep('error'); + }} + /> + ); + } + + if (step === 'install') { + return ( + setStep('done')} + onError={handleError} + /> + ); + } + + if (step === 'file-url') { + const n = urlFiles.length; + return ( + { + if (!v) return 'Required'; + try { + new URL(v); + } catch { + return 'Must be a valid URL'; + } + return null; + }} + onSubmit={(url) => { + setCurrentUrl(url); + setStep('file-fetch'); + }} + /> + ); + } + + if (step === 'file-fetch') { + return ( + { + setCurrentSha(sha256); + setCurrentBuffer(buffer); + setStep('file-target'); + }} + onError={handleError} + /> + ); + } + + if (step === 'file-target') { + const name = fileNameFromUrl(currentUrl); + const suggested = urlFiles.length === 0 ? `lib/${name}` : `lib/${id}/${name}`; + return ( + { + if (!v) return 'Required'; + if (!v.endsWith('.lua')) return 'Must end in .lua'; + return null; + }} + onSubmit={(target) => { + setUrlFiles((fs) => [...fs, { name, url: currentUrl, sha256: currentSha, target, buffer: currentBuffer }]); + setStep('file-more'); + }} + /> + ); + } + + if (step === 'file-more') { + return ( + setStep('file-url')} + onNo={() => setStep('require')} + /> + ); + } + + if (step === 'write') { + return ( + setStep('done')} + onError={handleError} + /> + ); + } + + if (step === 'done') { + return ; + } + + return ( + + + ✖ Error + + {errorMsg} + + ); +} + +export async function showAddWizard(opts: { projectDir: string; lockfile: Lockfile }): Promise { + const { waitUntilExit } = render(, { + alternateScreen: true, + }); + await waitUntilExit(); +} diff --git a/cli/src/ui/package-progress.tsx b/cli/src/ui/package-progress.tsx index 59aa42b9..bdb6d36e 100644 --- a/cli/src/ui/package-progress.tsx +++ b/cli/src/ui/package-progress.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from "react"; import { Box, Text, render, useApp } from "ink"; +import { SPINNER_FRAMES } from "./components.js"; import { installPackage } from "../lib/package/install.js"; import { formatRequireHint } from "../lib/package/resolve.js"; import type { ResolvedPackage } from "../lib/package/resolve.js"; @@ -26,15 +27,13 @@ type PkgState = { error?: string; }; -const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - function useSpinner() { const [frame, setFrame] = useState(0); useEffect(() => { - const t = setInterval(() => setFrame((f) => (f + 1) % SPINNER.length), 80); + const t = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 80); return () => clearInterval(t); }, []); - return SPINNER[frame]; + return SPINNER_FRAMES[frame]; } function FileRow({ file, spinner }: { file: FileState; spinner: string }) { diff --git a/cli/src/ui/package-workflow.tsx b/cli/src/ui/package-workflow.tsx index ac3c74ba..4870c061 100644 --- a/cli/src/ui/package-workflow.tsx +++ b/cli/src/ui/package-workflow.tsx @@ -2,6 +2,7 @@ import { useState, useMemo, useEffect } from 'react'; import { Box, Text, render, useApp, useInput } from 'ink'; import type { Registry, RegistryEntry } from '../lib/package/registry.js'; import type { Lockfile, LockfileEntry } from '../lib/package/lockfile.js'; +import { trustLabel, trustColor } from '../lib/trust.js'; export type PackageWorkflowResult = { action: 'install' | 'update' | 'remove'; id: string } | { action: 'cancel' }; @@ -18,17 +19,6 @@ const VISIBLE = 14; const VER_W = 10; const TRUST_W = 13; -function trustLabel(trust: string) { - if (trust === 'verified') return 'verified'; - if (trust === 'known') return 'known'; - return 'experimental'; -} - -function trustColor(trust: string): string { - if (trust === 'verified') return 'green'; - if (trust === 'known') return 'yellow'; - return 'red'; -} function PackageWorkflow({ registry, diff --git a/cli/src/ui/plugin-workflow.tsx b/cli/src/ui/plugin-workflow.tsx index 87101808..0b3ee1e2 100644 --- a/cli/src/ui/plugin-workflow.tsx +++ b/cli/src/ui/plugin-workflow.tsx @@ -1,6 +1,7 @@ import React, { useMemo, useState } from "react"; -import { Box, Text, render, useApp, useInput } from "ink"; +import { Text, render, useApp } from "ink"; import { pluginCatalog } from "../generated/plugin-catalog.js"; +import { SelectStep, MultiSelectStep, TextInputStep, BooleanStep } from "./components.js"; type PluginAction = "list" | "install" | "remove" | "update" | "cancel"; type PluginSource = "local" | "remote"; @@ -49,100 +50,6 @@ function pluginOption(id: string): Option { }; } -function SingleSelect({ - title, - options, - cursor, -}: { - title: string; - options: Option[]; - cursor: number; -}) { - return ( - - {title} - - {options.map((option, index) => ( - - - {index === cursor ? "›" : " "} {index + 1}. {option.label} - - {option.description ? {option.description} : null} - - ))} - - Use ↑/↓, j/k, 1-{options.length}, then Enter. - - ); -} - -function MultiSelect({ - title, - options, - selected, - cursor, -}: { - title: string; - options: Option[]; - selected: Set; - cursor: number; -}) { - return ( - - {title} - - {options.length === 0 ? Nothing to choose here. : null} - {options.map((option, index) => ( - - - {index === cursor ? "›" : " "} {selected.has(option.value) ? "●" : "○"} {option.label} - - {option.description ? {option.description} : null} - - ))} - - Space toggles, Enter continues. - - ); -} - -function TextInput({ title, value, placeholder }: { title: string; value: string; placeholder?: string }) { - return ( - - {title} - {value || placeholder || " "} - Enter to continue. Backspace edits. - - ); -} - -function Confirm({ - title, - rows, - yes, -}: { - title: string; - rows: string[]; - yes: boolean; -}) { - return ( - - {title} - - {rows.map((row) => ( - {row} - ))} - - - Yes - / - No - - Use y/n, ←/→, then Enter. - - ); -} - function PluginWorkflow({ installedIds, defaultBranch, @@ -151,19 +58,15 @@ function PluginWorkflow({ onComplete, }: PluginWorkflowInput & { onComplete: (result: PluginWorkflowResult) => void }) { const { exit } = useApp(); - const initialActionIndex = initialAction ? Math.max(0, actions.findIndex((option) => option.value === initialAction)) : 0; - const [phase, setPhase] = useState(initialAction === "install" || initialAction === "update" ? "source" : "action"); - const [actionCursor, setActionCursor] = useState(initialActionIndex); - const [sourceCursor, setSourceCursor] = useState(0); - const [pluginCursor, setPluginCursor] = useState(0); + const initialPhase: Phase = initialAction === "install" || initialAction === "update" ? "source" : "action"; + const [phase, setPhase] = useState(initialPhase); + const [action, setAction] = useState(initialAction ?? "install"); + const [source, setSource] = useState("local"); const [branch, setBranch] = useState(defaultBranch); - const [selected, setSelected] = useState>( - new Set(defaultSelectAll && initialAction === "update" ? installedIds : []), + const [selectedIds, setSelectedIds] = useState( + defaultSelectAll && initialAction === "update" ? installedIds : [], ); - const [confirmed, setConfirmed] = useState(true); - const action = actions[actionCursor].value; - const source = sources[sourceCursor].value; const installed = useMemo(() => new Set(installedIds), [installedIds]); const pluginOptions = useMemo(() => { @@ -181,112 +84,112 @@ function PluginWorkflow({ exit(); }; - const move = (delta: number, count: number, setter: (value: number | ((value: number) => number)) => void) => { - if (count <= 0) return; - setter((value: number) => (value + count + delta) % count); - }; - - const completeSelection = () => { - if (action === "remove") { - setPhase("confirm"); - return; - } - if (action === "install" || action === "update") { - if (source === "remote") { - setPhase("branch"); - } else { - setPhase("confirm"); - } - return; - } - finish({ action: "cancel" }); - }; - - useInput((input, key) => { - if (key.escape) { - finish({ action: "cancel" }); - return; - } - - if (phase === "action") { - if (key.upArrow || input === "k") move(-1, actions.length, setActionCursor); - else if (key.downArrow || input === "j") move(1, actions.length, setActionCursor); - else if (Number(input) >= 1 && Number(input) <= actions.length) setActionCursor(Number(input) - 1); - else if (key.return) { - const next = actions[actionCursor].value; - setSelected(new Set()); - setPluginCursor(0); - if (next === "list" || next === "cancel") finish({ action: next }); - else if (next === "install" || next === "update") setPhase("source"); - else setPhase("plugins"); - } - return; - } + const cancel = () => finish({ action: "cancel" }); - if (phase === "source") { - if (key.upArrow || input === "k") move(-1, sources.length, setSourceCursor); - else if (key.downArrow || input === "j") move(1, sources.length, setSourceCursor); - else if (Number(input) >= 1 && Number(input) <= sources.length) setSourceCursor(Number(input) - 1); - else if (key.return) setPhase("plugins"); - return; - } - - if (phase === "plugins") { - if (key.upArrow || input === "k") move(-1, pluginOptions.length, setPluginCursor); - else if (key.downArrow || input === "j") move(1, pluginOptions.length, setPluginCursor); - else if (input === "a") setSelected(new Set(pluginOptions.map((option) => option.value))); - else if (input === " ") { - const option = pluginOptions[pluginCursor]; - if (!option) return; - setSelected((current) => { - const next = new Set(current); - if (next.has(option.value)) next.delete(option.value); - else next.add(option.value); - return next; - }); - } else if (key.return) completeSelection(); - return; - } + if (phase === "action") { + return ( + a.value)} + labels={actions.map((a) => a.label)} + descriptions={actions.map((a) => a.description ?? "")} + initialIndex={initialAction ? Math.max(0, actions.findIndex((a) => a.value === initialAction)) : 0} + onSelect={(value) => { + const act = value as PluginAction; + setAction(act); + if (act === "list" || act === "cancel") { + finish({ action: act }); + } else if (act === "install" || act === "update") { + setPhase("source"); + } else { + setPhase("plugins"); + } + }} + onCancel={cancel} + /> + ); + } - if (phase === "branch") { - if (key.return) setPhase("confirm"); - else if (key.backspace || key.delete) setBranch((value) => value.slice(0, -1)); - else if (input && !key.ctrl && !key.meta) setBranch((value) => value + input); - return; - } + if (phase === "source") { + return ( + s.value)} + labels={sources.map((s) => s.label)} + descriptions={sources.map((s) => s.description ?? "")} + onSelect={(value) => { + setSource(value as PluginSource); + setPhase("plugins"); + }} + onCancel={cancel} + /> + ); + } - if (phase === "confirm") { - if (input === "y" || key.leftArrow) setConfirmed(true); - else if (input === "n" || key.rightArrow) setConfirmed(false); - else if (key.return) { - if (!confirmed) { - finish({ action: "cancel" }); - return; - } - const pluginIds = [...selected]; - if (action === "remove") finish({ action, pluginIds }); - else if (action === "install" || action === "update") finish({ action, pluginIds, source, branch: branch.trim() || "main" }); - else finish({ action: "cancel" }); - } - } - }); + if (phase === "branch") { + return ( + { + setBranch(value || "main"); + setPhase("confirm"); + }} + /> + ); + } - if (phase === "action") return ; - if (phase === "source") return ; - if (phase === "branch") return ; if (phase === "plugins") { + const allSelected = defaultSelectAll && action === "update"; + const pluginTitle = + action === "install" ? "Choose plugins to install" + : action === "update" ? "Choose plugins to update" + : "Choose plugins to remove"; return ( - p.value)} + labels={pluginOptions.map((p) => p.label)} + descriptions={pluginOptions.map((p) => p.description ?? "")} + initialSelected={allSelected ? undefined : new Set()} + onSubmit={(chosen) => { + setSelectedIds(chosen); + if (action === "remove") { + setPhase("confirm"); + } else if (source === "remote") { + setPhase("branch"); + } else { + setPhase("confirm"); + } + }} + onCancel={cancel} /> ); } - const rows = selected.size > 0 ? [...selected].map((id) => `- ${id}`) : ["No plugins selected."]; - return ; + const rows = selectedIds.length > 0 ? selectedIds : ["No plugins selected."]; + return ( + { + if (action === "remove") { + finish({ action, pluginIds: selectedIds }); + } else if (action === "install" || action === "update") { + finish({ action, pluginIds: selectedIds, source, branch: branch.trim() || "main" }); + } else { + finish({ action: "cancel" }); + } + }} + onCancel={cancel} + > + {rows.map((row) => ( + + {" - "} + {row} + + ))} + + ); } export async function choosePluginWorkflow(input: PluginWorkflowInput): Promise { diff --git a/cli/src/ui/remove-workflow.tsx b/cli/src/ui/remove-workflow.tsx index 01d28567..13db94cc 100644 --- a/cli/src/ui/remove-workflow.tsx +++ b/cli/src/ui/remove-workflow.tsx @@ -1,5 +1,6 @@ import React, { useMemo, useState } from "react"; import { Box, Text, render, useApp, useInput } from "ink"; +import { BooleanStep } from "./components.js"; export type RemoveTarget = { id: string; @@ -23,7 +24,6 @@ function RemoveWorkflow({ const { exit } = useApp(); const [cursor, setCursor] = useState(0); const [confirm, setConfirm] = useState(false); - const [yes, setYes] = useState(true); const [selected, setSelected] = useState>( new Set(targets.filter((target) => target.defaultSelected).map((target) => target.id)), ); @@ -41,20 +41,13 @@ function RemoveWorkflow({ }; useInput((input, key) => { + if (confirm) return; + if (key.escape) { finish({ cancelled: true }); return; } - if (confirm) { - if (input === "y" || key.leftArrow) setYes(true); - else if (input === "n" || key.rightArrow) setYes(false); - else if (key.return) { - finish(yes ? { cancelled: false, targetIds: [...selected] } : { cancelled: true }); - } - return; - } - if (key.upArrow || input === "k") move(-1); else if (key.downArrow || input === "j") move(1); else if (input === "a") setSelected(new Set(rows.map((target) => target.id))); @@ -73,22 +66,17 @@ function RemoveWorkflow({ }); if (confirm) { + const selectedTargets = [...selected].map((id) => rows.find((item) => item.id === id)).filter(Boolean); return ( - - Remove selected Feather files and markers? - - {[...selected].map((id) => { - const target = rows.find((item) => item.id === id); - return target ? - {target.label}: {target.path} : null; - })} - - - Yes - / - No - - Use y/n, ←/→, then Enter. - + finish({ cancelled: false, targetIds: [...selected] })} + onCancel={() => finish({ cancelled: true })} + > + {selectedTargets.map((target) => ( + {" - "}{target!.label}: {target!.path} + ))} + ); } @@ -100,13 +88,13 @@ function RemoveWorkflow({ {rows.map((target, index) => ( - {index === cursor ? "›" : " "} {selected.has(target.id) ? "●" : "○"} {target.label}: {target.path} + {index === cursor ? "❯" : " "} {selected.has(target.id) ? "◉" : "○"} {target.label}: {target.path} {target.description} ))} - Space toggles, a selects all, Enter continues, Esc cancels. + {"↑↓ or j/k navigate · Space toggle · a select all · Enter confirm · Esc cancel"} ); } diff --git a/cli/src/ui/update-workflow.tsx b/cli/src/ui/update-workflow.tsx index ef94fa29..e869a488 100644 --- a/cli/src/ui/update-workflow.tsx +++ b/cli/src/ui/update-workflow.tsx @@ -1,5 +1,6 @@ import React, { useState } from "react"; -import { Box, Text, render, useApp, useInput } from "ink"; +import { render, useApp } from "ink"; +import { SelectStep, TextInputStep, BooleanStep } from "./components.js"; export type UpdateSource = "local" | "remote"; @@ -7,20 +8,14 @@ export type CoreUpdateWorkflowResult = | { cancelled: true } | { cancelled: false; source: UpdateSource; branch: string }; -type SourceOption = { - value: UpdateSource; - label: string; - description: string; -}; - -const sources: SourceOption[] = [ +const sources = [ { - value: "local", + value: "local" as UpdateSource, label: "Bundled/local copy", description: "Use the CLI-bundled Lua runtime, or --local-src when provided.", }, { - value: "remote", + value: "remote" as UpdateSource, label: "GitHub download", description: "Download core files from GitHub using a branch or tag.", }, @@ -35,90 +30,52 @@ function CoreUpdateWorkflow({ }) { const { exit } = useApp(); const [phase, setPhase] = useState<"source" | "branch" | "confirm">("source"); - const [sourceCursor, setSourceCursor] = useState(0); + const [source, setSource] = useState("local"); const [branch, setBranch] = useState(defaultBranch); - const [confirmed, setConfirmed] = useState(true); - - const source = sources[sourceCursor].value; const finish = (result: CoreUpdateWorkflowResult) => { onComplete(result); exit(); }; - const move = (delta: number) => { - setSourceCursor((value) => (value + sources.length + delta) % sources.length); - }; - - useInput((input, key) => { - if (key.escape) { - finish({ cancelled: true }); - return; - } - - if (phase === "source") { - if (key.upArrow || input === "k") move(-1); - else if (key.downArrow || input === "j") move(1); - else if (Number(input) >= 1 && Number(input) <= sources.length) setSourceCursor(Number(input) - 1); - else if (key.return) setPhase(source === "remote" ? "branch" : "confirm"); - return; - } - - if (phase === "branch") { - if (key.return) setPhase("confirm"); - else if (key.backspace || key.delete) setBranch((value) => value.slice(0, -1)); - else if (input && !key.ctrl && !key.meta) setBranch((value) => value + input); - return; - } - - if (input === "y" || key.leftArrow) setConfirmed(true); - else if (input === "n" || key.rightArrow) setConfirmed(false); - else if (key.return) { - if (!confirmed) finish({ cancelled: true }); - else finish({ cancelled: false, source, branch: branch.trim() || "main" }); - } - }); + const cancel = () => finish({ cancelled: true }); if (phase === "source") { return ( - - Update Feather core from - - {sources.map((option, index) => ( - - - {index === sourceCursor ? "›" : " "} {index + 1}. {option.label} - - {option.description} - - ))} - - Use ↑/↓, j/k, 1-{sources.length}, then Enter. Esc cancels. - + s.value)} + labels={sources.map((s) => s.label)} + descriptions={sources.map((s) => s.description)} + onSelect={(value) => { + setSource(value as UpdateSource); + setPhase(value === "remote" ? "branch" : "confirm"); + }} + onCancel={cancel} + /> ); } if (phase === "branch") { return ( - - GitHub branch or tag - {branch || "main"} - Enter to continue. Backspace edits. - + { + setBranch(value || "main"); + setPhase("confirm"); + }} + /> ); } return ( - - Update Feather core? - Source: {source === "remote" ? `GitHub ${branch.trim() || "main"}` : "bundled/local copy"} - - Yes - / - No - - Use y/n, ←/→, then Enter. - + finish({ cancelled: false, source, branch: branch.trim() || "main" })} + onCancel={cancel} + /> ); } diff --git a/packages/README.md b/packages/README.md index 8e6357a0..cfc8e3d6 100644 --- a/packages/README.md +++ b/packages/README.md @@ -1,6 +1,8 @@ # Package Manager -Feather includes a curated package installer for LÖVE libraries. It is not a general package manager. It is a hand-curated catalog of known-good libraries with verified checksums. +Feather includes a curated package installer for LÖVE libraries. It is **not a general package manager** — it is a set of utilities to manage libraries that would otherwise require manual copy-paste. There is no central registry in the npm or Cargo sense. All catalog packages are sourced from GitHub repositories; if a repository is deleted, renamed, or made private, that package will no longer be installable. **You are responsible for your own copies.** Committing installed files to your repo rather than adding them to `.gitignore` is strongly recommended if long-term reproducibility matters to you. + +The catalog is hand-curated and pinned to exact commit SHAs with verified checksums. ## Concepts @@ -131,20 +133,37 @@ If the requested version does not exist on GitHub, the install aborts with a cle ### `feather package add` -Install one or more Lua files from direct URLs into your project. Launches an interactive wizard that walks you through each step: +Install a Lua library that is not in the Feather catalog. Launches an interactive wizard and first asks how you want to add the package: + +- **From GitHub repository** — commit-SHA pinned, reproducible. The wizard fetches the repo's tags and branches, lets you pick one, resolves it to an exact commit SHA, shows all `.lua` files in the tree, and lets you select which ones to install and where. +- **From direct URL(s)** — for libraries not on GitHub or without versioned releases. Each URL is downloaded immediately and its SHA-256 is computed before you confirm. -1. Name the dependency — how it appears in `feather.lock.json` -2. Enter file URLs — each is downloaded immediately, SHA-256 computed, and you confirm the install path -3. Optionally add more files to the same named package -4. Enter the require path for the usage hint -5. Review the full summary (files, checksums, trust warning) before anything is written +Both flows share the same steps for naming the package, setting install paths, entering a require path, and reviewing before anything is written. ```sh feather package add feather package add --dir path/to/my-game ``` -Files are written and `feather.lock.json` is updated only after you confirm at the review step. +**GitHub repo flow (step by step):** + +1. Choose mode → "From GitHub repository" +2. Package name — how it appears in `feather.lock.json` +3. GitHub repo (`owner/repo`) +4. Select a tag or branch — tags listed first, then branches +5. Select which `.lua` files to install +6. Set install target path for each file +7. Enter the require path for your game code +8. Review summary and confirm — files are written only after this step + +**Direct URL flow (step by step):** + +1. Choose mode → "From direct URL(s)" +2. Package name +3. Enter a file URL — downloaded and SHA-256 computed immediately +4. Confirm install path, optionally add more files to the same package +5. Enter the require path +6. Review summary and confirm > [!CAUTION] > Packages installed this way have `trust: experimental` — they have not been reviewed by the Feather team. Only install files from sources you trust. The SHA-256 of each file is recorded in the lockfile so future `feather package audit` runs will detect any tampering. From e2cf2572a7ffbe4ef976b0ba7cf26728aba11a44 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Fri, 15 May 2026 23:29:49 -0400 Subject: [PATCH 20/68] package: lovebpm --- cli/src/generated/registry.json | 34 ++++++++++++++++++++++++++++++++- packages/lovebpm.json | 32 +++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 packages/lovebpm.json diff --git a/cli/src/generated/registry.json b/cli/src/generated/registry.json index ad592b43..4bb49f57 100644 --- a/cli/src/generated/registry.json +++ b/cli/src/generated/registry.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-05-14", + "updatedAt": "2026-05-16", "packages": { "anim8": { "type": "love2d-library", @@ -1267,6 +1267,38 @@ "require": "lib.love-dialogue", "example": "local love_dialogue = require('lib.love-dialogue')" }, + "lovebpm": { + "type": "love2d-library", + "trust": "verified", + "description": "A LÖVE library for syncing events to the BPM of an audio track", + "tags": [ + "music", + "sound", + "audio", + "track", + "bpm", + "events" + ], + "homepage": "https://github.com/rxi/lovebpm", + "license": "MIT", + "source": { + "repo": "rxi/lovebpm", + "tag": "master", + "commitSha": "22f8537083818ebfc2a0788fbec3e991894371f9", + "baseUrl": "https://raw.githubusercontent.com/rxi/lovebpm/22f8537083818ebfc2a0788fbec3e991894371f9/" + }, + "install": { + "files": [ + { + "name": "lovebpm.lua", + "sha256": "fe3bb0fa97f79c0ff8e32810a16473bbe7677b1d21b753aeec1e35ac8f86b00c", + "target": "lib/lovebpm.lua" + } + ] + }, + "require": "lib.lovebpm", + "example": "local lovebpm = require('lib.lovebpm')" + }, "lua-state-machine": { "type": "love2d-library", "trust": "verified", diff --git a/packages/lovebpm.json b/packages/lovebpm.json new file mode 100644 index 00000000..836408f0 --- /dev/null +++ b/packages/lovebpm.json @@ -0,0 +1,32 @@ +{ + "type": "love2d-library", + "trust": "verified", + "description": "A LÖVE library for syncing events to the BPM of an audio track", + "tags": [ + "music", + "sound", + "audio", + "track", + "bpm", + "events" + ], + "homepage": "https://github.com/rxi/lovebpm", + "license": "MIT", + "source": { + "repo": "rxi/lovebpm", + "tag": "master", + "commitSha": "22f8537083818ebfc2a0788fbec3e991894371f9", + "baseUrl": "https://raw.githubusercontent.com/rxi/lovebpm/22f8537083818ebfc2a0788fbec3e991894371f9/" + }, + "install": { + "files": [ + { + "name": "lovebpm.lua", + "sha256": "fe3bb0fa97f79c0ff8e32810a16473bbe7677b1d21b753aeec1e35ac8f86b00c", + "target": "lib/lovebpm.lua" + } + ] + }, + "require": "lib.lovebpm", + "example": "local lovebpm = require('lib.lovebpm')" +} From febae2a047f2c94b057b2bdf09122a59b36648c6 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Fri, 15 May 2026 23:53:18 -0400 Subject: [PATCH 21/68] cli: fix custom package add --- cli/src/commands/doctor.ts | 13 +- cli/src/commands/init.ts | 35 +- cli/src/commands/package.ts | 114 +- cli/src/commands/plugin.ts | 55 +- cli/src/commands/remove.ts | 7 +- cli/src/commands/run.ts | 25 +- cli/src/commands/update.ts | 10 +- cli/src/index.ts | 7 +- cli/src/lib/output.ts | 60 + cli/src/ui/components.tsx | 6 +- cli/src/ui/confirm.tsx | 54 + cli/src/ui/package-add.tsx | 9 +- cli/src/ui/remove-workflow.tsx | 6 +- cli/test/e2e.mjs | 4 +- cli/test/package.test.mjs | 76 +- package-lock.json | 9022 ++++++++++++++++---------------- 16 files changed, 4903 insertions(+), 4600 deletions(-) create mode 100644 cli/src/lib/output.ts create mode 100644 cli/src/ui/confirm.tsx diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts index 84e8fb09..d9cda919 100644 --- a/cli/src/commands/doctor.ts +++ b/cli/src/commands/doctor.ts @@ -7,6 +7,7 @@ import { findLoveBinary, getLoveVersion } from "../lib/love.js"; import { loadConfig } from "../lib/config.js"; import { normalizeInstallDir } from "../lib/install.js"; import { parseManagedValue, findInstalledPluginDirs, readPluginManifest } from "../lib/plugin-utils.js"; +import { icon as statusIcon, style } from "../lib/output.js"; type Severity = "pass" | "warn" | "fail" | "info"; @@ -44,10 +45,10 @@ function add( } function icon(severity: Severity): string { - if (severity === "pass") return chalk.green("✔"); - if (severity === "warn") return chalk.yellow("!"); - if (severity === "fail") return chalk.red("✖"); - return chalk.cyan("i"); + if (severity === "pass") return statusIcon.success; + if (severity === "warn") return statusIcon.warning; + if (severity === "fail") return statusIcon.error; + return statusIcon.info; } function colorLabel(severity: Severity, label: string): string { @@ -108,8 +109,8 @@ function isWeakApiKey(value: unknown): boolean { } function renderReport(checks: DoctorCheck[], projectDir: string): void { - console.log(chalk.bold("\nFeather doctor\n")); - console.log(chalk.dim(`Project: ${projectDir}\n`)); + console.log(style.heading("\nFeather doctor\n")); + console.log(style.muted(`Project: ${projectDir}\n`)); const groups = [...new Set(checks.map((check) => check.group))]; for (const group of groups) { diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index 6e6a9b21..1f4bea97 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -16,6 +16,7 @@ import { configTemplate, luaKey, luaValue } from '../lib/config.js'; import { chooseInitMode, type InitMode, type InitSetup } from '../ui/init-mode.js'; import { pluginCatalog } from '../generated/plugin-catalog.js'; import { resolveLocalLuaRoot } from '../lib/paths.js'; +import { icon, statusLine, style } from '../lib/output.js'; export interface InitOptions { branch?: string; @@ -78,7 +79,7 @@ export async function initCommand(dir: string, opts: InitOptions): Promise const target = resolve(dir); if (!existsSync(join(target, 'main.lua'))) { - console.error(chalk.red(`No main.lua found in ${target}. Is this a love2d project?`)); + console.error(statusLine('error', `No main.lua found in ${target}. Is this a love2d project?`)); process.exit(1); } @@ -106,15 +107,15 @@ export async function initCommand(dir: string, opts: InitOptions): Promise installDir, source: useRemote ? `github:${setup.branch || opts.branch || 'main'}` : 'local', }); - console.log('\n' + chalk.bold('Done!') + ' Run this project through Feather CLI.\n'); - console.log(chalk.dim(` feather run ${dir}`)); - console.log(chalk.dim(' Use `--config ` if feather.config.lua lives elsewhere.\n')); + console.log(`\n${style.heading('Done!')} Run this project through Feather CLI.\n`); + console.log(style.muted(` feather run ${dir}`)); + console.log(style.muted(' Use `--config ` if feather.config.lua lives elsewhere.\n')); return; } if (alreadyInstalled) { - console.log(chalk.yellow('Feather is already installed in this project.')); - console.log(chalk.dim('Run `feather update` to update to the latest version.')); + console.log(style.warning('Feather is already installed in this project.')); + console.log(style.muted('Run `feather update` to update to the latest version.')); } const branch = setup.branch || opts.branch || 'main'; @@ -197,9 +198,9 @@ export async function initCommand(dir: string, opts: InitOptions): Promise if (mode === 'auto') { const patched = patchMainLua(mainPath, installDir); if (patched) { - console.log(chalk.green('✔') + ' Patched main.lua with feather.auto require'); + console.log(`${icon.success} Patched main.lua with feather.auto require`); } else { - console.log(chalk.dim(' main.lua already references feather — skipped patch')); + console.log(style.muted(' main.lua already references feather — skipped patch')); } } else if (mode === 'manual') { const pluginIds = @@ -212,15 +213,15 @@ export async function initCommand(dir: string, opts: InitOptions): Promise const patched = patchMainLuaForManual(mainPath); if (created) { - console.log(chalk.green('✔') + ' Created feather.debugger.lua'); + console.log(`${icon.success} Created feather.debugger.lua`); } else { - console.log(chalk.dim(' feather.debugger.lua already exists — skipped')); + console.log(style.muted(' feather.debugger.lua already exists — skipped')); } if (patched) { - console.log(chalk.green('✔') + ' Patched main.lua with feather.debugger.lua loader'); + console.log(`${icon.success} Patched main.lua with feather.debugger.lua loader`); } else { - console.log(chalk.dim(' main.lua already references Feather — skipped patch')); + console.log(style.muted(' main.lua already references Feather — skipped patch')); } } @@ -231,12 +232,12 @@ export async function initCommand(dir: string, opts: InitOptions): Promise manualEntrypoint: mode === 'manual' ? 'feather.debugger.lua' : undefined, }); - console.log('\n' + chalk.bold('Done!') + ' Start the Feather desktop app, then run your game.\n'); + console.log(`\n${style.heading('Done!')} Start the Feather desktop app, then run your game.\n`); if (mode === 'manual') { - console.log(chalk.dim(' Manual setup lives in feather.debugger.lua and is loaded from main.lua.\n')); + console.log(style.muted(' Manual setup lives in feather.debugger.lua and is loaded from main.lua.\n')); } else { - console.log(chalk.dim(' Tip: use `feather run .` to inject without touching game code.\n')); + console.log(style.muted(' Tip: use `feather run .` to inject without touching game code.\n')); } } @@ -248,9 +249,9 @@ function writeConfig( const configPath = join(target, 'feather.config.lua'); if (!existsSync(configPath)) { writeFileSync(configPath, configTemplate(config, context)); - console.log(chalk.green('✔') + ' Created feather.config.lua'); + console.log(`${icon.success} Created feather.config.lua`); } else { - console.log(chalk.dim(' feather.config.lua already exists — skipped')); + console.log(style.muted(' feather.config.lua already exists — skipped')); } } diff --git a/cli/src/commands/package.ts b/cli/src/commands/package.ts index 2edcb674..dd6bd8ed 100644 --- a/cli/src/commands/package.ts +++ b/cli/src/commands/package.ts @@ -10,8 +10,10 @@ import { auditLockfile } from '../lib/package/audit.js'; import { showPackageBrowser } from '../ui/package-workflow.js'; import { showInstallProgress } from '../ui/package-progress.js'; import { showAddWizard } from '../ui/package-add.js'; +import { confirmAction } from '../ui/confirm.js'; import { findProjectDir } from '../lib/paths.js'; import { trustBadge } from '../lib/trust.js'; +import { icon, keyValueRows, statusLine, style } from '../lib/output.js'; export type PackageSearchOptions = { offline?: boolean; @@ -199,27 +201,42 @@ export async function packageInstallCommand(names: string[], opts: PackageInstal const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); if (opts.fromUrl) { - if (!opts.allowUntrusted && !opts.yes) { - console.log(); - console.log(chalk.yellow('Installing from untrusted URL')); - console.log(` URL: ${opts.fromUrl}`); - if (!opts.target) { - console.log(chalk.red(' --target is required with --from-url')); - process.exitCode = 1; - return; - } - console.log(` Target: ${opts.target}`); + if (!opts.target) { console.log(); - console.log(chalk.red(' This package has NOT been reviewed by the Feather team.')); - console.log(chalk.dim(' Use --allow-untrusted to confirm you trust this source.')); + console.log(statusLine('error', '--target is required with --from-url')); process.exitCode = 1; return; } - if (!opts.target) { - console.log(chalk.red('--target is required with --from-url')); - process.exitCode = 1; - return; + if (!opts.allowUntrusted) { + console.log(); + console.log(style.warning('Installing from untrusted URL')); + for (const row of keyValueRows([ + ['URL', opts.fromUrl], + ['Target', opts.target], + ['Trust', 'experimental; not reviewed by the Feather team'], + ])) { + console.log(row); + } + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + console.log(); + console.log(style.danger('Use --allow-untrusted to confirm this source in non-interactive mode.')); + process.exitCode = 1; + return; + } + + const confirmed = await confirmAction({ + title: 'feather package install', + label: 'Install this unreviewed URL?', + hint: 'Only continue if you trust the source and target path.', + danger: true, + rows: [`URL: ${opts.fromUrl}`, `Target: ${opts.target}`], + }); + if (!confirmed) { + console.log(style.muted('Install cancelled.')); + return; + } } const spinner = ora(`Fetching ${opts.fromUrl}…`).start(); @@ -240,20 +257,28 @@ export async function packageInstallCommand(names: string[], opts: PackageInstal if (opts.dryRun) { spinner.stop(); console.log(); - console.log(chalk.yellow('Dry run — no files written')); - console.log(` URL: ${opts.fromUrl}`); - console.log(` SHA-256: ${result.sha256}`); - console.log(` Size: ${result.size} bytes`); - console.log(` Target: ${result.target}`); - console.log(chalk.yellow(' Trust: experimental ⚠')); + console.log(style.warning('Dry run: no files written')); + for (const row of keyValueRows([ + ['URL', opts.fromUrl], + ['SHA-256', result.sha256], + ['Size', `${result.size} bytes`], + ['Target', result.target], + ['Trust', 'experimental; not reviewed'], + ])) { + console.log(row); + } return; } spinner.succeed('Installed from URL (experimental)'); console.log(); - console.log(` SHA-256: ${result.sha256}`); - console.log(` Target: ${result.target}`); - console.log(chalk.yellow(' Trust: experimental ⚠ — not reviewed by the Feather team')); + for (const row of keyValueRows([ + ['SHA-256', result.sha256], + ['Target', result.target], + ['Trust', style.warning('experimental; not reviewed by the Feather team')], + ])) { + console.log(row); + } writeLockfile(projectDir, lockfile); return; } @@ -453,17 +478,41 @@ export async function packageRemoveCommand(name: string, opts: PackageRemoveOpti return; } + const existingFiles = entry.files.filter((file) => existsSync(join(projectDir, file.target))); + if (!opts.yes && (!process.stdin.isTTY || !process.stdout.isTTY)) { + console.log(style.danger(`Refusing to remove "${name}" without --yes in non-interactive mode.`)); + process.exitCode = 1; + return; + } + + if (!opts.yes) { + const confirmed = await confirmAction({ + title: 'feather package remove', + label: `Remove package "${name}"?`, + hint: 'This deletes installed files and updates feather.lock.json.', + danger: true, + rows: [ + ...existingFiles.map((file) => file.target), + 'feather.lock.json', + ], + }); + if (!confirmed) { + console.log(chalk.dim('Package remove cancelled.')); + return; + } + } + for (const file of entry.files) { const abs = join(projectDir, file.target); if (existsSync(abs)) { rmSync(abs); - console.log(chalk.dim(` removed ${file.target}`)); + console.log(style.muted(` removed ${file.target}`)); } } removeFromLockfile(lockfile, name); writeLockfile(projectDir, lockfile); - console.log(` ${chalk.bold(name)} removed.`); + console.log(` ${icon.success} ${chalk.bold(name)} removed.`); } export type PackageAddOptions = { @@ -471,6 +520,17 @@ export type PackageAddOptions = { }; export async function packageAddCommand(opts: PackageAddOptions = {}): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY || typeof process.stdin.setRawMode !== 'function') { + console.log(statusLine('error', '`feather package add` requires an interactive terminal.')); + console.log( + style.muted( + 'Run it from a real TTY, or use `feather package install --from-url --target --allow-untrusted` for scripts.', + ), + ); + process.exitCode = 1; + return; + } + const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); const lockfile = readLockfile(projectDir); await showAddWizard({ projectDir, lockfile }); diff --git a/cli/src/commands/plugin.ts b/cli/src/commands/plugin.ts index 7d07f767..c7fb242f 100644 --- a/cli/src/commands/plugin.ts +++ b/cli/src/commands/plugin.ts @@ -11,8 +11,10 @@ import { normalizeInstallDir, } from "../lib/install.js"; import { choosePluginUpdateWorkflow, choosePluginWorkflow } from "../ui/plugin-workflow.js"; +import { confirmAction } from "../ui/confirm.js"; import { findProjectDir, resolveLocalLuaRoot } from "../lib/paths.js"; import { findInstalledPluginDirs, readPluginManifest } from "../lib/plugin-utils.js"; +import { icon, statusLine, style, table } from "../lib/output.js"; function pluginsDir(projectDir: string, installDir = "feather"): string { return join(projectDir, normalizeInstallDir(installDir), "plugins"); @@ -45,13 +47,23 @@ export async function pluginListCommand(dir?: string, installDir = "feather"): P } console.log(chalk.bold(`\nInstalled plugins (${dirs.length})\n`)); - for (const dir of dirs) { + const rows = dirs.map((dir) => { const meta = readPluginManifest(dir); - if (meta) { - console.log(` ${chalk.cyan(meta.id.padEnd(24))} ${chalk.dim(meta.version.padEnd(8))} ${meta.name}`); - } else { - console.log(` ${chalk.cyan(dir.replace(dirPath, "").replace(/^[/\\]/, ""))}`); - } + return { + id: meta?.id ?? dir.replace(dirPath, "").replace(/^[/\\]/, ""), + version: meta?.version ?? "", + name: meta?.name ?? "", + }; + }); + for (const line of table({ + columns: [ + { key: "id", label: "ID", color: (value) => chalk.cyan(value) }, + { key: "version", label: "VERSION", color: (value) => chalk.dim(value) }, + { key: "name", label: "NAME" }, + ], + rows, + })) { + console.log(line); } console.log(); } @@ -111,17 +123,40 @@ export async function pluginInstallCommand( } } -export async function pluginRemoveCommand(pluginId: string, opts: { dir?: string; installDir?: string }): Promise { +export async function pluginRemoveCommand( + pluginId: string, + opts: { dir?: string; installDir?: string; yes?: boolean }, +): Promise { const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); const pluginDir = join(pluginsDir(projectDir, opts.installDir), pluginId.replace(/\./g, "/")); if (!existsSync(pluginDir)) { - console.error(chalk.red(`Plugin not found: ${pluginId}`)); + console.error(statusLine("error", `Plugin not found: ${pluginId}`)); process.exit(1); } + if (!opts.yes && (!process.stdin.isTTY || !process.stdout.isTTY)) { + console.log(style.danger(`Refusing to remove "${pluginId}" without --yes in non-interactive mode.`)); + process.exitCode = 1; + return; + } + + if (!opts.yes) { + const confirmed = await confirmAction({ + title: "feather plugin remove", + label: `Remove plugin "${pluginId}"?`, + hint: "This recursively deletes the installed plugin directory.", + danger: true, + rows: [pluginDir], + }); + if (!confirmed) { + console.log(chalk.dim("Plugin remove cancelled.")); + return; + } + } + rmSync(pluginDir, { recursive: true, force: true }); - console.log(chalk.green("✔") + ` Removed ${pluginId}`); + console.log(`${icon.success} Removed ${pluginId}`); } export async function pluginUpdateCommand( @@ -261,6 +296,6 @@ export async function pluginWorkflowCommand(opts: { } for (const id of result.pluginIds) { - await pluginRemoveCommand(id, { dir: projectDir, installDir }); + await pluginRemoveCommand(id, { dir: projectDir, installDir, yes: true }); } } diff --git a/cli/src/commands/remove.ts b/cli/src/commands/remove.ts index eca93c7b..c208c6eb 100644 --- a/cli/src/commands/remove.ts +++ b/cli/src/commands/remove.ts @@ -4,6 +4,7 @@ import chalk from "chalk"; import { normalizeInstallDir } from "../lib/install.js"; import { chooseRemoveWorkflow, type RemoveTarget } from "../ui/remove-workflow.js"; import { parseManagedValue } from "../lib/plugin-utils.js"; +import { icon, style } from "../lib/output.js"; export interface RemoveOptions { yes?: boolean; @@ -57,6 +58,7 @@ function discoverTargets(context: RemoveContext, opts: RemoveOptions): RemoveTar path: context.mainPath, description: "Remove only the FEATHER-INIT require block and DEBUGGER:update hook.", defaultSelected: true, + dangerous: true, }); } } @@ -69,6 +71,7 @@ function discoverTargets(context: RemoveContext, opts: RemoveOptions): RemoveTar path: runtimePath, description: "Delete the installed Feather core and plugins directory.", defaultSelected: true, + dangerous: true, }); } @@ -80,6 +83,7 @@ function discoverTargets(context: RemoveContext, opts: RemoveOptions): RemoveTar path: manualPath, description: "Delete the generated manual setup file.", defaultSelected: true, + dangerous: true, }); } @@ -93,6 +97,7 @@ function discoverTargets(context: RemoveContext, opts: RemoveOptions): RemoveTar ? "Delete the generated Feather config file." : "Delete feather.config.lua. This file does not contain managed metadata.", defaultSelected: configSrc.includes("FEATHER-MANAGED-BEGIN"), + dangerous: true, }); } @@ -163,7 +168,7 @@ export async function removeCommand(dir: string, opts: RemoveOptions): Promise { if (!gamePath) { if (!process.stdin.isTTY) { - console.error(chalk.red("Game path is required. Use `feather run `.")); + console.error(statusLine("error", "Game path is required. Use `feather run `.")); process.exit(1); } const result = await chooseRunWorkflow(); if (result.cancelled) { - console.log(chalk.dim("Run cancelled.")); + console.log(style.muted("Run cancelled.")); return; } @@ -46,12 +46,12 @@ export async function runCommand(gamePath: string | undefined, opts: RunOptions) const absGame = resolve(gamePath); if (!existsSync(absGame)) { - console.error(chalk.red(`Game path not found: ${absGame}`)); + console.error(statusLine("error", `Game path not found: ${absGame}`)); process.exit(1); } if (!existsSync(`${absGame}/main.lua`)) { - console.error(chalk.red(`No main.lua found in: ${absGame}`)); + console.error(statusLine("error", `No main.lua found in: ${absGame}`)); process.exit(1); } @@ -59,7 +59,7 @@ export async function runCommand(gamePath: string | undefined, opts: RunOptions) try { loveBin = findLoveBinary(opts.love); } catch (err) { - console.error(chalk.red((err as Error).message)); + console.error(statusLine("error", (err as Error).message)); process.exit(1); } @@ -75,10 +75,13 @@ export async function runCommand(gamePath: string | undefined, opts: RunOptions) userConfig: userConfig as Record | undefined, }); - console.log(chalk.dim(`[feather] shim → ${shim.dir}`)); - console.log(chalk.cyan(`[feather] running ${absGame}`)); - if (opts.gameArgs && opts.gameArgs.length > 0) { - console.log(chalk.dim(`[feather] args → ${opts.gameArgs.join(" ")}`)); + console.log(style.info("Feather run")); + for (const row of keyValueRows([ + ["Game", absGame], + ["Shim", shim.dir], + ["Args", opts.gameArgs?.join(" ")], + ])) { + console.log(row); } const env = shimEnv(absGame, sessionName); @@ -91,7 +94,7 @@ export async function runCommand(gamePath: string | undefined, opts: RunOptions) shim.cleanup(); if (result.error) { - console.error(chalk.red(`Failed to launch love: ${result.error.message}`)); + console.error(statusLine("error", `Failed to launch love: ${result.error.message}`)); process.exit(1); } diff --git a/cli/src/commands/update.ts b/cli/src/commands/update.ts index e9fc012b..53757b5f 100644 --- a/cli/src/commands/update.ts +++ b/cli/src/commands/update.ts @@ -1,10 +1,10 @@ import { existsSync } from "node:fs"; import { join, resolve } from "node:path"; -import chalk from "chalk"; import ora from "ora"; import { fetchManifest, installCore, installCoreFromLocal, normalizeInstallDir } from "../lib/install.js"; import { chooseCoreUpdateWorkflow } from "../ui/update-workflow.js"; import { resolveLocalLuaRoot } from "../lib/paths.js"; +import { statusLine, style } from "../lib/output.js"; export async function updateCommand( dir: string, @@ -14,7 +14,7 @@ export async function updateCommand( const installDir = normalizeInstallDir(opts.installDir); if (!existsSync(join(target, installDir, "init.lua"))) { - console.error(chalk.red(`Feather is not installed in ${target}. Run \`feather init\` first.`)); + console.error(statusLine("error", `Feather is not installed in ${target}. Run \`feather init\` first.`)); process.exit(1); } @@ -25,7 +25,7 @@ export async function updateCommand( if (!hasExplicitSource && process.stdin.isTTY) { const result = await chooseCoreUpdateWorkflow(branch); if (result.cancelled) { - console.log(chalk.dim("Update cancelled.")); + console.log(style.muted("Update cancelled.")); return; } useRemote = result.source === "remote"; @@ -45,7 +45,7 @@ export async function updateCommand( process.exit(1); } - console.log(chalk.bold("\nDone!") + " Feather core is up to date.\n"); + console.log(`\n${style.heading("Done!")} Feather core is up to date.\n`); return; } @@ -71,5 +71,5 @@ export async function updateCommand( process.exit(1); } - console.log(chalk.bold("\nDone!") + " Feather core is up to date.\n"); + console.log(`\n${style.heading("Done!")} Feather core is up to date.\n`); } diff --git a/cli/src/index.ts b/cli/src/index.ts index 1f291516..2b12cc9f 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -196,9 +196,14 @@ plugin .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(async (id: string, opts) => { const merged = pluginCommandOptions(opts); - await pluginRemoveCommand(id, { dir: merged.dir as string | undefined, installDir: merged.installDir as string }); + await pluginRemoveCommand(id, { + dir: merged.dir as string | undefined, + installDir: merged.installDir as string, + yes: merged.yes as boolean | undefined, + }); }); plugin diff --git a/cli/src/lib/output.ts b/cli/src/lib/output.ts new file mode 100644 index 00000000..787c4bc4 --- /dev/null +++ b/cli/src/lib/output.ts @@ -0,0 +1,60 @@ +import chalk from 'chalk'; + +export const icon = { + success: chalk.green('✔'), + error: chalk.red('✖'), + warning: chalk.yellow('!'), + info: chalk.cyan('i'), + danger: chalk.red('!'), +}; + +export const style = { + heading: chalk.bold, + muted: chalk.dim, + success: chalk.green, + warning: chalk.yellow, + danger: chalk.red, + info: chalk.cyan, +}; + +export function statusLine(kind: keyof typeof icon, message: string): string { + return `${icon[kind]} ${message}`; +} + +export function keyValueRows(rows: Array<[string, string | number | undefined | null]>): string[] { + const visible = rows.filter(([, value]) => value !== undefined && value !== null && value !== ''); + const width = Math.max(0, ...visible.map(([key]) => key.length)); + return visible.map(([key, value]) => ` ${style.muted(key.padEnd(width))} ${value}`); +} + +export function table>(input: { + columns: Array<{ key: keyof Row; label: string; color?: (value: string, row: Row) => string }>; + rows: Row[]; + indent?: string; +}): string[] { + const indent = input.indent ?? ' '; + const widths = input.columns.map((column) => + Math.max( + column.label.length, + ...input.rows.map((row) => String(row[column.key] ?? '').length), + ), + ); + + const header = input.columns + .map((column, index) => column.label.padEnd(widths[index]!)) + .join(' '); + const lines = [`${indent}${style.muted(header)}`]; + + for (const row of input.rows) { + lines.push( + `${indent}${input.columns + .map((column, index) => { + const value = String(row[column.key] ?? '').padEnd(widths[index]!); + return column.color ? column.color(value, row) : value; + }) + .join(' ')}`, + ); + } + + return lines; +} diff --git a/cli/src/ui/components.tsx b/cli/src/ui/components.tsx index fbaf5741..875ce957 100644 --- a/cli/src/ui/components.tsx +++ b/cli/src/ui/components.tsx @@ -269,6 +269,7 @@ export function BooleanStep({ hint, children, defaultYes = true, + tone = 'default', onConfirm, onCancel, }: { @@ -279,6 +280,7 @@ export function BooleanStep({ hint?: string; children?: ReactNode; defaultYes?: boolean; + tone?: 'default' | 'danger'; onConfirm: () => void; onCancel: () => void; }) { @@ -297,12 +299,12 @@ export function BooleanStep({ return (
- {' '}{label} + {' '}{label} {hint && {hint}} {children} {' '} - Yes + Yes / No diff --git a/cli/src/ui/confirm.tsx b/cli/src/ui/confirm.tsx new file mode 100644 index 00000000..df3f6fc7 --- /dev/null +++ b/cli/src/ui/confirm.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Text, render, useApp } from 'ink'; +import { BooleanStep } from './components.js'; + +export type ConfirmActionOptions = { + title?: string; + label: string; + hint?: string; + rows?: string[]; + danger?: boolean; + defaultYes?: boolean; +}; + +function ConfirmAction({ + title, + label, + hint, + rows = [], + danger, + defaultYes, + onComplete, +}: ConfirmActionOptions & { onComplete: (confirmed: boolean) => void }) { + const { exit } = useApp(); + const finish = (confirmed: boolean) => { + onComplete(confirmed); + exit(); + }; + + return ( + finish(true)} + onCancel={() => finish(false)} + > + {rows.map((row) => ( + + {' - '} + {row} + + ))} + + ); +} + +export async function confirmAction(options: ConfirmActionOptions): Promise { + let confirmed = false; + const { waitUntilExit } = render( { confirmed = value; }} />); + await waitUntilExit(); + return confirmed; +} diff --git a/cli/src/ui/package-add.tsx b/cli/src/ui/package-add.tsx index a66237d6..84ea37c0 100644 --- a/cli/src/ui/package-add.tsx +++ b/cli/src/ui/package-add.tsx @@ -332,6 +332,7 @@ function Wizard({ projectDir, lockfile }: { projectDir: string; lockfile: Lockfi // Repo flow const [repoName, setRepoName] = useState(''); const [tag, setTag] = useState(''); + const [commitSha, setCommitSha] = useState(''); const [baseUrl, setBaseUrl] = useState(''); const [tagOptions, setTagOptions] = useState([]); const [tagLabels, setTagLabels] = useState([]); @@ -415,6 +416,7 @@ function Wizard({ projectDir, lockfile }: { projectDir: string; lockfile: Lockfi if (step === 'fetch-tags') { return ( { const { values, labels } = await fetchRepoMeta(repoName); @@ -448,9 +450,11 @@ function Wizard({ projectDir, lockfile }: { projectDir: string; lockfile: Lockfi if (step === 'resolve-commit') { return ( { const sha = await fetchCommitSha(repoName, tag); + setCommitSha(sha); setBaseUrl(`https://raw.githubusercontent.com/${repoName}/${sha}/`); setStep('fetch-files'); }} @@ -462,9 +466,10 @@ function Wizard({ projectDir, lockfile }: { projectDir: string; lockfile: Lockfi if (step === 'fetch-files') { return ( { - const files = await fetchLuaFiles(repoName, tag); + const files = await fetchLuaFiles(repoName, commitSha); if (files.length === 0) throw new Error('No .lua files found at this ref.'); setLuaFiles(files); setStep('files'); diff --git a/cli/src/ui/remove-workflow.tsx b/cli/src/ui/remove-workflow.tsx index 13db94cc..2ccc976f 100644 --- a/cli/src/ui/remove-workflow.tsx +++ b/cli/src/ui/remove-workflow.tsx @@ -8,6 +8,7 @@ export type RemoveTarget = { path: string; description: string; defaultSelected: boolean; + dangerous?: boolean; }; export type RemoveWorkflowResult = @@ -70,6 +71,9 @@ function RemoveWorkflow({ return ( finish({ cancelled: false, targetIds: [...selected] })} onCancel={() => finish({ cancelled: true })} > @@ -87,7 +91,7 @@ function RemoveWorkflow({ {rows.length === 0 ? No managed Feather files or markers were found. : null} {rows.map((target, index) => ( - + {index === cursor ? "❯" : " "} {selected.has(target.id) ? "◉" : "○"} {target.label}: {target.path} {target.description} diff --git a/cli/test/e2e.mjs b/cli/test/e2e.mjs index 91631dcd..f3a3fc3b 100644 --- a/cli/test/e2e.mjs +++ b/cli/test/e2e.mjs @@ -171,7 +171,7 @@ try { const pkgDir = mkdtempSync(join(tmpdir(), `feather-pkg-${pkgId}-`)); // install - run(['package', 'install', pkgId, '--dir', pkgDir]); + run(['package', 'install', pkgId, '--offline', '--dir', pkgDir]); // every file must exist on disk for (const file of entry.install.files) { @@ -198,7 +198,7 @@ try { } // remove - run(['package', 'remove', pkgId, '--dir', pkgDir]); + run(['package', 'remove', pkgId, '--dir', pkgDir, '--yes']); for (const file of entry.install.files) { assert(!existsSync(join(pkgDir, file.target)), `${pkgId}: ${file.target} still on disk after remove`); } diff --git a/cli/test/package.test.mjs b/cli/test/package.test.mjs index 891e056b..4e89c15b 100644 --- a/cli/test/package.test.mjs +++ b/cli/test/package.test.mjs @@ -200,6 +200,31 @@ test('install --from-url: missing --allow-untrusted exits 1 with warning', () => assert.ok(stdout.includes('NOT been reviewed') || stdout.includes('allow-untrusted')); }); +test('add: non-interactive terminal exits cleanly', () => { + const dir = makeTmp(); + const { stdout, stderr, exitCode } = run(['package', 'add', '--dir', dir]); + assert.equal(exitCode, 1); + assert.ok(stdout.includes('interactive terminal')); + assert.equal(stderr.includes('Raw mode is not supported'), false); +}); + +test('install --from-url: --yes alone does not bypass untrusted source', () => { + const dir = makeTmp(); + const { stdout, exitCode } = run([ + 'package', + 'install', + '--from-url', + 'https://example.com/helper.lua', + '--target', + 'lib/helper.lua', + '--yes', + '--dir', + dir, + ]); + assert.equal(exitCode, 1); + assert.ok(stdout.includes('allow-untrusted') || stdout.includes('untrusted')); +}); + test('install --from-url: missing --target exits 1', () => { const dir = makeTmp(); const { stdout, exitCode } = run([ @@ -301,6 +326,24 @@ test('remove: not installed exits 1 with message', () => { assert.ok(stdout.includes('not installed')); }); +test('remove: installed package requires --yes in non-interactive mode', () => { + const dir = makeTmp(); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'anim8.lua'), 'return {}'); + writeLock(dir, { + anim8: { + version: 'v2.3.1', + trust: 'verified', + source: { repo: 'kikito/anim8', tag: 'v2.3.1' }, + files: [{ name: 'anim8.lua', target: 'lib/anim8.lua', sha256: 'any' }], + }, + }); + const { stdout, exitCode } = run(['package', 'remove', 'anim8', '--dir', dir]); + assert.equal(exitCode, 1); + assert.ok(stdout.includes('--yes')); + assert.ok(existsSync(join(dir, 'lib', 'anim8.lua')), 'file should remain'); +}); + test('remove: deletes file and removes lockfile entry', () => { const dir = makeTmp(); mkdirSync(join(dir, 'lib'), { recursive: true }); @@ -313,7 +356,7 @@ test('remove: deletes file and removes lockfile entry', () => { files: [{ name: 'anim8.lua', target: 'lib/anim8.lua', sha256: 'any' }], }, }); - const { exitCode } = run(['package', 'remove', 'anim8', '--dir', dir]); + const { exitCode } = run(['package', 'remove', 'anim8', '--dir', dir, '--yes']); assert.equal(exitCode, 0); assert.ok(!existsSync(join(dir, 'lib', 'anim8.lua')), 'file should be deleted'); const lock = JSON.parse(readFileSync(join(dir, 'feather.lock.json'), 'utf8')); @@ -339,7 +382,7 @@ test('remove: does not touch files belonging to other packages', () => { files: [{ name: 'bump.lua', target: 'lib/bump.lua', sha256: 'any' }], }, }); - run(['package', 'remove', 'anim8', '--dir', dir]); + run(['package', 'remove', 'anim8', '--dir', dir, '--yes']); assert.ok(existsSync(join(dir, 'lib', 'bump.lua')), 'bump.lua must not be touched'); const lock = JSON.parse(readFileSync(join(dir, 'feather.lock.json'), 'utf8')); assert.ok(lock.packages.bump, 'bump lockfile entry must remain'); @@ -498,7 +541,7 @@ test('remove: url package removes file and lockfile entry', () => { writeUrlLock(dir, 'my-helper', [ { name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: 'any' }, ]); - const { exitCode } = run(['package', 'remove', 'my-helper', '--dir', dir]); + const { exitCode } = run(['package', 'remove', 'my-helper', '--dir', dir, '--yes']); assert.equal(exitCode, 0); assert.ok(!existsSync(join(dir, 'lib', 'helper.lua')), 'file should be deleted'); const lock = JSON.parse(readFileSync(join(dir, 'feather.lock.json'), 'utf8')); @@ -514,12 +557,37 @@ test('remove: url package with multiple files removes all of them', () => { { name: 'a.lua', url: 'https://example.com/a.lua', target: 'lib/mypkg/a.lua', sha256: 'any' }, { name: 'b.lua', url: 'https://example.com/b.lua', target: 'lib/mypkg/b.lua', sha256: 'any' }, ]); - const { exitCode } = run(['package', 'remove', 'mypkg', '--dir', dir]); + const { exitCode } = run(['package', 'remove', 'mypkg', '--dir', dir, '--yes']); assert.equal(exitCode, 0); assert.ok(!existsSync(join(dir, 'lib', 'mypkg', 'a.lua'))); assert.ok(!existsSync(join(dir, 'lib', 'mypkg', 'b.lua'))); }); +test('plugin remove: --yes removes plugin in non-interactive mode', () => { + const dir = makeTmp(); + const pluginDir = join(dir, 'feather', 'plugins', 'my-plugin'); + mkdirSync(pluginDir, { recursive: true }); + writeFileSync( + join(pluginDir, 'manifest.lua'), + 'return { id = "my-plugin", name = "My Plugin", version = "1.0.0" }\n', + ); + + const { stdout, exitCode } = run(['plugin', 'remove', 'my-plugin', '--dir', dir, '--yes']); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('Removed my-plugin')); + assert.ok(!existsSync(pluginDir), 'plugin directory should be deleted'); +}); + +test('output: NO_COLOR keeps package search readable without ANSI escapes', () => { + const { stdout, exitCode } = run(['package', 'search', 'anim', '--offline'], { + env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, + }); + assert.equal(exitCode, 0); + assert.ok(stdout.includes('anim8')); + // eslint-disable-next-line no-control-regex + assert.equal(/\x1B\[[0-?]*[ -/]*[@-~]/.test(stdout), false); +}); + test('update: url package is skipped with experimental message', () => { const dir = makeTmp(); writeUrlLock(dir, 'my-helper', [ diff --git a/package-lock.json b/package-lock.json index 2c0eb345..45c4daf1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -573,742 +573,777 @@ "tslib": "^2.4.0" } }, - "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", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, + "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", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "node": ">=18" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, + "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": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=18" } }, - "node_modules/@eslint/config-array": { - "version": "0.23.5", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", - "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^3.0.5", - "debug": "^4.3.1", - "minimatch": "^10.2.4" - }, + "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": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=18" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", - "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1" - }, + "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": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=18" } }, - "node_modules/@eslint/core": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", - "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, + "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": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=18" } }, - "node_modules/@eslint/js": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", - "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", - "dev": true, + "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": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "eslint": "^10.0.0" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } + "node": ">=18" } }, - "node_modules/@eslint/object-schema": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", - "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", - "dev": true, - "license": "Apache-2.0", + "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": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=18" } }, - "node_modules/@eslint/plugin-kit": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", - "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1", - "levn": "^0.4.1" - }, + "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": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=18" } }, - "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "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", - "dependencies": { - "@floating-ui/utils": "^0.2.11" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "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", - "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@floating-ui/react-dom": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", - "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "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", - "dependencies": { - "@floating-ui/dom": "^1.7.6" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", - "license": "MIT" - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.18.0" + "node": ">=18" } }, - "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", - "dev": true, - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" - }, + "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.18.0" + "node": ">=18" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, + "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.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": ">=18" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, + "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": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": ">=18" } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, + "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.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "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", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "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", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "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", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "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": ">=6.0.0" + "node": ">=18" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "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", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@kyonru/feather": { - "resolved": "cli", - "link": true - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "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, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@oxc-project/types": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", - "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "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", - "funding": { - "url": "https://github.com/sponsors/Boshen" + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@playwright/test": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", - "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.60.0" - }, - "bin": { - "playwright": "cli.js" - }, + "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/@radix-ui/number": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", - "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==" - }, - "node_modules/@radix-ui/primitive": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", - "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", - "license": "MIT" - }, - "node_modules/@radix-ui/react-accessible-icon": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz", - "integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==", + "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", - "dependencies": { - "@radix-ui/react-visually-hidden": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@radix-ui/react-accordion": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", - "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "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", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collapsible": "1.1.12", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "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", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "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", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@radix-ui/react-alert-dialog": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", - "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "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", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dialog": "1.1.15", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3" + "eslint-visitor-keys": "^3.4.3" }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" + "@eslint/core": "^1.2.1" }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@radix-ui/react-arrow": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", - "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" }, "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "eslint": "^10.0.0" }, "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { + "eslint": { "optional": true } } }, - "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "@floating-ui/utils": "^0.2.11" } }, - "node_modules/@radix-ui/react-aspect-ratio": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", - "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==", + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" } }, - "node_modules/@radix-ui/react-aspect-ratio/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.3" + "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "react": ">=16.8.0", + "react-dom": ">=16.8.0" } }, - "node_modules/@radix-ui/react-aspect-ratio/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "engines": { + "node": ">=18.18.0" } }, - "node_modules/@radix-ui/react-avatar": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.11.tgz", - "integrity": "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-context": "1.1.3", - "@radix-ui/react-primitive": "2.1.4", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-is-hydrated": "0.1.0", - "@radix-ui/react-use-layout-effect": "1.1.1" + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "engines": { + "node": ">=18.18" }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-context": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", - "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "engines": { + "node": ">=18.18" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@radix-ui/react-checkbox": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", - "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@kyonru/feather": { + "resolved": "cli", + "link": true + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "node_modules/@oxc-project/types": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz", + "integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" + "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", - "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -1325,7 +1360,7 @@ } } }, - "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -1348,7 +1383,7 @@ } } }, - "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -1366,14 +1401,16 @@ } } }, - "node_modules/@radix-ui/react-collection": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", - "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", "license": "MIT", "dependencies": { + "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, @@ -1392,7 +1429,7 @@ } } }, - "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -1415,7 +1452,7 @@ } } }, - "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -1433,46 +1470,13 @@ } } }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context-menu": { - "version": "2.2.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", - "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-menu": "2.1.16", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", @@ -1489,7 +1493,7 @@ } } }, - "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -1512,7 +1516,7 @@ } } }, - "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -1530,26 +1534,13 @@ } } }, - "node_modules/@radix-ui/react-dialog": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", - "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", + "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", @@ -1566,7 +1557,7 @@ } } }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-aspect-ratio/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -1589,7 +1580,7 @@ } } }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-aspect-ratio/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -1607,31 +1598,17 @@ } } }, - "node_modules/@radix-ui/react-direction": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", - "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dismissable-layer": { + "node_modules/@radix-ui/react-avatar": { "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", - "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.11.tgz", + "integrity": "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4", "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-escape-keydown": "1.1.1" + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -1648,18 +1625,40 @@ } } }, - "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, "peerDependencies": { "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { @@ -1671,7 +1670,30 @@ } } }, - "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -1689,19 +1711,20 @@ } } }, - "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", - "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -1718,7 +1741,7 @@ } } }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -1741,7 +1764,7 @@ } } }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -1759,30 +1782,16 @@ } } }, - "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", - "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-scope": { + "node_modules/@radix-ui/react-collection": { "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", - "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1" + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -1799,7 +1808,7 @@ } } }, - "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -1822,7 +1831,7 @@ } } }, - "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -1840,41 +1849,46 @@ } } }, - "node_modules/@radix-ui/react-form": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz", - "integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-label": "2.1.7", - "@radix-ui/react-primitive": "2.1.3" - }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", "peerDependencies": { "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true - }, - "@types/react-dom": { + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { "optional": true } } }, - "node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-label": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", - "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -1891,7 +1905,7 @@ } } }, - "node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -1914,7 +1928,7 @@ } } }, - "node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -1932,21 +1946,26 @@ } } }, - "node_modules/@radix-ui/react-hover-card": { + "node_modules/@radix-ui/react-dialog": { "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", - "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", @@ -1963,7 +1982,7 @@ } } }, - "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -1986,7 +2005,7 @@ } } }, - "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -2004,13 +2023,10 @@ } } }, - "node_modules/@radix-ui/react-id": { + "node_modules/@radix-ui/react-direction": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", - "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -2021,53 +2037,17 @@ } } }, - "node_modules/@radix-ui/react-label": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", - "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.4" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menu": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", - "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" + "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -2084,7 +2064,7 @@ } } }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -2107,7 +2087,7 @@ } } }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -2125,21 +2105,18 @@ } } }, - "node_modules/@radix-ui/react-menubar": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", - "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==", + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { @@ -2157,7 +2134,7 @@ } } }, - "node_modules/@radix-ui/react-menubar/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -2180,7 +2157,7 @@ } } }, - "node_modules/@radix-ui/react-menubar/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -2198,26 +2175,30 @@ } } }, - "node_modules/@radix-ui/react-navigation-menu": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", - "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3" + "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -2234,7 +2215,7 @@ } } }, - "node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -2257,7 +2238,7 @@ } } }, - "node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -2275,24 +2256,18 @@ } } }, - "node_modules/@radix-ui/react-one-time-password-field": { + "node_modules/@radix-ui/react-form": { "version": "0.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz", - "integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz", + "integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==", "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-is-hydrated": "0.1.0", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", @@ -2309,7 +2284,30 @@ } } }, - "node_modules/@radix-ui/react-one-time-password-field/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -2332,7 +2330,7 @@ } } }, - "node_modules/@radix-ui/react-one-time-password-field/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -2350,20 +2348,21 @@ } } }, - "node_modules/@radix-ui/react-password-toggle-field": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz", - "integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==", + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", + "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-is-hydrated": "0.1.0" + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -2380,7 +2379,7 @@ } } }, - "node_modules/@radix-ui/react-password-toggle-field/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -2403,7 +2402,7 @@ } } }, - "node_modules/@radix-ui/react-password-toggle-field/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -2421,15 +2420,57 @@ } } }, - "node_modules/@radix-ui/react-popover": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", - "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", @@ -2438,8 +2479,9 @@ "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, @@ -2458,7 +2500,7 @@ } } }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -2481,7 +2523,7 @@ } } }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -2499,22 +2541,22 @@ } } }, - "node_modules/@radix-ui/react-popper": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", - "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", + "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==", "license": "MIT", "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-rect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/rect": "1.1.1" + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -2531,7 +2573,7 @@ } } }, - "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-menubar/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -2554,7 +2596,7 @@ } } }, - "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-menubar/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -2572,14 +2614,26 @@ } } }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", - "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", + "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", "license": "MIT", "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -2596,7 +2650,7 @@ } } }, - "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -2619,7 +2673,7 @@ } } }, - "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -2637,13 +2691,23 @@ } } }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", - "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz", + "integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==", "license": "MIT", "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { @@ -2661,13 +2725,13 @@ } } }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", - "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "node_modules/@radix-ui/react-one-time-password-field/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.4" + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -2684,87 +2748,38 @@ } } }, - "node_modules/@radix-ui/react-progress": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", - "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", + "node_modules/@radix-ui/react-one-time-password-field/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", "dependencies": { - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3" + "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true - }, - "@types/react-dom": { - "optional": true } } }, - "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-radio-group": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", - "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz", + "integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0" }, "peerDependencies": { "@types/react": "*", @@ -2781,7 +2796,7 @@ } } }, - "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-password-toggle-field/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -2804,7 +2819,7 @@ } } }, - "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-password-toggle-field/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -2822,21 +2837,27 @@ } } }, - "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", - "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", @@ -2853,7 +2874,7 @@ } } }, - "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -2876,7 +2897,7 @@ } } }, - "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -2894,21 +2915,22 @@ } } }, - "node_modules/@radix-ui/react-scroll-area": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", - "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.3", + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -2925,7 +2947,7 @@ } } }, - "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -2948,7 +2970,7 @@ } } }, - "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -2966,33 +2988,14 @@ } } }, - "node_modules/@radix-ui/react-select": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", - "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" + "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -3009,7 +3012,7 @@ } } }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -3032,7 +3035,7 @@ } } }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -3050,13 +3053,14 @@ } } }, - "node_modules/@radix-ui/react-separator": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", - "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.4" + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -3073,23 +3077,13 @@ } } }, - "node_modules/@radix-ui/react-slider": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", - "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" + "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", @@ -3106,13 +3100,14 @@ } } }, - "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "node_modules/@radix-ui/react-progress": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.3" + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", @@ -3129,28 +3124,33 @@ } } }, - "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", - "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" @@ -3165,16 +3165,19 @@ } } }, - "node_modules/@radix-ui/react-switch": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", - "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" @@ -3194,7 +3197,7 @@ } } }, - "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -3217,7 +3220,7 @@ } } }, - "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -3235,19 +3238,20 @@ } } }, - "node_modules/@radix-ui/react-tabs": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", - "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { @@ -3265,7 +3269,7 @@ } } }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -3288,7 +3292,7 @@ } } }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -3306,24 +3310,21 @@ } } }, - "node_modules/@radix-ui/react-toast": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", - "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", "license": "MIT", "dependencies": { + "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3" + "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -3340,7 +3341,7 @@ } } }, - "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -3363,7 +3364,7 @@ } } }, - "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -3381,15 +3382,33 @@ } } }, - "node_modules/@radix-ui/react-toggle": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", - "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", "license": "MIT", "dependencies": { + "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", @@ -3406,19 +3425,13 @@ } } }, - "node_modules/@radix-ui/react-toggle-group": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", - "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-toggle": "1.1.10", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -3435,13 +3448,31 @@ } } }, - "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.3" + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", @@ -3458,25 +3489,40 @@ } } }, - "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -3499,7 +3545,7 @@ } } }, - "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -3517,42 +3563,37 @@ } } }, - "node_modules/@radix-ui/react-toolbar": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz", - "integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==", + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-separator": "1.1.7", - "@radix-ui/react-toggle-group": "1.1.11" + "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true - }, - "@types/react-dom": { - "optional": true } } }, - "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.3" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -3569,13 +3610,13 @@ } } }, - "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-separator": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", - "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -3592,7 +3633,7 @@ } } }, - "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -3610,24 +3651,20 @@ } } }, - "node_modules/@radix-ui/react-tooltip": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", - "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-visually-hidden": "1.2.3" + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -3644,7 +3681,7 @@ } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -3667,7 +3704,7 @@ } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -3685,44 +3722,70 @@ } } }, - "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", - "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", - "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", - "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -3734,58 +3797,91 @@ } } }, - "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", - "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "license": "MIT", "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.1" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-use-is-hydrated": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", - "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", + "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", + "license": "MIT", "dependencies": { - "use-sync-external-store": "^1.5.0" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", - "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-use-previous": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", - "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -3796,30 +3892,36 @@ } } }, - "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", - "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "license": "MIT", "dependencies": { - "@radix-ui/rect": "1.1.1" + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-use-size": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", - "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -3831,13 +3933,19 @@ } } }, - "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", - "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz", + "integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-toggle-group": "1.1.11" }, "peerDependencies": { "@types/react": "*", @@ -3854,7 +3962,7 @@ } } }, - "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -3877,7 +3985,30 @@ } } }, - "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -3895,214 +4026,403 @@ } } }, - "node_modules/@radix-ui/rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", - "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", - "license": "MIT" - }, - "node_modules/@reduxjs/toolkit": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", - "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^11.0.0", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { - "react": { + "@types/react": { "optional": true }, - "react-redux": { + "@types/react-dom": { "optional": true } } }, - "node_modules/@reduxjs/toolkit/node_modules/immer": { - "version": "11.1.7", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.7.tgz", - "integrity": "sha512-LFVFtAROHcDy1er5UI6nodRFnZ2SgdCXhfNSI+DpObO8N7Pur/muBGsjzH5wpnFHCYhYVQxZskCkV4koQ//3/Q==", + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", - "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", - "cpu": [ - "arm64" - ], + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", - "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", - "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", - "cpu": [ - "x64" - ], + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", - "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", - "cpu": [ - "x64" - ], + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", - "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", - "cpu": [ - "arm" - ], + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", - "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", - "cpu": [ - "arm64" - ], + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.7", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.7.tgz", + "integrity": "sha512-LFVFtAROHcDy1er5UI6nodRFnZ2SgdCXhfNSI+DpObO8N7Pur/muBGsjzH5wpnFHCYhYVQxZskCkV4koQ//3/Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" } }, - "node_modules/@rolldown/binding-linux-arm64-musl": { + "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", - "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", + "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", "cpu": [ "arm64" ], "license": "MIT", "optional": true, "os": [ - "linux" + "android" ], "engines": { "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "node_modules/@rolldown/binding-darwin-arm64": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", - "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", "cpu": [ - "ppc64" + "arm64" ], "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { + "node_modules/@rolldown/binding-darwin-x64": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", - "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", + "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", "cpu": [ - "s390x" + "x64" ], "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-x64-gnu": { + "node_modules/@rolldown/binding-freebsd-x64": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", - "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", "cpu": [ "x64" ], "license": "MIT", "optional": true, "os": [ - "linux" + "freebsd" ], "engines": { "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-x64-musl": { + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", - "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", "cpu": [ - "x64" + "arm" ], "license": "MIT", "optional": true, @@ -4113,230 +4433,60 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-openharmony-arm64": { + "node_modules/@rolldown/binding-linux-arm64-gnu": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", - "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", "cpu": [ "arm64" ], "license": "MIT", "optional": true, "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", - "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", - "cpu": [ - "wasm32" + "linux" ], - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { + "node_modules/@rolldown/binding-linux-arm64-musl": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", - "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", "cpu": [ "arm64" ], "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ], "engines": { "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-win32-x64-msvc": { + "node_modules/@rolldown/binding-linux-ppc64-gnu": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", - "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", "cpu": [ - "x64" + "ppc64" ], "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ], "engines": { "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/pluginutils": { + "node_modules/@rolldown/binding-linux-s390x-gnu": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "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==", - "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": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", - "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", - "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", - "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", "cpu": [ - "arm64" + "s390x" ], "license": "MIT", "optional": true, @@ -4344,13 +4494,13 @@ "linux" ], "engines": { - "node": ">= 20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", - "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", "cpu": [ "x64" ], @@ -4360,13 +4510,13 @@ "linux" ], "engines": { - "node": ">= 20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", - "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", "cpu": [ "x64" ], @@ -4376,42 +4526,47 @@ "linux" ], "engines": { - "node": ">= 20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", - "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", + "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", "cpu": [ "wasm32" ], "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.10.0", - "@emnapi/runtime": "^1.10.0", - "@emnapi/wasi-threads": "^1.2.1", - "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.8.1" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", - "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", "cpu": [ "arm64" ], @@ -4421,13 +4576,13 @@ "win32" ], "engines": { - "node": ">= 20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", - "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", "cpu": [ "x64" ], @@ -4437,4008 +4592,3853 @@ "win32" ], "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/vite": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", - "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", - "license": "MIT", - "dependencies": { - "@tailwindcss/node": "4.3.0", - "@tailwindcss/oxide": "4.3.0", - "tailwindcss": "4.3.0" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7 || ^8" - } - }, - "node_modules/@tanstack/eslint-plugin-query": { - "version": "5.100.10", - "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.100.10.tgz", - "integrity": "sha512-Ddou3agTWv5rvHSBby4yHlugUFHVh0nyo2fyoZ81qSxaTBIwNCoPgpiJhjo5QkThrH1wGC7k548BcMTszZCkBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/utils": "^8.58.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": "^5.4.0 || ^6.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@tanstack/query-async-storage-persister": { - "version": "5.100.10", - "resolved": "https://registry.npmjs.org/@tanstack/query-async-storage-persister/-/query-async-storage-persister-5.100.10.tgz", - "integrity": "sha512-NbxsCI9qyMbEooFnFsAR2dbVQ6cmxZpOWSjLFy62qfMkxXnNjkskqex7YgtzWSaD/ozOOLgrbDiyYWEPYKmMyQ==", - "license": "MIT", - "dependencies": { - "@tanstack/query-core": "5.100.10", - "@tanstack/query-persist-client-core": "5.100.10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tanstack/query-core": { - "version": "5.100.10", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz", - "integrity": "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" }, - "node_modules/@tanstack/query-persist-client-core": { - "version": "5.100.10", - "resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.100.10.tgz", - "integrity": "sha512-O9Pey40DhTTDBABS0bHr+KNL5/VMf6PrqjexS8WoDDtnkaoWM+y0MSe0V9E5W+BwvkjM33mB3aYcCxa175gZTQ==", - "license": "MIT", - "dependencies": { - "@tanstack/query-core": "5.100.10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } + "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/@tanstack/react-query": { - "version": "5.100.10", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.10.tgz", - "integrity": "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==", - "license": "MIT", - "dependencies": { - "@tanstack/query-core": "5.100.10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^18 || ^19" - } + "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/@tanstack/react-query-persist-client": { - "version": "5.100.10", - "resolved": "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-5.100.10.tgz", - "integrity": "sha512-EImacngLXYEtzlrIPf8IAqKN3foS7cmSj4GWqsHJvc7K+8fy2c3s7mdV8oTJeii/TvrzO4X9fcnXi6tUHMIOHA==", + "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": { - "@tanstack/query-persist-client-core": "5.100.10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "@tanstack/react-query": "^5.100.10", - "react": "^18 || ^19" - } - }, - "node_modules/@tanstack/react-table": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", - "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", - "dependencies": { - "@tanstack/table-core": "8.21.3" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" - } - }, - "node_modules/@tanstack/table-core": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", - "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tauri-apps/api": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz", - "integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==", - "license": "Apache-2.0 OR MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/tauri" - } - }, - "node_modules/@tauri-apps/cli": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.7.1.tgz", - "integrity": "sha512-RcGWR4jOUEl92w3uvI0h61Llkfj9lwGD1iwvDRD2isMrDhOzjeeeVn9aGzeW1jubQ/kAbMYfydcA4BA0Cy733Q==", - "dev": true, - "bin": { - "tauri": "tauri.js" - }, + "@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": ">= 10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/tauri" + "node": ">= 20" }, "optionalDependencies": { - "@tauri-apps/cli-darwin-arm64": "2.7.1", - "@tauri-apps/cli-darwin-x64": "2.7.1", - "@tauri-apps/cli-linux-arm-gnueabihf": "2.7.1", - "@tauri-apps/cli-linux-arm64-gnu": "2.7.1", - "@tauri-apps/cli-linux-arm64-musl": "2.7.1", - "@tauri-apps/cli-linux-riscv64-gnu": "2.7.1", - "@tauri-apps/cli-linux-x64-gnu": "2.7.1", - "@tauri-apps/cli-linux-x64-musl": "2.7.1", - "@tauri-apps/cli-win32-arm64-msvc": "2.7.1", - "@tauri-apps/cli-win32-ia32-msvc": "2.7.1", - "@tauri-apps/cli-win32-x64-msvc": "2.7.1" + "@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/@tauri-apps/cli-darwin-arm64": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.7.1.tgz", - "integrity": "sha512-j2NXQN6+08G03xYiyKDKqbCV2Txt+hUKg0a8hYr92AmoCU8fgCjHyva/p16lGFGUG3P2Yu0xiNe1hXL9ZuRMzA==", + "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" ], - "dev": true, + "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": ">= 10" + "node": ">= 20" } }, - "node_modules/@tauri-apps/cli-darwin-x64": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.7.1.tgz", - "integrity": "sha512-CdYAefeM35zKsc91qIyKzbaO7FhzTyWKsE8hj7tEJ1INYpoh1NeNNyL/NSEA3Nebi5ilugioJ5tRK8ZXG8y3gw==", + "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" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, - "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.7.1.tgz", - "integrity": "sha512-dnvyJrTA1UJxJjQ8q1N/gWomjP8Twij1BUQu2fdcT3OPpqlrbOk5R1yT0oD/721xoKNjroB5BXCsmmlykllxNg==", + "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": [ - "arm" + "x64" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" + "freebsd" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, - "node_modules/@tauri-apps/cli-linux-arm64-gnu": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.7.1.tgz", - "integrity": "sha512-FtBW6LJPNRTws3qyUc294AqCWU91l/H0SsFKq6q4Q45MSS4x6wxLxou8zB53tLDGEPx3JSoPLcDaSfPlSbyujQ==", + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", "cpu": [ - "arm64" + "arm" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, - "node_modules/@tauri-apps/cli-linux-arm64-musl": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.7.1.tgz", - "integrity": "sha512-/HXY0t4FHkpFzjeYS5c16mlA6z0kzn5uKLWptTLTdFSnYpr8FCnOP4Sdkvm2TDQPF2ERxXtNCd+WR/jQugbGnA==", + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", "cpu": [ "arm64" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, - "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.7.1.tgz", - "integrity": "sha512-GeW5lVI2GhhnaYckiDzstG2j2Jwlud5d2XefRGwlOK+C/bVGLT1le8MNPYK8wgRlpeK8fG1WnJJYD6Ke7YQ8bg==", + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", "cpu": [ - "riscv64" + "arm64" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, - "node_modules/@tauri-apps/cli-linux-x64-gnu": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.7.1.tgz", - "integrity": "sha512-DprxKQkPxIPYwUgg+cscpv2lcIUhn2nxEPlk0UeaiV9vATxCXyytxr1gLcj3xgjGyNPlM0MlJyYaPy1JmRg1cA==", + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", "cpu": [ "x64" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, - "node_modules/@tauri-apps/cli-linux-x64-musl": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.7.1.tgz", - "integrity": "sha512-KLlq3kOK7OUyDR757c0zQjPULpGZpLhNB0lZmZpHXvoOUcqZoCXJHh4dT/mryWZJp5ilrem5l8o9ngrDo0X1AA==", + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", "cpu": [ "x64" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, - "node_modules/@tauri-apps/cli-win32-arm64-msvc": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.7.1.tgz", - "integrity": "sha512-dH7KUjKkSypCeWPiainHyXoES3obS+JIZVoSwSZfKq2gWgs48FY3oT0hQNYrWveE+VR4VoR3b/F3CPGbgFvksA==", + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], "cpu": [ - "arm64" + "wasm32" ], - "dev": true, + "license": "MIT", "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, "engines": { - "node": ">= 10" + "node": ">=14.0.0" } }, - "node_modules/@tauri-apps/cli-win32-ia32-msvc": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.7.1.tgz", - "integrity": "sha512-1oeibfyWQPVcijOrTg709qhbXArjX3x1MPjrmA5anlygwrbByxLBcLXvotcOeULFcnH2FYUMMLLant8kgvwE5A==", + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", "cpu": [ - "ia32" + "arm64" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, - "node_modules/@tauri-apps/cli-win32-x64-msvc": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.7.1.tgz", - "integrity": "sha512-D7Q9kDObutuirCNLxYQ7KAg2Xxg99AjcdYz/KuMw5HvyEPbkC9Q7JL0vOrQOrHEHxIQ2lYzFOZvKKoC2yyqXcg==", + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", "cpu": [ "x64" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/plugin-dialog": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", - "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.11.0" - } - }, - "node_modules/@tauri-apps/plugin-fs": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz", - "integrity": "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.11.0" - } - }, - "node_modules/@tauri-apps/plugin-opener": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", - "integrity": "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.11.0" + "node": ">= 20" } }, - "node_modules/@tauri-apps/plugin-shell": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", - "integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==", - "license": "MIT OR Apache-2.0", + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "license": "MIT", "dependencies": { - "@tauri-apps/api": "^2.10.1" + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "node_modules/@tanstack/eslint-plugin-query": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.100.10.tgz", + "integrity": "sha512-Ddou3agTWv5rvHSBby4yHlugUFHVh0nyo2fyoZ81qSxaTBIwNCoPgpiJhjo5QkThrH1wGC7k548BcMTszZCkBw==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "tslib": "^2.4.0" + "@typescript-eslint/utils": "^8.58.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "^5.4.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@types/d3-array": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", - "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", - "license": "MIT" - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT" - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "license": "MIT" - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "node_modules/@tanstack/query-async-storage-persister": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/query-async-storage-persister/-/query-async-storage-persister-5.100.10.tgz", + "integrity": "sha512-NbxsCI9qyMbEooFnFsAR2dbVQ6cmxZpOWSjLFy62qfMkxXnNjkskqex7YgtzWSaD/ozOOLgrbDiyYWEPYKmMyQ==", "license": "MIT", "dependencies": { - "@types/d3-color": "*" + "@tanstack/query-core": "5.100.10", + "@tanstack/query-persist-client-core": "5.100.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@types/d3-path": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "license": "MIT" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "node_modules/@tanstack/query-core": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz", + "integrity": "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==", "license": "MIT", - "dependencies": { - "@types/d3-time": "*" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@types/d3-shape": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", - "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "node_modules/@tanstack/query-persist-client-core": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.100.10.tgz", + "integrity": "sha512-O9Pey40DhTTDBABS0bHr+KNL5/VMf6PrqjexS8WoDDtnkaoWM+y0MSe0V9E5W+BwvkjM33mB3aYcCxa175gZTQ==", "license": "MIT", "dependencies": { - "@types/d3-path": "*" + "@tanstack/query-core": "5.100.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@types/d3-time": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "license": "MIT" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "license": "MIT" - }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true - }, - "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "node_modules/@tanstack/react-query": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.10.tgz", + "integrity": "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==", "license": "MIT", "dependencies": { - "@types/unist": "*" + "@tanstack/query-core": "5.100.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" } }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "25.8.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", - "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", - "devOptional": true, + "node_modules/@tanstack/react-query-persist-client": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-5.100.10.tgz", + "integrity": "sha512-EImacngLXYEtzlrIPf8IAqKN3foS7cmSj4GWqsHJvc7K+8fy2c3s7mdV8oTJeii/TvrzO4X9fcnXi6tUHMIOHA==", "license": "MIT", "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" + "@tanstack/query-persist-client-core": "5.100.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.100.10", + "react": "^18 || ^19" } }, - "node_modules/@types/node/node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "devOptional": true, - "license": "MIT" + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } }, - "node_modules/@types/prismjs": { - "version": "1.26.5", - "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz", - "integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==" + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } }, - "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" + "node_modules/@tauri-apps/api": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz", + "integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" } }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" + "node_modules/@tauri-apps/cli": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.7.1.tgz", + "integrity": "sha512-RcGWR4jOUEl92w3uvI0h61Llkfj9lwGD1iwvDRD2isMrDhOzjeeeVn9aGzeW1jubQ/kAbMYfydcA4BA0Cy733Q==", + "dev": true, + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.7.1", + "@tauri-apps/cli-darwin-x64": "2.7.1", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.7.1", + "@tauri-apps/cli-linux-arm64-gnu": "2.7.1", + "@tauri-apps/cli-linux-arm64-musl": "2.7.1", + "@tauri-apps/cli-linux-riscv64-gnu": "2.7.1", + "@tauri-apps/cli-linux-x64-gnu": "2.7.1", + "@tauri-apps/cli-linux-x64-musl": "2.7.1", + "@tauri-apps/cli-win32-arm64-msvc": "2.7.1", + "@tauri-apps/cli-win32-ia32-msvc": "2.7.1", + "@tauri-apps/cli-win32-x64-msvc": "2.7.1" } }, - "node_modules/@types/react-syntax-highlighter": { - "version": "15.5.13", - "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", - "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==", + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.7.1.tgz", + "integrity": "sha512-j2NXQN6+08G03xYiyKDKqbCV2Txt+hUKg0a8hYr92AmoCU8fgCjHyva/p16lGFGUG3P2Yu0xiNe1hXL9ZuRMzA==", + "cpu": [ + "arm64" + ], "dev": true, - "dependencies": { - "@types/react": "*" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" } }, - "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT" - }, - "node_modules/@types/use-sync-external-store": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", - "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", - "license": "MIT" + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.7.1.tgz", + "integrity": "sha512-CdYAefeM35zKsc91qIyKzbaO7FhzTyWKsE8hj7tEJ1INYpoh1NeNNyL/NSEA3Nebi5ilugioJ5tRK8ZXG8y3gw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } }, - "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", - "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.7.1.tgz", + "integrity": "sha512-dnvyJrTA1UJxJjQ8q1N/gWomjP8Twij1BUQu2fdcT3OPpqlrbOk5R1yT0oD/721xoKNjroB5BXCsmmlykllxNg==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.3", - "@typescript-eslint/type-utils": "8.59.3", - "@typescript-eslint/utils": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.59.3", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "node": ">= 10" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.7.1.tgz", + "integrity": "sha512-FtBW6LJPNRTws3qyUc294AqCWU91l/H0SsFKq6q4Q45MSS4x6wxLxou8zB53tLDGEPx3JSoPLcDaSfPlSbyujQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 4" + "node": ">= 10" } }, - "node_modules/@typescript-eslint/parser": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz", - "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.7.1.tgz", + "integrity": "sha512-/HXY0t4FHkpFzjeYS5c16mlA6z0kzn5uKLWptTLTdFSnYpr8FCnOP4Sdkvm2TDQPF2ERxXtNCd+WR/jQugbGnA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.59.3", - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3", - "debug": "^4.4.3" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "node": ">= 10" } }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", - "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.7.1.tgz", + "integrity": "sha512-GeW5lVI2GhhnaYckiDzstG2j2Jwlud5d2XefRGwlOK+C/bVGLT1le8MNPYK8wgRlpeK8fG1WnJJYD6Ke7YQ8bg==", + "cpu": [ + "riscv64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.3", - "@typescript-eslint/types": "^8.59.3", - "debug": "^4.4.3" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "node": ">= 10" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", - "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.7.1.tgz", + "integrity": "sha512-DprxKQkPxIPYwUgg+cscpv2lcIUhn2nxEPlk0UeaiV9vATxCXyytxr1gLcj3xgjGyNPlM0MlJyYaPy1JmRg1cA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">= 10" } }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", - "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.7.1.tgz", + "integrity": "sha512-KLlq3kOK7OUyDR757c0zQjPULpGZpLhNB0lZmZpHXvoOUcqZoCXJHh4dT/mryWZJp5ilrem5l8o9ngrDo0X1AA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "node": ">= 10" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", - "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.7.1.tgz", + "integrity": "sha512-dH7KUjKkSypCeWPiainHyXoES3obS+JIZVoSwSZfKq2gWgs48FY3oT0hQNYrWveE+VR4VoR3b/F3CPGbgFvksA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3", - "@typescript-eslint/utils": "8.59.3", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.7.1.tgz", + "integrity": "sha512-1oeibfyWQPVcijOrTg709qhbXArjX3x1MPjrmA5anlygwrbByxLBcLXvotcOeULFcnH2FYUMMLLant8kgvwE5A==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" } }, - "node_modules/@typescript-eslint/types": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", - "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.7.1.tgz", + "integrity": "sha512-D7Q9kDObutuirCNLxYQ7KAg2Xxg99AjcdYz/KuMw5HvyEPbkC9Q7JL0vOrQOrHEHxIQ2lYzFOZvKKoC2yyqXcg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">= 10" } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", - "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", - "dev": true, - "license": "MIT", + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", + "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==", + "license": "MIT OR Apache-2.0", "dependencies": { - "@typescript-eslint/project-service": "8.59.3", - "@typescript-eslint/tsconfig-utils": "8.59.3", - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "@tauri-apps/api": "^2.11.0" } }, - "node_modules/@typescript-eslint/utils": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", - "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", - "dev": true, - "license": "MIT", + "node_modules/@tauri-apps/plugin-fs": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz", + "integrity": "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==", + "license": "MIT OR Apache-2.0", "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.3", - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "@tauri-apps/api": "^2.11.0" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", - "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", - "dev": true, - "license": "MIT", + "node_modules/@tauri-apps/plugin-opener": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", + "integrity": "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==", + "license": "MIT OR Apache-2.0", "dependencies": { - "@typescript-eslint/types": "8.59.3", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "@tauri-apps/api": "^2.11.0" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node_modules/@tauri-apps/plugin-shell": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", + "integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" } }, - "node_modules/@vitejs/plugin-react": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", - "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", - "dev": true, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "license": "MIT", + "optional": true, "dependencies": { - "@rolldown/pluginutils": "^1.0.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "vite": "^8.0.0" - }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - } + "tslib": "^2.4.0" } }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" + "dependencies": { + "@types/d3-color": "*" } }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + "dependencies": { + "@types/d3-time": "*" } }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "@types/d3-path": "*" } }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", "license": "MIT", "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "@types/unist": "*" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, - "engines": { - "node": ">=8" - } + "license": "MIT" }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, + "node_modules/@types/node": { + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", + "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", + "devOptional": true, + "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "undici-types": ">=7.24.0 <7.24.7" } }, - "node_modules/aria-hidden": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", - "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - } + "node_modules/@types/node/node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "devOptional": true, + "license": "MIT" }, - "node_modules/auto-bind": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", - "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "node_modules/@types/prismjs": { + "version": "1.26.5", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz", + "integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "dependencies": { + "csstype": "^3.2.2" } }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" + "peerDependencies": { + "@types/react": "^19.2.0" } }, - "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "node_modules/@types/react-syntax-highlighter": { + "version": "15.5.13", + "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", + "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==", "dev": true, - "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" + "@types/react": "*" } }, - "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/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "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", + "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", "dev": true, + "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/type-utils": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": ">=10" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "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" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, - "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" + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.3", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "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==", + "node_modules/@typescript-eslint/eslint-plugin/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", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "engines": { + "node": ">= 4" } }, - "node_modules/character-reference-invalid": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", - "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "node_modules/@typescript-eslint/parser": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz", + "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", + "dev": true, "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/class-variance-authority": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", - "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", "dependencies": { - "clsx": "^2.1.1" + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "debug": "^4.4.3" }, - "funding": { - "url": "https://polar.sh/cva" - } - }, - "node_modules/cli-boxes": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz", - "integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==", - "license": "MIT", "engines": { - "node": ">=18.20 <19 || >=20.10" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", + "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", + "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.3", + "@typescript-eslint/types": "^8.59.3", + "debug": "^4.4.3" + }, "engines": { - "node": ">=6" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/cli-truncate": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.0.0.tgz", - "integrity": "sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", + "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", + "dev": true, "license": "MIT", "dependencies": { - "slice-ansi": "^9.0.0", - "string-width": "^8.2.0" + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3" }, "engines": { - "node": ">=22" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/cli-truncate/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", + "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", + "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/cli-truncate/node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", + "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", + "dev": true, "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": ">=20" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/cli-truncate/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==", + "node_modules/@typescript-eslint/types": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", + "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", + "dev": true, "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, "engines": { - "node": ">=12" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", + "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", "dev": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/code-excerpt": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", - "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", "license": "MIT", "dependencies": { - "convert-to-spaces": "^2.0.1" + "@typescript-eslint/project-service": "8.59.3", + "@typescript-eslint/tsconfig-utils": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@typescript-eslint/utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", + "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", "dev": true, + "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3" }, "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "license": "MIT", + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "license": "MIT", - "engines": { - "node": ">=18" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", + "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "4.1.2", - "rxjs": "7.8.2", - "shell-quote": "1.8.3", - "supports-color": "8.1.1", - "tree-kill": "1.2.2", - "yargs": "17.7.2" - }, - "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" + "@typescript-eslint/types": "8.59.3", + "eslint-visitor-keys": "^5.0.0" }, "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" - } - }, - "node_modules/convert-to-spaces": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", - "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", "dev": true, + "license": "MIT", "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "@rolldown/pluginutils": "^1.0.0" }, "engines": { - "node": ">= 8" + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" }, "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" + "node": ">=0.4.0" } }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/d3-format": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", - "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", - "license": "ISC", - "engines": { - "node": ">=12" + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", "dependencies": { - "d3-color": "1 - 3" + "environment": "^1.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "license": "ISC", + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "engines": { - "node": ">=12" + "node": ">=8" } }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "license": "ISC", + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" + "color-convert": "^2.0.1" }, "engines": { - "node": ">=12" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "license": "ISC", + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", "dependencies": { - "d3-path": "^3.1.0" + "tslib": "^2.0.0" }, "engines": { - "node": ">=12" + "node": ">=10" } }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2 - 3" + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=12" + "node": "18 || 20 || >=22" } }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "license": "ISC", + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", "dependencies": { - "d3-time": "1 - 3" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=12" + "node": "18 || 20 || >=22" } }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", + "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": ">=12" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "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, - "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "has-flag": "^4.0.0" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=8" } }, - "node_modules/decimal.js-light": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", - "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", - "license": "MIT" - }, - "node_modules/decode-named-character-reference": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", - "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "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", - "dependencies": { - "character-entities": "^2.0.0" - }, "funding": { "type": "github", "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", - "engines": { - "node": ">=8" + "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" } }, - "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/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/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/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" + "clsx": "^2.1.1" }, - "engines": { - "node": ">=10.13.0" + "funding": { + "url": "https://polar.sh/cva" } }, - "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/cli-boxes": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz", + "integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=18.20 <19 || >=20.10" }, "funding": { "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/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", "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, + "node_modules/cli-truncate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.0.0.tgz", + "integrity": "sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA==", "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.5.5", - "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.2", - "eslint-visitor-keys": "^5.0.1", - "espree": "^11.2.0", - "esquery": "^1.7.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" + "slice-ansi": "^9.0.0", + "string-width": "^8.2.0" }, - "bin": { - "eslint": "bin/eslint.js" + "engines": { + "node": ">=22" }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=12" }, "funding": { - "url": "https://eslint.org/donate" + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, - "peerDependencies": { - "jiti": "*" + "engines": { + "node": ">=20" }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint-scope": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", - "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", - "dev": true, - "license": "BSD-2-Clause", + "node_modules/cli-truncate/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==", + "license": "MIT", "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" + "ansi-regex": "^6.2.2" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=12" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, - "license": "Apache-2.0", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=12" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" }, - "funding": { - "url": "https://opencollective.com/eslint" + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "dependencies": { + "color-name": "~1.1.4" }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", "funding": { - "url": "https://opencollective.com/eslint" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/espree": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", - "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "acorn": "^8.16.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=18" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=18" } }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { - "estraverse": "^5.1.0" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, "engines": { - "node": ">=0.10" + "node": ">= 8" } }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", "dependencies": { - "estraverse": "^5.2.0" + "internmap": "1 - 2" }, "engines": { - "node": ">=4.0" + "node": ">=12" } }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", "engines": { - "node": ">=4.0" + "node": ">=12" } }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "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", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } }, - "node_modules/fault": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", - "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", - "license": "MIT", + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", "dependencies": { - "format": "^0.2.0" + "d3-color": "1 - 3" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "engines": { + "node": ">=12" } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "license": "MIT", + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "node": ">=12" } }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", "dependencies": { - "flat-cache": "^4.0.0" + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" }, "engines": { - "node": ">=16.0.0" + "node": ">=12" } }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" + "d3-path": "^3.1.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" + "d3-array": "2 - 3" }, "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, - "node_modules/format": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", - "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", - "engines": { - "node": ">=0.4.x" - } - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "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==", - "dev": true, - "engines": { - "node": "6.* || 8.* || >= 10.*" + "node": ">=12" } }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" } }, - "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", "engines": { - "node": ">=6" + "node": ">=12" } }, - "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", - "devOptional": true, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { - "resolve-pkg-maps": "^1.0.0" + "ms": "^2.1.3" }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/gif.js": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/gif.js/-/gif.js-0.2.0.tgz", - "integrity": "sha512-bYxCoT8OZKmbxY8RN4qDiYuj4nrQDTzgLRcFVovyona1PTWNePzI4nzOmotnlOFIzTk/ZxAHtv+TfVLiBWj/hw==" - }, - "node_modules/gif.js.optimized": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gif.js.optimized/-/gif.js.optimized-1.0.1.tgz", - "integrity": "sha512-IS0F42Xken6lp/iR4irgG4r52tvxRkEKsXGZmlUHUOb00SWNMezJOJwkVaJk2MLW53rqzMbPnnBtEhs9hcMJ9w==" + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", "dependencies": { - "is-glob": "^4.0.3" + "character-entities": "^2.0.0" }, - "engines": { - "node": ">=10.13.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" + "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/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "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": ">=8" } }, - "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", - "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "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/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/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": { - "@types/hast": "^3.0.0" + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": ">=10.13.0" } }, - "node_modules/hastscript": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", - "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "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", - "dependencies": { - "@types/hast": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-parse-selector": "^4.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0" + "engines": { + "node": ">=18" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/highlight.js": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", - "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", - "license": "BSD-3-Clause", + "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": "*" + "node": ">=6" } }, - "node_modules/highlightjs-vue": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", - "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", - "license": "CC0-1.0" - }, - "node_modules/husky": { - "version": "9.1.7", - "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", - "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "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, - "bin": { - "husky": "bin.js" - }, "engines": { - "node": ">=18" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/typicode" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "node_modules/eslint": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", + "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", "dev": true, "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, "engines": { - "node": ">= 4" - } - }, - "node_modules/immer": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", - "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", - "license": "MIT", + "node": "^20.19.0 || ^22.13.0 || >=24" + }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/indent-string": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", - "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", - "license": "MIT", - "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/eslint" } }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=12" - } - }, - "node_modules/is-alphabetical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", - "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-alphanumerical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", - "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", - "license": "MIT", - "dependencies": { - "is-alphabetical": "^2.0.0", - "is-decimal": "^2.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://opencollective.com/eslint" } }, - "node_modules/is-decimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", - "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", - "license": "MIT", + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://opencollective.com/eslint" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, "engines": { - "node": ">=0.10.0" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=8" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "is-extglob": "^2.1.1" + "estraverse": "^5.1.0" }, "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-hexadecimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", - "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "node": ">=0.10" } }, - "node_modules/is-in-ci": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", - "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", - "license": "MIT", - "bin": { - "is-in-ci": "cli.js" + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" }, "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=4.0" } }, - "node_modules/is-interactive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", - "license": "MIT", + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=4.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", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "license": "MIT", + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10.0" } }, - "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/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" }, - "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/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "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", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, "license": "MIT" }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, + "node_modules/fault": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", + "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", + "license": "MIT", "dependencies": { - "json-buffer": "3.0.1" + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" + "flat-cache": "^4.0.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">=16.0.0" } }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "license": "MPL-2.0", + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, "dependencies": { - "detect-libc": "^2.0.3" + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" }, "engines": { - "node": ">= 12.0.0" + "node": ">=10" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "engines": { + "node": ">=16" } }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">=0.4.x" } }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], + "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": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", "engines": { - "node": ">= 12.0.0" + "node": ">=18" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", "engines": { - "node": ">= 12.0.0" + "node": ">=6" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" + "node_modules/gif.js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/gif.js/-/gif.js-0.2.0.tgz", + "integrity": "sha512-bYxCoT8OZKmbxY8RN4qDiYuj4nrQDTzgLRcFVovyona1PTWNePzI4nzOmotnlOFIzTk/ZxAHtv+TfVLiBWj/hw==" + }, + "node_modules/gif.js.optimized": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gif.js.optimized/-/gif.js.optimized-1.0.1.tgz", + "integrity": "sha512-IS0F42Xken6lp/iR4irgG4r52tvxRkEKsXGZmlUHUOb00SWNMezJOJwkVaJk2MLW53rqzMbPnnBtEhs9hcMJ9w==" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "engines": { + "node": ">=10.13.0" } }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">=8" } }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" + "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", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://opencollective.com/unified" } }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://opencollective.com/unified" } }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "*" } }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "node_modules/highlightjs-vue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", + "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", + "license": "CC0-1.0" + }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", "dev": true, - "dependencies": { - "p-locate": "^5.0.0" + "bin": { + "husky": "bin.js" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/typicode" } }, - "node_modules/log-symbols": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", - "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "is-unicode-supported": "^1.3.0" - }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 4" } }, - "node_modules/log-symbols/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==", + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/immer" } }, - "node_modules/log-symbols/node_modules/is-unicode-supported": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", - "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", - "license": "MIT", + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.8.19" } }, - "node_modules/lowlight": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", - "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", "license": "MIT", - "dependencies": { - "fault": "^1.0.0", - "highlight.js": "~10.7.0" + "engines": { + "node": ">=12" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lucide-react": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz", - "integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==", + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "engines": { + "node": ">=12" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "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/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true - }, - "node_modules/next-themes": { - "version": "0.4.6", - "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", - "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" } }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, "engines": { - "node": ">= 0.8.0" + "node": ">=8" } }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "dependencies": { - "yocto-queue": "^0.1.0" + "is-extglob": "^2.1.1" }, "engines": { - "node": ">=10" - }, + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "dependencies": { - "p-limit": "^3.0.2" + "node_modules/is-in-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", + "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" }, "engines": { - "node": ">=10" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse-entities": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", - "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0", - "character-entities-legacy": "^3.0.0", - "character-reference-invalid": "^2.0.0", - "decode-named-character-reference": "^1.0.0", - "is-alphanumerical": "^2.0.0", - "is-decimal": "^2.0.0", - "is-hexadecimal": "^2.0.0" + "engines": { + "node": ">=12" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse-entities/node_modules/@types/unist": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", - "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", - "license": "MIT" - }, - "node_modules/patch-console": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", - "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", "license": "MIT", "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" + "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/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "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", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, - "engines": { - "node": ">=8" - } + "license": "MIT" }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" } }, - "node_modules/playwright": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", - "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, - "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.60.0" + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" }, - "bin": { - "playwright": "cli.js" + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" }, "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "fsevents": "2.3.2" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, - "node_modules/playwright-core": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", - "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, "engines": { - "node": "^10 || ^12 || >=14" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 0.8.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/prism-react-renderer": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz", - "integrity": "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==", - "dependencies": { - "@types/prismjs": "^1.26.0", - "clsx": "^2.0.0" + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" }, - "peerDependencies": { - "react": ">=16.0.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/prismjs": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", - "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", - "license": "MIT", + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/property-information": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", - "license": "MIT", + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/radix-ui": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", - "integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-accessible-icon": "1.1.7", - "@radix-ui/react-accordion": "1.2.12", - "@radix-ui/react-alert-dialog": "1.1.15", - "@radix-ui/react-arrow": "1.1.7", - "@radix-ui/react-aspect-ratio": "1.1.7", - "@radix-ui/react-avatar": "1.1.10", - "@radix-ui/react-checkbox": "1.3.3", - "@radix-ui/react-collapsible": "1.1.12", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-context-menu": "2.2.16", - "@radix-ui/react-dialog": "1.1.15", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-dropdown-menu": "2.1.16", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-form": "0.1.8", - "@radix-ui/react-hover-card": "1.1.15", - "@radix-ui/react-label": "2.1.7", - "@radix-ui/react-menu": "2.1.16", - "@radix-ui/react-menubar": "1.1.16", - "@radix-ui/react-navigation-menu": "1.2.14", - "@radix-ui/react-one-time-password-field": "0.1.8", - "@radix-ui/react-password-toggle-field": "0.1.3", - "@radix-ui/react-popover": "1.1.15", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-progress": "1.1.7", - "@radix-ui/react-radio-group": "1.3.8", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-scroll-area": "1.2.10", - "@radix-ui/react-select": "2.2.6", - "@radix-ui/react-separator": "1.1.7", - "@radix-ui/react-slider": "1.3.6", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-switch": "1.2.6", - "@radix-ui/react-tabs": "1.1.13", - "@radix-ui/react-toast": "1.2.15", - "@radix-ui/react-toggle": "1.1.10", - "@radix-ui/react-toggle-group": "1.1.11", - "@radix-ui/react-toolbar": "1.1.11", - "@radix-ui/react-tooltip": "1.2.8", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-escape-keydown": "1.1.1", - "@radix-ui/react-use-is-hydrated": "0.1.0", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/radix-ui/node_modules/@radix-ui/react-avatar": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", - "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-is-hydrated": "0.1.0", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/radix-ui/node_modules/@radix-ui/react-label": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", - "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/radix-ui/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, "dependencies": { - "@radix-ui/react-slot": "1.2.3" + "p-locate": "^5.0.0" }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "engines": { + "node": ">=10" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/radix-ui/node_modules/@radix-ui/react-separator": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", - "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "engines": { + "node": ">=18" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/radix-ui/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "node_modules/log-symbols/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==", "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/react-dom": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", - "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "node_modules/lowlight": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", + "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", "license": "MIT", "dependencies": { - "scheduler": "^0.27.0" + "fault": "^1.0.0", + "highlight.js": "~10.7.0" }, - "peerDependencies": { - "react": "^19.2.6" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "peer": true + "node_modules/lucide-react": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz", + "integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } }, - "node_modules/react-redux": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", - "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "license": "MIT", "dependencies": { - "@types/use-sync-external-store": "^0.0.6", - "use-sync-external-store": "^1.4.0" - }, - "peerDependencies": { - "@types/react": "^18.2.25 || ^19", - "react": "^18.0 || ^19", - "redux": "^5.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "redux": { - "optional": true - } + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/react-remove-scroll": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", - "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", - "dependencies": { - "react-remove-scroll-bar": "^2.3.7", - "react-style-singleton": "^2.2.3", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.3", - "use-sidecar": "^1.1.3" - }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "node": ">=6" } }, - "node_modules/react-remove-scroll-bar": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", - "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "react-style-singleton": "^2.2.2", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" + "brace-expansion": "^5.0.5" }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "engines": { + "node": "18 || 20 || >=22" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/react-resizable-panels": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.11.1.tgz", - "integrity": "sha512-kA4w58V6wYdRLm2rg9pzroZwGlqBLul1FjMP0J8kqTo3zSHtjeH+LXmZaldCo6+HWqs1e5hOcPoajKXdOze37Q==", + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", "license": "MIT", "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, - "node_modules/react-router": { - "version": "7.15.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz", - "integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==", - "license": "MIT", + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, "dependencies": { - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0" + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" }, "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } + "node": ">= 0.8.0" } }, - "node_modules/react-style-singleton": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", - "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "dependencies": { - "get-nonce": "^1.0.0", - "tslib": "^2.0.0" + "yocto-queue": "^0.1.0" }, "engines": { "node": ">=10" }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/react-syntax-highlighter": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", - "integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==", - "license": "MIT", + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, "dependencies": { - "@babel/runtime": "^7.28.4", - "highlight.js": "^10.4.1", - "highlightjs-vue": "^1.0.0", - "lowlight": "^1.17.0", - "prismjs": "^1.30.0", - "refractor": "^5.0.0" + "p-limit": "^3.0.2" }, "engines": { - "node": ">= 16.20.2" + "node": ">=10" }, - "peerDependencies": { - "react": ">= 0.14.0" - } - }, - "node_modules/react-virtuoso": { - "version": "4.18.7", - "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.18.7.tgz", - "integrity": "sha512-xNF5zDGEEIMB7cKwcen/pLig0YDf6OnfFrVgKFa7sHPf9fRem0CaLshyObbBcP88jzn0enavL39EgplgdyT21g==", - "license": "MIT", - "peerDependencies": { - "react": ">=16 || >=17 || >= 18 || >= 19", - "react-dom": ">=16 || >=17 || >= 18 || >=19" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/recharts": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", - "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", "license": "MIT", - "workspaces": [ - "www" - ], "dependencies": { - "@reduxjs/toolkit": "^1.9.0 || 2.x.x", - "clsx": "^2.1.1", - "decimal.js-light": "^2.5.1", - "es-toolkit": "^1.39.3", - "eventemitter3": "^5.0.1", - "immer": "^10.1.1", - "react-redux": "8.x.x || 9.x.x", - "reselect": "5.1.1", - "tiny-invariant": "^1.3.3", - "use-sync-external-store": "^1.2.2", - "victory-vendor": "^37.0.2" - }, - "engines": { - "node": ">=18" + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/redux": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, - "node_modules/redux-thunk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", "license": "MIT", - "peerDependencies": { - "redux": "^5.0.0" + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/refractor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", - "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/prismjs": "^1.0.0", - "hastscript": "^9.0.0", - "parse-entities": "^4.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, - "node_modules/reselect": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", - "license": "MIT" + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "devOptional": true, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "engines": { + "node": ">=12" + }, "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/rolldown": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", - "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", - "license": "MIT", + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@oxc-project/types": "=0.130.0", - "@rolldown/pluginutils": "^1.0.0" + "playwright-core": "1.60.0" }, "bin": { - "rolldown": "bin/cli.mjs" + "playwright": "cli.js" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.1", - "@rolldown/binding-darwin-arm64": "1.0.1", - "@rolldown/binding-darwin-x64": "1.0.1", - "@rolldown/binding-freebsd-x64": "1.0.1", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", - "@rolldown/binding-linux-arm64-gnu": "1.0.1", - "@rolldown/binding-linux-arm64-musl": "1.0.1", - "@rolldown/binding-linux-ppc64-gnu": "1.0.1", - "@rolldown/binding-linux-s390x-gnu": "1.0.1", - "@rolldown/binding-linux-x64-gnu": "1.0.1", - "@rolldown/binding-linux-x64-musl": "1.0.1", - "@rolldown/binding-openharmony-arm64": "1.0.1", - "@rolldown/binding-wasm32-wasi": "1.0.1", - "@rolldown/binding-win32-arm64-msvc": "1.0.1", - "@rolldown/binding-win32-x64-msvc": "1.0.1" - } - }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "dependencies": { - "tslib": "^2.1.0" + "fsevents": "2.3.2" } }, - "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/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", "dev": true, - "license": "ISC", + "license": "Apache-2.0", "bin": { - "semver": "bin/semver.js" + "playwright-core": "cli.js" }, "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/set-cookie-parser": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", - "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", "dependencies": { - "shebang-regex": "^3.0.0" + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { - "node": ">=8" + "node": "^10 || ^12 || >=14" } }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "engines": { - "node": ">=8" + "node": ">= 0.8.0" } }, - "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "node_modules/prism-react-renderer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz", + "integrity": "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==", + "dependencies": { + "@types/prismjs": "^1.26.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.0.0" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, + "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=6" + } + }, + "node_modules/radix-ui": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", + "integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-accessible-icon": "1.1.7", + "@radix-ui/react-accordion": "1.2.12", + "@radix-ui/react-alert-dialog": "1.1.15", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-aspect-ratio": "1.1.7", + "@radix-ui/react-avatar": "1.1.10", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-context-menu": "2.2.16", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-form": "0.1.8", + "@radix-ui/react-hover-card": "1.1.15", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-menubar": "1.1.16", + "@radix-ui/react-navigation-menu": "1.2.14", + "@radix-ui/react-one-time-password-field": "0.1.8", + "@radix-ui/react-password-toggle-field": "0.1.3", + "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-progress": "1.1.7", + "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-scroll-area": "1.2.10", + "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-slider": "1.3.6", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-switch": "1.2.6", + "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-toast": "1.2.15", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-toggle-group": "1.1.11", + "@radix-ui/react-toolbar": "1.1.11", + "@radix-ui/react-tooltip": "1.2.8", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-escape-keydown": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/slice-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", - "integrity": "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==", + "node_modules/radix-ui/node_modules/@radix-ui/react-avatar": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.3", - "is-fullwidth-code-point": "^5.1.0" + "@radix-ui/react-primitive": "2.1.3" }, - "engines": { - "node": ">=22" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "node_modules/radix-ui/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "license": "MIT", - "engines": { - "node": ">=12" + "dependencies": { + "@radix-ui/react-slot": "1.2.3" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "node_modules/radix-ui/node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.1" + "@radix-ui/react-primitive": "2.1.3" }, - "engines": { - "node": ">=18" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/sonner": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", - "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "node_modules/radix-ui/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, "peerDependencies": { - "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", - "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", "license": "MIT", "dependencies": { - "escape-string-regexp": "^2.0.0" + "scheduler": "^0.27.0" }, - "engines": { - "node": ">=10" + "peerDependencies": { + "react": "^19.2.6" } }, - "node_modules/stack-utils/node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "license": "MIT", - "engines": { - "node": ">=8" - } + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "peer": true }, - "node_modules/stdin-discarder": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", - "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } } }, - "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, + "node_modules/react-remove-scroll": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", + "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "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, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", "dependencies": { - "ansi-regex": "^5.0.1" + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "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, + "node_modules/react-resizable-panels": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.11.1.tgz", + "integrity": "sha512-kA4w58V6wYdRLm2rg9pzroZwGlqBLul1FjMP0J8kqTo3zSHtjeH+LXmZaldCo6+HWqs1e5hOcPoajKXdOze37Q==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-router": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz", + "integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", "dependencies": { - "has-flag": "^4.0.0" + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" }, "engines": { "node": ">=10" }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/tagged-tag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", - "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "node_modules/react-syntax-highlighter": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", + "integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==", "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "highlight.js": "^10.4.1", + "highlightjs-vue": "^1.0.0", + "lowlight": "^1.17.0", + "prismjs": "^1.30.0", + "refractor": "^5.0.0" + }, "engines": { - "node": ">=20" + "node": ">= 16.20.2" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "react": ">= 0.14.0" } }, - "node_modules/tailwind-merge": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", - "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "node_modules/react-virtuoso": { + "version": "4.18.7", + "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.18.7.tgz", + "integrity": "sha512-xNF5zDGEEIMB7cKwcen/pLig0YDf6OnfFrVgKFa7sHPf9fRem0CaLshyObbBcP88jzn0enavL39EgplgdyT21g==", "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" + "peerDependencies": { + "react": ">=16 || >=17 || >= 18 || >= 19", + "react-dom": ">=16 || >=17 || >= 18 || >=19" } }, - "node_modules/tailwindcss": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", - "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", "license": "MIT", - "engines": { - "node": ">=6" + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/terminal-size": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz", - "integrity": "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==", - "license": "MIT", "engines": { "node": ">=18" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" }, - "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/refractor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", + "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", "license": "MIT", "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" + "@types/hast": "^3.0.0", + "@types/prismjs": "^1.0.0", + "hastscript": "^9.0.0", + "parse-entities": "^4.0.0" }, "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "bin": { - "tree-kill": "cli.js" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true, - "license": "MIT", "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" + "node": ">=0.10.0" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" }, - "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "devOptional": true, "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rolldown": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", + "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" + "@oxc-project/types": "=0.130.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { - "tsx": "dist/cli.mjs" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/tsx/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/tsx/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/tsx/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" + "@rolldown/binding-android-arm64": "1.0.1", + "@rolldown/binding-darwin-arm64": "1.0.1", + "@rolldown/binding-darwin-x64": "1.0.1", + "@rolldown/binding-freebsd-x64": "1.0.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.1", + "@rolldown/binding-linux-arm64-musl": "1.0.1", + "@rolldown/binding-linux-ppc64-gnu": "1.0.1", + "@rolldown/binding-linux-s390x-gnu": "1.0.1", + "@rolldown/binding-linux-x64-gnu": "1.0.1", + "@rolldown/binding-linux-x64-musl": "1.0.1", + "@rolldown/binding-openharmony-arm64": "1.0.1", + "@rolldown/binding-wasm32-wasi": "1.0.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.1", + "@rolldown/binding-win32-x64-msvc": "1.0.1" } }, - "node_modules/tsx/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/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" } }, - "node_modules/tsx/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" - ], + "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/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, "engines": { - "node": ">=18" + "node": ">=10" } }, - "node_modules/tsx/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" - ], + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/tsx/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" - ], + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/tsx/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" - ], + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, "engines": { - "node": ">=18" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tsx/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" - ], + "node_modules/slice-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", + "integrity": "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, "engines": { - "node": ">=18" + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/tsx/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" - ], + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/tsx/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" - ], + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, "engines": { "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/tsx/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" - ], + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", "engines": { - "node": ">=18" + "node": ">=0.10.0" } }, - "node_modules/tsx/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" - ], + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/tsx/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" - ], + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, "engines": { - "node": ">=18" + "node": ">=10" } }, - "node_modules/tsx/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" - ], + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/tsx/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" - ], + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/tsx/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" - ], + "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": ">=18" + "node": ">=8" } }, - "node_modules/tsx/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" - ], + "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": ">=18" + "node": ">=8" } }, - "node_modules/tsx/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" - ], + "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": ">=18" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/tsx/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" - ], + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], "engines": { - "node": ">=18" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/tsx/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" - ], + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" } }, - "node_modules/tsx/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" - ], + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], "engines": { - "node": ">=18" + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, - "node_modules/tsx/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" - ], + "node_modules/terminal-size": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz", + "integrity": "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==", "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], "engines": { "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/tsx/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" - ], + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, "engines": { - "node": ">=18" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tsx/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" - ], + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { - "node": ">=18" + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" } }, - "node_modules/tsx/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" - ], + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "devOptional": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, "engines": { - "node": ">=18" + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" } }, "node_modules/tsx/node_modules/esbuild": { From 31b78e7ed0b76f3a2fe880ea56df4ffd720eba8e Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 00:05:08 -0400 Subject: [PATCH 22/68] cli: update folder structure --- cli/src/commands/doctor.ts | 385 +----------------- cli/src/commands/doctor/checks.ts | 89 +++++ cli/src/commands/doctor/index.ts | 269 +++++++++++++ cli/src/commands/doctor/report.ts | 46 +++ cli/src/commands/package.ts | 594 +--------------------------- cli/src/commands/package/add.ts | 16 + cli/src/commands/package/audit.ts | 63 +++ cli/src/commands/package/index.ts | 9 + cli/src/commands/package/info.ts | 52 +++ cli/src/commands/package/install.ts | 212 ++++++++++ cli/src/commands/package/list.ts | 77 ++++ cli/src/commands/package/remove.ts | 61 +++ cli/src/commands/package/search.ts | 39 ++ cli/src/commands/package/shared.ts | 41 ++ cli/src/commands/package/update.ts | 63 +++ cli/src/commands/plugin.ts | 301 +------------- cli/src/commands/plugin/index.ts | 6 + cli/src/commands/plugin/install.ts | 64 +++ cli/src/commands/plugin/list.ts | 44 +++ cli/src/commands/plugin/remove.ts | 43 ++ cli/src/commands/plugin/shared.ts | 32 ++ cli/src/commands/plugin/update.ts | 98 +++++ cli/src/commands/plugin/workflow.ts | 66 ++++ cli/src/hooks/use-text-input.tsx | 6 +- cli/src/ui/package-add.tsx | 5 + 25 files changed, 1403 insertions(+), 1278 deletions(-) create mode 100644 cli/src/commands/doctor/checks.ts create mode 100644 cli/src/commands/doctor/index.ts create mode 100644 cli/src/commands/doctor/report.ts create mode 100644 cli/src/commands/package/add.ts create mode 100644 cli/src/commands/package/audit.ts create mode 100644 cli/src/commands/package/index.ts create mode 100644 cli/src/commands/package/info.ts create mode 100644 cli/src/commands/package/install.ts create mode 100644 cli/src/commands/package/list.ts create mode 100644 cli/src/commands/package/remove.ts create mode 100644 cli/src/commands/package/search.ts create mode 100644 cli/src/commands/package/shared.ts create mode 100644 cli/src/commands/package/update.ts create mode 100644 cli/src/commands/plugin/index.ts create mode 100644 cli/src/commands/plugin/install.ts create mode 100644 cli/src/commands/plugin/list.ts create mode 100644 cli/src/commands/plugin/remove.ts create mode 100644 cli/src/commands/plugin/shared.ts create mode 100644 cli/src/commands/plugin/update.ts create mode 100644 cli/src/commands/plugin/workflow.ts diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts index d9cda919..db789bdf 100644 --- a/cli/src/commands/doctor.ts +++ b/cli/src/commands/doctor.ts @@ -1,385 +1,2 @@ -import { existsSync, readFileSync } from "node:fs"; -import { join, resolve } from "node:path"; -import { createConnection } from "node:net"; -import { spawnSync } from "node:child_process"; -import chalk from "chalk"; -import { findLoveBinary, getLoveVersion } from "../lib/love.js"; -import { loadConfig } from "../lib/config.js"; -import { normalizeInstallDir } from "../lib/install.js"; -import { parseManagedValue, findInstalledPluginDirs, readPluginManifest } from "../lib/plugin-utils.js"; -import { icon as statusIcon, style } from "../lib/output.js"; +export * from './doctor/index.js'; -type Severity = "pass" | "warn" | "fail" | "info"; - -type DoctorCheck = { - group: string; - label: string; - severity: Severity; - detail?: string; - fix?: string; -}; - -export type DoctorOptions = { - installDir?: string; - host?: string; - port?: number; - json?: boolean; -}; - -const severityOrder: Record = { - fail: 0, - warn: 1, - info: 2, - pass: 3, -}; - -function add( - checks: DoctorCheck[], - group: string, - label: string, - severity: Severity, - detail?: string, - fix?: string, -): void { - checks.push({ group, label, severity, detail, fix }); -} - -function icon(severity: Severity): string { - if (severity === "pass") return statusIcon.success; - if (severity === "warn") return statusIcon.warning; - if (severity === "fail") return statusIcon.error; - return statusIcon.info; -} - -function colorLabel(severity: Severity, label: string): string { - if (severity === "pass") return chalk.white(label); - if (severity === "warn") return chalk.yellow(label); - if (severity === "fail") return chalk.red(label); - return chalk.white(label); -} - -function portReachable(port: number, host = "127.0.0.1", timeout = 1000): Promise { - return new Promise((resolve) => { - const sock = createConnection({ port, host }); - const timer = setTimeout(() => { - sock.destroy(); - resolve(false); - }, timeout); - sock.once("connect", () => { - clearTimeout(timer); - sock.destroy(); - resolve(true); - }); - sock.once("error", () => { - clearTimeout(timer); - resolve(false); - }); - }); -} - -function commandVersion(command: string, args: string[]): string | null { - const result = spawnSync(command, args, { encoding: "utf8" }); - if (result.error || result.status !== 0) return null; - return (result.stdout || result.stderr).trim().split("\n")[0] ?? null; -} - -function readIfExists(path: string): string | null { - if (!existsSync(path)) return null; - return readFileSync(path, "utf8"); -} - -function uncommentedLua(src: string): string { - return src - .split("\n") - .filter((line) => !line.trimStart().startsWith("--")) - .join("\n"); -} - -function luaBoolEnabled(src: string, key: string): boolean { - return new RegExp(`${key}\\s*=\\s*true\\b`).test(src); -} - -function hasConfigArrayValue(src: string, key: string, value: string): boolean { - const match = src.match(new RegExp(`${key}\\s*=\\s*\\{([\\s\\S]*?)\\}`)); - return match ? new RegExp(`["']${value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`).test(match[1]) : false; -} - -function isWeakApiKey(value: unknown): boolean { - return typeof value !== "string" || value.trim().length < 24 || value === "change-me" || value === "dev"; -} - -function renderReport(checks: DoctorCheck[], projectDir: string): void { - console.log(style.heading("\nFeather doctor\n")); - console.log(style.muted(`Project: ${projectDir}\n`)); - - const groups = [...new Set(checks.map((check) => check.group))]; - for (const group of groups) { - console.log(chalk.bold(group)); - for (const check of checks.filter((item) => item.group === group).sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity])) { - console.log(` ${icon(check.severity)} ${colorLabel(check.severity, check.label)}${check.detail ? chalk.dim(` ${check.detail}`) : ""}`); - if (check.fix) console.log(chalk.dim(` → ${check.fix}`)); - } - console.log(); - } - - const failures = checks.filter((check) => check.severity === "fail"); - const warnings = checks.filter((check) => check.severity === "warn"); - const passed = checks.filter((check) => check.severity === "pass"); - - if (failures.length > 0) { - console.log(chalk.red.bold(`Doctor found ${failures.length} blocker${failures.length === 1 ? "" : "s"}.`)); - } else if (warnings.length > 0) { - console.log(chalk.yellow.bold(`Doctor passed with ${warnings.length} warning${warnings.length === 1 ? "" : "s"}.`)); - } else { - console.log(chalk.green.bold("Doctor found no problems.")); - } - console.log(chalk.dim(`${passed.length} passed, ${warnings.length} warnings, ${failures.length} failures.\n`)); -} - -export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}): Promise { - const projectDir = gamePath ? resolve(gamePath) : process.cwd(); - const installDir = normalizeInstallDir(opts.installDir ?? "feather"); - const checks: DoctorCheck[] = []; - - const nodeVersion = process.versions.node; - const [major] = nodeVersion.split(".").map(Number); - add( - checks, - "Environment", - "Node.js", - major >= 18 ? "pass" : "fail", - `v${nodeVersion}`, - major >= 18 ? undefined : "Install Node.js 18 or newer.", - ); - - const npmVersion = commandVersion("npm", ["--version"]); - add( - checks, - "Environment", - "npm", - npmVersion ? "pass" : "warn", - npmVersion ? `v${npmVersion}` : "not found", - npmVersion ? undefined : "Install npm if you want to use npm run feather or global CLI installs.", - ); - - let lovePath: string | null = null; - try { - lovePath = findLoveBinary(); - const ver = getLoveVersion(lovePath); - add(checks, "Environment", "LÖVE binary", "pass", `${lovePath} (${ver})`); - } catch { - add( - checks, - "Environment", - "LÖVE binary", - "fail", - "not found", - "Install from https://love2d.org or set LOVE_BIN to the executable path.", - ); - } - - add(checks, "Environment", "Platform", "info", `${process.platform} ${process.arch}`); - - const hasProjectDir = existsSync(projectDir); - add( - checks, - "Project", - "Project directory", - hasProjectDir ? "pass" : "fail", - projectDir, - hasProjectDir ? undefined : "Pass the game directory to `feather doctor `.", - ); - - const mainPath = join(projectDir, "main.lua"); - const mainSource = readIfExists(mainPath); - const hasMain = mainSource !== null; - add( - checks, - "Project", - "main.lua", - hasMain ? "pass" : "fail", - hasMain ? mainPath : "missing", - hasMain ? undefined : "Run doctor from a LÖVE project root or pass the project directory.", - ); - - const configPath = join(projectDir, "feather.config.lua"); - const configSource = readIfExists(configPath); - let config: ReturnType = null; - if (configSource) { - try { - config = loadConfig(projectDir); - add(checks, "Project", "feather.config.lua", "pass", configPath); - } catch (err) { - add(checks, "Project", "feather.config.lua", "fail", (err as Error).message); - } - } else { - add(checks, "Project", "feather.config.lua", "warn", "missing", "Run `feather init` to create a shared config."); - } - - const managedMode = configSource ? parseManagedValue(configSource, "mode") : null; - const managedInstallDir = configSource ? parseManagedValue(configSource, "installDir") : null; - const effectiveInstallDir = normalizeInstallDir(opts.installDir ?? managedInstallDir ?? installDir); - if (managedMode) { - add(checks, "Project", "Managed init metadata", "pass", `mode=${managedMode}, installDir=${managedInstallDir ?? effectiveInstallDir}`); - } else if (configSource) { - add(checks, "Project", "Managed init metadata", "info", "not present", "New `feather init` configs include metadata for `feather remove`."); - } - - const runtimeDir = join(projectDir, effectiveInstallDir); - const hasRuntime = existsSync(join(runtimeDir, "init.lua")); - const configOnlyMode = managedMode === "cli"; - add( - checks, - "Runtime", - "Embedded Feather runtime", - hasRuntime ? "pass" : configOnlyMode ? "info" : "warn", - hasRuntime ? runtimeDir : configOnlyMode ? "not needed for cli mode" : "missing", - hasRuntime || configOnlyMode ? undefined : "Run `feather init --mode auto` for embedded/device workflows.", - ); - - if (hasRuntime) { - for (const file of ["auto.lua", "lib/ws.lua", "plugin_manager.lua"]) { - const path = join(runtimeDir, file); - add( - checks, - "Runtime", - file, - existsSync(path) ? "pass" : "fail", - existsSync(path) ? undefined : "missing", - existsSync(path) ? undefined : `Reinstall runtime with \`feather init --mode auto --install-dir ${effectiveInstallDir}\`.`, - ); - } - - const initSource = readIfExists(join(runtimeDir, "init.lua")); - const runtimeVersion = initSource?.match(/FEATHER_VERSION_NAME\s*=\s*"([^"]+)"/)?.[1] ?? "unknown"; - add(checks, "Runtime", "Runtime version", runtimeVersion === "unknown" ? "warn" : "pass", runtimeVersion); - } - - const runtimePluginRoot = join(runtimeDir, "plugins"); - const sourceTreePluginRoot = join(projectDir, "plugins"); - const pluginRoot = existsSync(runtimePluginRoot) ? runtimePluginRoot : sourceTreePluginRoot; - const pluginDirs = findInstalledPluginDirs(pluginRoot); - if (hasRuntime) { - add( - checks, - "Plugins", - "Plugin directory", - existsSync(pluginRoot) ? "pass" : "info", - existsSync(pluginRoot) ? pluginRoot : "not installed", - existsSync(pluginRoot) ? undefined : "Core-only installs are valid; run `feather plugin` to manage plugins.", - ); - add(checks, "Plugins", "Installed plugins", pluginDirs.length > 0 ? "pass" : "info", `${pluginDirs.length}`); - const malformed = pluginDirs.filter((dir) => !readPluginManifest(dir)?.id); - if (malformed.length > 0) { - add(checks, "Plugins", "Plugin manifests", "warn", `${malformed.length} missing id`, "Reinstall affected plugins with `feather plugin update`."); - } else if (pluginDirs.length > 0) { - add(checks, "Plugins", "Plugin manifests", "pass", "all installed plugins declare an id"); - } - } - - if (mainSource) { - const hasUseDebugger = mainSource.includes("USE_DEBUGGER"); - const hasInitMarkers = mainSource.includes("FEATHER-INIT-BEGIN") && mainSource.includes("FEATHER-INIT-END"); - add( - checks, - "Safety", - "USE_DEBUGGER guard", - hasUseDebugger || configOnlyMode ? "pass" : "warn", - hasUseDebugger ? "present" : configOnlyMode ? "not needed for cli mode" : "not found", - hasUseDebugger || configOnlyMode ? undefined : "Guard Feather imports so production builds can skip debugger code.", - ); - add( - checks, - "Safety", - "FEATHER-INIT markers", - hasInitMarkers || configOnlyMode ? "pass" : "info", - hasInitMarkers ? "present" : "not present", - hasInitMarkers || configOnlyMode ? undefined : "`feather remove` works best when init markers are present.", - ); - } - - if (configSource) { - const activeConfigSource = uncommentedLua(configSource); - const captureScreenshot = luaBoolEnabled(activeConfigSource, "captureScreenshot"); - add( - checks, - "Safety", - "captureScreenshot", - captureScreenshot ? "warn" : "pass", - captureScreenshot ? "enabled" : "disabled", - captureScreenshot ? "Enable only when you need visual error context; it can affect performance." : undefined, - ); - - const hotReloadEnabled = /hotReload\s*=\s*\{[\s\S]*?enabled\s*=\s*true/.test(activeConfigSource); - const hotReloadPluginIncluded = hasConfigArrayValue(activeConfigSource, "include", "hot-reload") || pluginDirs.some((dir) => readPluginManifest(dir)?.id === "hot-reload"); - const persistToDisk = /hotReload\s*=\s*\{[\s\S]*?persistToDisk\s*=\s*true/.test(activeConfigSource); - const broadHotReload = /allow\s*=\s*\{[\s\S]*["'][^"']+\.\*["']/.test(activeConfigSource); - add( - checks, - "Safety", - "Hot reload", - hotReloadEnabled ? "warn" : "pass", - hotReloadEnabled ? "enabled" : "disabled", - hotReloadEnabled ? "Hot reload is development-only remote code execution; keep allowlists narrow and never ship with it on." : undefined, - ); - if (hotReloadEnabled && broadHotReload) { - add(checks, "Safety", "Hot reload allowlist", "warn", "contains wildcard", "Prefer exact module names while editing."); - } - if (hotReloadEnabled && !hotReloadPluginIncluded) { - add( - checks, - "Safety", - "Hot reload plugin", - "warn", - "not included", - "Install and include the opt-in `hot-reload` plugin, or remove debugger.hotReload.", - ); - } - if (hotReloadEnabled && persistToDisk) { - add(checks, "Safety", "Hot reload persistence", "warn", "persistToDisk=true", "Persisted patches survive app restarts until restored or cleared."); - } - - const consoleIncluded = hasConfigArrayValue(activeConfigSource, "include", "console") || pluginDirs.some((dir) => readPluginManifest(dir)?.id === "console"); - if (consoleIncluded) { - const apiKey = config?.apiKey; - add( - checks, - "Safety", - "Console API key", - isWeakApiKey(apiKey) ? "warn" : "pass", - isWeakApiKey(apiKey) ? "missing or weak" : "configured", - isWeakApiKey(apiKey) ? "Set a strong per-session or config API key when using Console." : undefined, - ); - } - } - - const configuredPort = opts.port ?? (typeof config?.port === "number" ? config.port : 4004); - const configuredHost = opts.host ?? "127.0.0.1"; - const mode = typeof config?.mode === "string" ? config.mode : "socket"; - if (mode === "disk") { - add(checks, "Connectivity", "WebSocket mode", "info", "disk mode", "Desktop WebSocket connectivity is not used in disk mode."); - } else { - const desktopUp = await portReachable(configuredPort, configuredHost); - add( - checks, - "Connectivity", - `Feather desktop (${configuredHost}:${configuredPort})`, - desktopUp ? "pass" : "warn", - desktopUp ? "reachable" : "not reachable", - desktopUp ? undefined : "Start the Feather desktop app, or pass --host/--port if checking a custom endpoint.", - ); - } - - const failures = checks.filter((check) => check.severity === "fail"); - const warnings = checks.filter((check) => check.severity === "warn"); - - if (opts.json) { - console.log(JSON.stringify({ projectDir, installDir: effectiveInstallDir, failures: failures.length, warnings: warnings.length, checks }, null, 2)); - } else { - renderReport(checks, projectDir); - } - - if (failures.length > 0) { - process.exitCode = 1; - } -} diff --git a/cli/src/commands/doctor/checks.ts b/cli/src/commands/doctor/checks.ts new file mode 100644 index 00000000..e2e82c59 --- /dev/null +++ b/cli/src/commands/doctor/checks.ts @@ -0,0 +1,89 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { createConnection } from 'node:net'; +import { spawnSync } from 'node:child_process'; + +export type Severity = 'pass' | 'warn' | 'fail' | 'info'; + +export type DoctorCheck = { + group: string; + label: string; + severity: Severity; + detail?: string; + fix?: string; +}; + +export type DoctorOptions = { + installDir?: string; + host?: string; + port?: number; + json?: boolean; +}; + +export const severityOrder: Record = { + fail: 0, + warn: 1, + info: 2, + pass: 3, +}; + +export function add( + checks: DoctorCheck[], + group: string, + label: string, + severity: Severity, + detail?: string, + fix?: string, +): void { + checks.push({ group, label, severity, detail, fix }); +} + +export function portReachable(port: number, host = '127.0.0.1', timeout = 1000): Promise { + return new Promise((resolve) => { + const sock = createConnection({ port, host }); + const timer = setTimeout(() => { + sock.destroy(); + resolve(false); + }, timeout); + sock.once('connect', () => { + clearTimeout(timer); + sock.destroy(); + resolve(true); + }); + sock.once('error', () => { + clearTimeout(timer); + resolve(false); + }); + }); +} + +export function commandVersion(command: string, args: string[]): string | null { + const result = spawnSync(command, args, { encoding: 'utf8' }); + if (result.error || result.status !== 0) return null; + return (result.stdout || result.stderr).trim().split('\n')[0] ?? null; +} + +export function readIfExists(path: string): string | null { + if (!existsSync(path)) return null; + return readFileSync(path, 'utf8'); +} + +export function uncommentedLua(src: string): string { + return src + .split('\n') + .filter((line) => !line.trimStart().startsWith('--')) + .join('\n'); +} + +export function luaBoolEnabled(src: string, key: string): boolean { + return new RegExp(`${key}\\s*=\\s*true\\b`).test(src); +} + +export function hasConfigArrayValue(src: string, key: string, value: string): boolean { + const match = src.match(new RegExp(`${key}\\s*=\\s*\\{([\\s\\S]*?)\\}`)); + return match ? new RegExp(`["']${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']`).test(match[1]) : false; +} + +export function isWeakApiKey(value: unknown): boolean { + return typeof value !== 'string' || value.trim().length < 24 || value === 'change-me' || value === 'dev'; +} + diff --git a/cli/src/commands/doctor/index.ts b/cli/src/commands/doctor/index.ts new file mode 100644 index 00000000..2de8c2de --- /dev/null +++ b/cli/src/commands/doctor/index.ts @@ -0,0 +1,269 @@ +import { existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { findLoveBinary, getLoveVersion } from '../../lib/love.js'; +import { loadConfig } from '../../lib/config.js'; +import { normalizeInstallDir } from '../../lib/install.js'; +import { parseManagedValue, findInstalledPluginDirs, readPluginManifest } from '../../lib/plugin-utils.js'; +import { + add, + commandVersion, + hasConfigArrayValue, + isWeakApiKey, + luaBoolEnabled, + portReachable, + readIfExists, + uncommentedLua, + type DoctorCheck, + type DoctorOptions, +} from './checks.js'; +import { renderReport } from './report.js'; + +export type { DoctorOptions } from './checks.js'; + +export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}): Promise { + const projectDir = gamePath ? resolve(gamePath) : process.cwd(); + const installDir = normalizeInstallDir(opts.installDir ?? 'feather'); + const checks: DoctorCheck[] = []; + + const nodeVersion = process.versions.node; + const [major] = nodeVersion.split('.').map(Number); + add( + checks, + 'Environment', + 'Node.js', + major >= 18 ? 'pass' : 'fail', + `v${nodeVersion}`, + major >= 18 ? undefined : 'Install Node.js 18 or newer.', + ); + + const npmVersion = commandVersion('npm', ['--version']); + add( + checks, + 'Environment', + 'npm', + npmVersion ? 'pass' : 'warn', + npmVersion ? `v${npmVersion}` : 'not found', + npmVersion ? undefined : 'Install npm if you want to use npm run feather or global CLI installs.', + ); + + try { + const lovePath = findLoveBinary(); + const ver = getLoveVersion(lovePath); + add(checks, 'Environment', 'LÖVE binary', 'pass', `${lovePath} (${ver})`); + } catch { + add( + checks, + 'Environment', + 'LÖVE binary', + 'fail', + 'not found', + 'Install from https://love2d.org or set LOVE_BIN to the executable path.', + ); + } + + add(checks, 'Environment', 'Platform', 'info', `${process.platform} ${process.arch}`); + + const hasProjectDir = existsSync(projectDir); + add( + checks, + 'Project', + 'Project directory', + hasProjectDir ? 'pass' : 'fail', + projectDir, + hasProjectDir ? undefined : 'Pass the game directory to `feather doctor `.', + ); + + const mainPath = join(projectDir, 'main.lua'); + const mainSource = readIfExists(mainPath); + const hasMain = mainSource !== null; + add( + checks, + 'Project', + 'main.lua', + hasMain ? 'pass' : 'fail', + hasMain ? mainPath : 'missing', + hasMain ? undefined : 'Run doctor from a LÖVE project root or pass the project directory.', + ); + + const configPath = join(projectDir, 'feather.config.lua'); + const configSource = readIfExists(configPath); + let config: ReturnType = null; + if (configSource) { + try { + config = loadConfig(projectDir); + add(checks, 'Project', 'feather.config.lua', 'pass', configPath); + } catch (err) { + add(checks, 'Project', 'feather.config.lua', 'fail', (err as Error).message); + } + } else { + add(checks, 'Project', 'feather.config.lua', 'warn', 'missing', 'Run `feather init` to create a shared config.'); + } + + const managedMode = configSource ? parseManagedValue(configSource, 'mode') : null; + const managedInstallDir = configSource ? parseManagedValue(configSource, 'installDir') : null; + const effectiveInstallDir = normalizeInstallDir(opts.installDir ?? managedInstallDir ?? installDir); + if (managedMode) { + add(checks, 'Project', 'Managed init metadata', 'pass', `mode=${managedMode}, installDir=${managedInstallDir ?? effectiveInstallDir}`); + } else if (configSource) { + add(checks, 'Project', 'Managed init metadata', 'info', 'not present', 'New `feather init` configs include metadata for `feather remove`.'); + } + + const runtimeDir = join(projectDir, effectiveInstallDir); + const hasRuntime = existsSync(join(runtimeDir, 'init.lua')); + const configOnlyMode = managedMode === 'cli'; + add( + checks, + 'Runtime', + 'Embedded Feather runtime', + hasRuntime ? 'pass' : configOnlyMode ? 'info' : 'warn', + hasRuntime ? runtimeDir : configOnlyMode ? 'not needed for cli mode' : 'missing', + hasRuntime || configOnlyMode ? undefined : 'Run `feather init --mode auto` for embedded/device workflows.', + ); + + if (hasRuntime) { + for (const file of ['auto.lua', 'lib/ws.lua', 'plugin_manager.lua']) { + const path = join(runtimeDir, file); + add( + checks, + 'Runtime', + file, + existsSync(path) ? 'pass' : 'fail', + existsSync(path) ? undefined : 'missing', + existsSync(path) ? undefined : `Reinstall runtime with \`feather init --mode auto --install-dir ${effectiveInstallDir}\`.`, + ); + } + + const initSource = readIfExists(join(runtimeDir, 'init.lua')); + const runtimeVersion = initSource?.match(/FEATHER_VERSION_NAME\s*=\s*"([^"]+)"/)?.[1] ?? 'unknown'; + add(checks, 'Runtime', 'Runtime version', runtimeVersion === 'unknown' ? 'warn' : 'pass', runtimeVersion); + } + + const runtimePluginRoot = join(runtimeDir, 'plugins'); + const sourceTreePluginRoot = join(projectDir, 'plugins'); + const pluginRoot = existsSync(runtimePluginRoot) ? runtimePluginRoot : sourceTreePluginRoot; + const pluginDirs = findInstalledPluginDirs(pluginRoot); + if (hasRuntime) { + add( + checks, + 'Plugins', + 'Plugin directory', + existsSync(pluginRoot) ? 'pass' : 'info', + existsSync(pluginRoot) ? pluginRoot : 'not installed', + existsSync(pluginRoot) ? undefined : 'Core-only installs are valid; run `feather plugin` to manage plugins.', + ); + add(checks, 'Plugins', 'Installed plugins', pluginDirs.length > 0 ? 'pass' : 'info', `${pluginDirs.length}`); + const malformed = pluginDirs.filter((dir) => !readPluginManifest(dir)?.id); + if (malformed.length > 0) { + add(checks, 'Plugins', 'Plugin manifests', 'warn', `${malformed.length} missing id`, 'Reinstall affected plugins with `feather plugin update`.'); + } else if (pluginDirs.length > 0) { + add(checks, 'Plugins', 'Plugin manifests', 'pass', 'all installed plugins declare an id'); + } + } + + if (mainSource) { + const hasUseDebugger = mainSource.includes('USE_DEBUGGER'); + const hasInitMarkers = mainSource.includes('FEATHER-INIT-BEGIN') && mainSource.includes('FEATHER-INIT-END'); + add( + checks, + 'Safety', + 'USE_DEBUGGER guard', + hasUseDebugger || configOnlyMode ? 'pass' : 'warn', + hasUseDebugger ? 'present' : configOnlyMode ? 'not needed for cli mode' : 'not found', + hasUseDebugger || configOnlyMode ? undefined : 'Guard Feather imports so production builds can skip debugger code.', + ); + add( + checks, + 'Safety', + 'FEATHER-INIT markers', + hasInitMarkers || configOnlyMode ? 'pass' : 'info', + hasInitMarkers ? 'present' : 'not present', + hasInitMarkers || configOnlyMode ? undefined : '`feather remove` works best when init markers are present.', + ); + } + + if (configSource) { + const activeConfigSource = uncommentedLua(configSource); + const captureScreenshot = luaBoolEnabled(activeConfigSource, 'captureScreenshot'); + add( + checks, + 'Safety', + 'captureScreenshot', + captureScreenshot ? 'warn' : 'pass', + captureScreenshot ? 'enabled' : 'disabled', + captureScreenshot ? 'Enable only when you need visual error context; it can affect performance.' : undefined, + ); + + const hotReloadEnabled = /hotReload\s*=\s*\{[\s\S]*?enabled\s*=\s*true/.test(activeConfigSource); + const hotReloadPluginIncluded = hasConfigArrayValue(activeConfigSource, 'include', 'hot-reload') || pluginDirs.some((dir) => readPluginManifest(dir)?.id === 'hot-reload'); + const persistToDisk = /hotReload\s*=\s*\{[\s\S]*?persistToDisk\s*=\s*true/.test(activeConfigSource); + const broadHotReload = /allow\s*=\s*\{[\s\S]*["'][^"']+\.\*["']/.test(activeConfigSource); + add( + checks, + 'Safety', + 'Hot reload', + hotReloadEnabled ? 'warn' : 'pass', + hotReloadEnabled ? 'enabled' : 'disabled', + hotReloadEnabled ? 'Hot reload is development-only remote code execution; keep allowlists narrow and never ship with it on.' : undefined, + ); + if (hotReloadEnabled && broadHotReload) { + add(checks, 'Safety', 'Hot reload allowlist', 'warn', 'contains wildcard', 'Prefer exact module names while editing.'); + } + if (hotReloadEnabled && !hotReloadPluginIncluded) { + add( + checks, + 'Safety', + 'Hot reload plugin', + 'warn', + 'not included', + 'Install and include the opt-in `hot-reload` plugin, or remove debugger.hotReload.', + ); + } + if (hotReloadEnabled && persistToDisk) { + add(checks, 'Safety', 'Hot reload persistence', 'warn', 'persistToDisk=true', 'Persisted patches survive app restarts until restored or cleared.'); + } + + const consoleIncluded = hasConfigArrayValue(activeConfigSource, 'include', 'console') || pluginDirs.some((dir) => readPluginManifest(dir)?.id === 'console'); + if (consoleIncluded) { + const apiKey = config?.apiKey; + add( + checks, + 'Safety', + 'Console API key', + isWeakApiKey(apiKey) ? 'warn' : 'pass', + isWeakApiKey(apiKey) ? 'missing or weak' : 'configured', + isWeakApiKey(apiKey) ? 'Set a strong per-session or config API key when using Console.' : undefined, + ); + } + } + + const configuredPort = opts.port ?? (typeof config?.port === 'number' ? config.port : 4004); + const configuredHost = opts.host ?? '127.0.0.1'; + const mode = typeof config?.mode === 'string' ? config.mode : 'socket'; + if (mode === 'disk') { + add(checks, 'Connectivity', 'WebSocket mode', 'info', 'disk mode', 'Desktop WebSocket connectivity is not used in disk mode.'); + } else { + const desktopUp = await portReachable(configuredPort, configuredHost); + add( + checks, + 'Connectivity', + `Feather desktop (${configuredHost}:${configuredPort})`, + desktopUp ? 'pass' : 'warn', + desktopUp ? 'reachable' : 'not reachable', + desktopUp ? undefined : 'Start the Feather desktop app, or pass --host/--port if checking a custom endpoint.', + ); + } + + const failures = checks.filter((check) => check.severity === 'fail'); + const warnings = checks.filter((check) => check.severity === 'warn'); + + if (opts.json) { + console.log(JSON.stringify({ projectDir, installDir: effectiveInstallDir, failures: failures.length, warnings: warnings.length, checks }, null, 2)); + } else { + renderReport(checks, projectDir); + } + + if (failures.length > 0) { + process.exitCode = 1; + } +} + diff --git a/cli/src/commands/doctor/report.ts b/cli/src/commands/doctor/report.ts new file mode 100644 index 00000000..c9947c5d --- /dev/null +++ b/cli/src/commands/doctor/report.ts @@ -0,0 +1,46 @@ +import chalk from 'chalk'; +import { icon as statusIcon, style } from '../../lib/output.js'; +import { type DoctorCheck, type Severity, severityOrder } from './checks.js'; + +function icon(severity: Severity): string { + if (severity === 'pass') return statusIcon.success; + if (severity === 'warn') return statusIcon.warning; + if (severity === 'fail') return statusIcon.error; + return statusIcon.info; +} + +function colorLabel(severity: Severity, label: string): string { + if (severity === 'pass') return chalk.white(label); + if (severity === 'warn') return chalk.yellow(label); + if (severity === 'fail') return chalk.red(label); + return chalk.white(label); +} + +export function renderReport(checks: DoctorCheck[], projectDir: string): void { + console.log(style.heading('\nFeather doctor\n')); + console.log(style.muted(`Project: ${projectDir}\n`)); + + const groups = [...new Set(checks.map((check) => check.group))]; + for (const group of groups) { + console.log(chalk.bold(group)); + for (const check of checks.filter((item) => item.group === group).sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity])) { + console.log(` ${icon(check.severity)} ${colorLabel(check.severity, check.label)}${check.detail ? chalk.dim(` ${check.detail}`) : ''}`); + if (check.fix) console.log(chalk.dim(` → ${check.fix}`)); + } + console.log(); + } + + const failures = checks.filter((check) => check.severity === 'fail'); + const warnings = checks.filter((check) => check.severity === 'warn'); + const passed = checks.filter((check) => check.severity === 'pass'); + + if (failures.length > 0) { + console.log(chalk.red.bold(`Doctor found ${failures.length} blocker${failures.length === 1 ? '' : 's'}.`)); + } else if (warnings.length > 0) { + console.log(chalk.yellow.bold(`Doctor passed with ${warnings.length} warning${warnings.length === 1 ? '' : 's'}.`)); + } else { + console.log(chalk.green.bold('Doctor found no problems.')); + } + console.log(chalk.dim(`${passed.length} passed, ${warnings.length} warnings, ${failures.length} failures.\n`)); +} + diff --git a/cli/src/commands/package.ts b/cli/src/commands/package.ts index dd6bd8ed..be79a465 100644 --- a/cli/src/commands/package.ts +++ b/cli/src/commands/package.ts @@ -1,594 +1,2 @@ -import { existsSync, rmSync } from 'node:fs'; -import { join, resolve } from 'node:path'; -import chalk from 'chalk'; -import ora from 'ora'; -import { loadRegistry } from '../lib/package/registry.js'; -import { readLockfile, writeLockfile, removeFromLockfile } from '../lib/package/lockfile.js'; -import { resolveMany, type ResolvedPackage } from '../lib/package/resolve.js'; -import { installFromUrl, restorePackage } from '../lib/package/install.js'; -import { auditLockfile } from '../lib/package/audit.js'; -import { showPackageBrowser } from '../ui/package-workflow.js'; -import { showInstallProgress } from '../ui/package-progress.js'; -import { showAddWizard } from '../ui/package-add.js'; -import { confirmAction } from '../ui/confirm.js'; -import { findProjectDir } from '../lib/paths.js'; -import { trustBadge } from '../lib/trust.js'; -import { icon, keyValueRows, statusLine, style } from '../lib/output.js'; +export * from './package/index.js'; -export type PackageSearchOptions = { - offline?: boolean; - registryUrl?: string; -}; - -export async function packageSearchCommand(query: string | undefined, opts: PackageSearchOptions = {}): Promise { - const spinner = ora('Loading registry…').start(); - let registry; - try { - registry = await loadRegistry(opts); - spinner.stop(); - } catch (err) { - spinner.fail(`Failed to load registry: ${(err as Error).message}`); - process.exitCode = 1; - return; - } - - const q = query?.toLowerCase(); - const entries = Object.entries(registry.packages).filter(([, entry]) => !entry.parent); - - const matches = q - ? entries.filter( - ([id, entry]) => - id.includes(q) || entry.description.toLowerCase().includes(q) || entry.tags.some((t) => t.includes(q)), - ) - : entries; - - if (matches.length === 0) { - console.log(chalk.dim(`No packages found${q ? ` matching "${query}"` : ''}.`)); - return; - } - - const maxId = Math.max(...matches.map(([id]) => id.length)); - for (const [id, entry] of matches.sort(([a], [b]) => a.localeCompare(b))) { - const badge = trustBadge(entry.trust); - console.log(` ${chalk.bold(id.padEnd(maxId + 2))} ${badge} ${chalk.dim(entry.description)}`); - if (entry.subpackages?.length) { - console.log(chalk.dim(` ${''.padEnd(maxId + 2)} subpackages: ${entry.subpackages.join(', ')}`)); - } - } - console.log(chalk.dim(`\n${matches.length} package(s). Run \`feather package info \` for details.`)); -} - -export type PackageListOptions = { - installed?: boolean; - offline?: boolean; - refresh?: boolean; - dir?: string; - registryUrl?: string; -}; - -export async function packageListCommand(opts: PackageListOptions = {}): Promise { - const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); - - if (opts.installed) { - const lockfile = readLockfile(projectDir); - const entries = Object.entries(lockfile.packages); - if (entries.length === 0) { - console.log(chalk.dim('No packages installed. Run `feather package install `.')); - return; - } - for (const [id, entry] of entries) { - console.log(` ${trustBadge(entry.trust)} ${chalk.bold(id)} @ ${entry.version}`); - } - console.log(chalk.dim(`\n${entries.length} package(s) installed.`)); - return; - } - - const spinner = ora('Loading registry…').start(); - let registry; - try { - registry = await loadRegistry({ offline: opts.offline, refresh: opts.refresh, registryUrl: opts.registryUrl }); - spinner.stop(); - } catch (err) { - spinner.fail(`Failed to load registry: ${(err as Error).message}`); - process.exitCode = 1; - return; - } - - const lockfile = readLockfile(projectDir); - - // Fall back to plain text when stdin is not a TTY (scripts, piped output) - if (!process.stdin.isTTY || !process.stdout.isTTY) { - const entries = Object.entries(registry.packages).filter(([, e]) => !e.parent); - for (const [id, entry] of entries.sort(([a], [b]) => a.localeCompare(b))) { - const installed = lockfile.packages[id]; - const installedLabel = installed ? chalk.cyan(` (installed ${installed.version})`) : ''; - console.log(` ${trustBadge(entry.trust)} ${chalk.bold(id)}${installedLabel} ${chalk.dim(entry.description)}`); - } - console.log(chalk.dim(`\n${entries.length} available.`)); - return; - } - - const result = await showPackageBrowser({ registry, lockfile }); - if (result.action === 'cancel') return; - - const { resolved, errors } = resolveMany([result.id], registry); - if (errors.length) { - for (const e of errors) console.log(chalk.red(` ✖ ${e}`)); - process.exitCode = 1; - return; - } - - if (result.action === 'remove') { - await packageRemoveCommand(result.id, { dir: opts.dir }); - return; - } - - const installResults = await showInstallProgress({ packages: resolved, lockfile, projectDir }); - if (installResults.every((r) => r.ok)) { - writeLockfile(projectDir, lockfile); - } else { - process.exitCode = 1; - } -} - -export type PackageInfoOptions = { - offline?: boolean; - dir?: string; - registryUrl?: string; -}; - -export async function packageInfoCommand(name: string, opts: PackageInfoOptions = {}): Promise { - const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); - const lockfile = readLockfile(projectDir); - - const spinner = ora('Loading registry…').start(); - let registry; - try { - registry = await loadRegistry({ offline: opts.offline, registryUrl: opts.registryUrl }); - spinner.stop(); - } catch (err) { - spinner.fail(`Failed to load registry: ${(err as Error).message}`); - process.exitCode = 1; - return; - } - - const entry = registry.packages[name]; - if (!entry) { - console.log(chalk.red(`Package "${name}" not found.`)); - process.exitCode = 1; - return; - } - - const installed = lockfile.packages[name]; - console.log(); - console.log(`${chalk.bold(name)} ${trustBadge(entry.trust)}`); - console.log(chalk.dim(entry.description)); - console.log(); - console.log(` Source: ${chalk.cyan(`github.com/${entry.source.repo}`)}`); - console.log(` Version: ${entry.source.tag}`); - console.log(` Tags: ${entry.tags.join(', ') || '—'}`); - if (entry.license) console.log(` License: ${entry.license}`); - if (entry.homepage) console.log(` Docs: ${chalk.cyan(entry.homepage)}`); - if (installed) { - console.log(` Status: ${chalk.green('installed')} @ ${installed.version}`); - } - if (entry.subpackages?.length) { - console.log(` Modules: ${entry.subpackages.join(', ')}`); - } - console.log(); - console.log(' Files to install:'); - for (const f of entry.install.files) { - console.log(` ${chalk.dim(f.name)} → ${f.target}`); - } - console.log(); - console.log(' Usage:'); - console.log(` ${chalk.cyan(entry.example ?? `local lib = require("${entry.require}")`)}`); - console.log(); -} - -export type PackageInstallOptions = { - dryRun?: boolean; - allowUntrusted?: boolean; - target?: string; - fromUrl?: string; - dir?: string; - offline?: boolean; - yes?: boolean; - registryUrl?: string; -}; - -export async function packageInstallCommand(names: string[], opts: PackageInstallOptions = {}): Promise { - const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); - - if (opts.fromUrl) { - if (!opts.target) { - console.log(); - console.log(statusLine('error', '--target is required with --from-url')); - process.exitCode = 1; - return; - } - - if (!opts.allowUntrusted) { - console.log(); - console.log(style.warning('Installing from untrusted URL')); - for (const row of keyValueRows([ - ['URL', opts.fromUrl], - ['Target', opts.target], - ['Trust', 'experimental; not reviewed by the Feather team'], - ])) { - console.log(row); - } - - if (!process.stdin.isTTY || !process.stdout.isTTY) { - console.log(); - console.log(style.danger('Use --allow-untrusted to confirm this source in non-interactive mode.')); - process.exitCode = 1; - return; - } - - const confirmed = await confirmAction({ - title: 'feather package install', - label: 'Install this unreviewed URL?', - hint: 'Only continue if you trust the source and target path.', - danger: true, - rows: [`URL: ${opts.fromUrl}`, `Target: ${opts.target}`], - }); - if (!confirmed) { - console.log(style.muted('Install cancelled.')); - return; - } - } - - const spinner = ora(`Fetching ${opts.fromUrl}…`).start(); - const lockfile = readLockfile(projectDir); - const result = await installFromUrl(lockfile, { - projectDir, - url: opts.fromUrl, - target: opts.target, - dryRun: opts.dryRun, - }); - - if (!result.ok) { - spinner.fail(result.error ?? 'Install failed'); - process.exitCode = 1; - return; - } - - if (opts.dryRun) { - spinner.stop(); - console.log(); - console.log(style.warning('Dry run: no files written')); - for (const row of keyValueRows([ - ['URL', opts.fromUrl], - ['SHA-256', result.sha256], - ['Size', `${result.size} bytes`], - ['Target', result.target], - ['Trust', 'experimental; not reviewed'], - ])) { - console.log(row); - } - return; - } - - spinner.succeed('Installed from URL (experimental)'); - console.log(); - for (const row of keyValueRows([ - ['SHA-256', result.sha256], - ['Target', result.target], - ['Trust', style.warning('experimental; not reviewed by the Feather team')], - ])) { - console.log(row); - } - writeLockfile(projectDir, lockfile); - return; - } - - // No names → restore everything recorded in feather.lock.json - if (names.length === 0) { - const lockfile = readLockfile(projectDir); - const entries = Object.entries(lockfile.packages).filter(([, e]) => !e.parent); - - if (entries.length === 0) { - console.log(chalk.dim('feather.lock.json is empty. Run `feather package install ` to add packages.')); - return; - } - - console.log(); - const auditResults = await auditLockfile(projectDir, lockfile); - const broken = new Set(auditResults.filter((r) => r.status !== 'verified').map((r) => r.id)); - - if (broken.size === 0) { - console.log(chalk.green(`✔ All ${entries.length} package(s) already up to date.`)); - return; - } - - let failed = false; - for (const [id, entry] of entries) { - if (!broken.has(id)) { - console.log(chalk.dim(` ${id} @ ${entry.version} — up to date`)); - continue; - } - const spinner = ora(` ${id} @ ${entry.version}`).start(); - const result = await restorePackage(id, entry, { projectDir, dryRun: opts.dryRun }); - if (result.ok) { - spinner.succeed(` ${id} @ ${entry.version}`); - } else { - spinner.fail(` ${id}: ${result.error}`); - failed = true; - } - } - - console.log(); - if (failed) process.exitCode = 1; - return; - } - - const spinner = ora('Loading registry…').start(); - let registry; - try { - registry = await loadRegistry({ offline: opts.offline, registryUrl: opts.registryUrl }); - spinner.stop(); - } catch (err) { - spinner.fail(`Failed to load registry: ${(err as Error).message}`); - process.exitCode = 1; - return; - } - - const lockfile = readLockfile(projectDir); - const { resolved, errors } = resolveMany(names, registry); - - if (errors.length) { - for (const e of errors) console.log(chalk.red(` ✖ ${e}`)); - process.exitCode = 1; - return; - } - - // Check already installed (skip check when a version override is requested) - const toInstall = resolved.filter((pkg) => { - if (pkg.versionOverride) return true; - const existing = lockfile.packages[pkg.id]; - if (existing && existing.version === pkg.entry.source.tag) { - console.log(chalk.dim(` ${pkg.id} is already installed at ${existing.version}`)); - return false; - } - return true; - }); - - if (toInstall.length === 0) return; - - // Block experimental or version-overridden packages without --allow-untrusted - for (const pkg of toInstall) { - if (pkg.entry.trust === 'experimental' && !opts.allowUntrusted) { - console.log(chalk.red(` "${pkg.id}" requires --allow-untrusted (trust: experimental)`)); - process.exitCode = 1; - return; - } - if (pkg.versionOverride && !opts.allowUntrusted) { - console.log( - chalk.red( - ` "${pkg.id}@${pkg.versionOverride}" requires --allow-untrusted — this version has not been reviewed by Feather`, - ), - ); - process.exitCode = 1; - return; - } - } - - if (opts.dryRun) { - console.log(); - for (const pkg of toInstall) { - const displayVersion = pkg.versionOverride ?? pkg.entry.source.tag; - console.log(` ${chalk.bold(pkg.id)} ${trustBadge(pkg.versionOverride ? 'experimental' : pkg.entry.trust)}`); - console.log(` Source: github.com/${pkg.entry.source.repo} Version: ${displayVersion}`); - for (const f of pkg.files) { - console.log(` ${chalk.dim(f.name)} → ${f.target}`); - } - console.log(); - } - console.log(chalk.yellow('Dry run — no files written.')); - return; - } - - const results = await showInstallProgress({ packages: toInstall, lockfile, projectDir, targetOverride: opts.target }); - if (results.every((r) => r.ok)) { - writeLockfile(projectDir, lockfile); - } else { - process.exitCode = 1; - } -} - -export type PackageUpdateOptions = { - dryRun?: boolean; - dir?: string; - offline?: boolean; - registryUrl?: string; -}; - -export async function packageUpdateCommand(name: string | undefined, opts: PackageUpdateOptions = {}): Promise { - const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); - const lockfile = readLockfile(projectDir); - - const installed = Object.entries(lockfile.packages); - if (installed.length === 0) { - console.log(chalk.dim('No packages installed.')); - return; - } - - const spinner = ora('Loading registry…').start(); - let registry; - try { - registry = await loadRegistry({ offline: opts.offline, registryUrl: opts.registryUrl }); - spinner.stop(); - } catch (err) { - spinner.fail(`Failed to load registry: ${(err as Error).message}`); - process.exitCode = 1; - return; - } - - const targets = name ? installed.filter(([id]) => id === name) : installed; - - if (name && targets.length === 0) { - console.log(chalk.red(`"${name}" is not installed.`)); - process.exitCode = 1; - return; - } - - const toUpdate: ResolvedPackage[] = []; - for (const [id, current] of targets) { - if (current.trust === 'experimental') { - console.log(chalk.dim(` Skipping "${id}" (experimental — re-install with --from-url to update)`)); - continue; - } - const entry = registry.packages[id]; - if (!entry) { - console.log(chalk.yellow(` "${id}" not found in registry — skipping`)); - continue; - } - if (entry.source.tag === current.version) { - console.log(chalk.dim(` ${id} is already up to date (${current.version})`)); - continue; - } - console.log(` ${chalk.bold(id)}: ${current.version} → ${entry.source.tag}`); - toUpdate.push({ id, entry, files: entry.install.files }); - } - - if (opts.dryRun || toUpdate.length === 0) return; - - const results = await showInstallProgress({ packages: toUpdate, lockfile, projectDir }); - if (results.every((r) => r.ok)) { - writeLockfile(projectDir, lockfile); - } else { - process.exitCode = 1; - } -} - -export type PackageRemoveOptions = { - dir?: string; - yes?: boolean; -}; - -export async function packageRemoveCommand(name: string, opts: PackageRemoveOptions = {}): Promise { - const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); - const lockfile = readLockfile(projectDir); - - const entry = lockfile.packages[name]; - if (!entry) { - console.log(chalk.red(`"${name}" is not installed.`)); - process.exitCode = 1; - return; - } - - const existingFiles = entry.files.filter((file) => existsSync(join(projectDir, file.target))); - if (!opts.yes && (!process.stdin.isTTY || !process.stdout.isTTY)) { - console.log(style.danger(`Refusing to remove "${name}" without --yes in non-interactive mode.`)); - process.exitCode = 1; - return; - } - - if (!opts.yes) { - const confirmed = await confirmAction({ - title: 'feather package remove', - label: `Remove package "${name}"?`, - hint: 'This deletes installed files and updates feather.lock.json.', - danger: true, - rows: [ - ...existingFiles.map((file) => file.target), - 'feather.lock.json', - ], - }); - if (!confirmed) { - console.log(chalk.dim('Package remove cancelled.')); - return; - } - } - - for (const file of entry.files) { - const abs = join(projectDir, file.target); - if (existsSync(abs)) { - rmSync(abs); - console.log(style.muted(` removed ${file.target}`)); - } - } - - removeFromLockfile(lockfile, name); - writeLockfile(projectDir, lockfile); - console.log(` ${icon.success} ${chalk.bold(name)} removed.`); -} - -export type PackageAddOptions = { - dir?: string; -}; - -export async function packageAddCommand(opts: PackageAddOptions = {}): Promise { - if (!process.stdin.isTTY || !process.stdout.isTTY || typeof process.stdin.setRawMode !== 'function') { - console.log(statusLine('error', '`feather package add` requires an interactive terminal.')); - console.log( - style.muted( - 'Run it from a real TTY, or use `feather package install --from-url --target --allow-untrusted` for scripts.', - ), - ); - process.exitCode = 1; - return; - } - - const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); - const lockfile = readLockfile(projectDir); - await showAddWizard({ projectDir, lockfile }); -} - -export type PackageAuditOptions = { - dir?: string; - json?: boolean; -}; - -export async function packageAuditCommand(opts: PackageAuditOptions = {}): Promise { - const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); - const lockfile = readLockfile(projectDir); - - const entries = Object.values(lockfile.packages); - if (entries.length === 0) { - console.log(chalk.dim('No packages installed.')); - return; - } - - const spinner = ora('Auditing…').start(); - const results = await auditLockfile(projectDir, lockfile); - spinner.stop(); - - if (opts.json) { - console.log(JSON.stringify(results, null, 2)); - if (results.some((r) => r.status !== 'verified')) process.exitCode = 1; - return; - } - - console.log(chalk.bold(`\nAuditing ${entries.length} installed package(s)…\n`)); - - const maxId = Math.max(...results.map((r) => r.id.length)); - for (const r of results) { - if (r.status === 'verified') { - console.log(` ${chalk.green('✔')} ${r.id.padEnd(maxId + 2)} ${chalk.dim(r.target)} ${chalk.green('verified')}`); - } else if (r.status === 'missing') { - console.log( - ` ${chalk.yellow('!')} ${r.id.padEnd(maxId + 2)} ${chalk.dim(r.target)} ${chalk.yellow('missing')}`, - ); - } else { - console.log( - ` ${chalk.red('✖')} ${r.id.padEnd(maxId + 2)} ${chalk.dim(r.target)} ${chalk.red('MODIFIED ← SHA-256 mismatch')}`, - ); - } - } - - const bad = results.filter((r) => r.status !== 'verified'); - console.log(); - if (bad.length === 0) { - console.log(chalk.green.bold('All packages verified.')); - } else { - console.log( - chalk.red.bold( - `${bad.length} issue(s) found. Re-install affected packages with \`feather package install \`.`, - ), - ); - process.exitCode = 1; - } - console.log(); -} diff --git a/cli/src/commands/package/add.ts b/cli/src/commands/package/add.ts new file mode 100644 index 00000000..19cda6a7 --- /dev/null +++ b/cli/src/commands/package/add.ts @@ -0,0 +1,16 @@ +import { readLockfile } from '../../lib/package/lockfile.js'; +import { showAddWizard } from '../../ui/package-add.js'; +import { ensurePackageAddInteractive, resolvePackageProjectDir } from './shared.js'; + +export type PackageAddOptions = { + dir?: string; +}; + +export async function packageAddCommand(opts: PackageAddOptions = {}): Promise { + if (!ensurePackageAddInteractive()) return; + + const projectDir = resolvePackageProjectDir(opts.dir); + const lockfile = readLockfile(projectDir); + await showAddWizard({ projectDir, lockfile }); +} + diff --git a/cli/src/commands/package/audit.ts b/cli/src/commands/package/audit.ts new file mode 100644 index 00000000..19dbb1b5 --- /dev/null +++ b/cli/src/commands/package/audit.ts @@ -0,0 +1,63 @@ +import chalk from 'chalk'; +import ora from 'ora'; +import { auditLockfile } from '../../lib/package/audit.js'; +import { readLockfile } from '../../lib/package/lockfile.js'; +import { resolvePackageProjectDir } from './shared.js'; + +export type PackageAuditOptions = { + dir?: string; + json?: boolean; +}; + +export async function packageAuditCommand(opts: PackageAuditOptions = {}): Promise { + const projectDir = resolvePackageProjectDir(opts.dir); + const lockfile = readLockfile(projectDir); + + const entries = Object.values(lockfile.packages); + if (entries.length === 0) { + console.log(chalk.dim('No packages installed.')); + return; + } + + const spinner = ora('Auditing…').start(); + const results = await auditLockfile(projectDir, lockfile); + spinner.stop(); + + if (opts.json) { + console.log(JSON.stringify(results, null, 2)); + if (results.some((r) => r.status !== 'verified')) process.exitCode = 1; + return; + } + + console.log(chalk.bold(`\nAuditing ${entries.length} installed package(s)…\n`)); + + const maxId = Math.max(...results.map((r) => r.id.length)); + for (const r of results) { + if (r.status === 'verified') { + console.log(` ${chalk.green('✔')} ${r.id.padEnd(maxId + 2)} ${chalk.dim(r.target)} ${chalk.green('verified')}`); + } else if (r.status === 'missing') { + console.log( + ` ${chalk.yellow('!')} ${r.id.padEnd(maxId + 2)} ${chalk.dim(r.target)} ${chalk.yellow('missing')}`, + ); + } else { + console.log( + ` ${chalk.red('✖')} ${r.id.padEnd(maxId + 2)} ${chalk.dim(r.target)} ${chalk.red('MODIFIED ← SHA-256 mismatch')}`, + ); + } + } + + const bad = results.filter((r) => r.status !== 'verified'); + console.log(); + if (bad.length === 0) { + console.log(chalk.green.bold('All packages verified.')); + } else { + console.log( + chalk.red.bold( + `${bad.length} issue(s) found. Re-install affected packages with \`feather package install \`.`, + ), + ); + process.exitCode = 1; + } + console.log(); +} + diff --git a/cli/src/commands/package/index.ts b/cli/src/commands/package/index.ts new file mode 100644 index 00000000..80151b3f --- /dev/null +++ b/cli/src/commands/package/index.ts @@ -0,0 +1,9 @@ +export * from './add.js'; +export * from './audit.js'; +export * from './info.js'; +export * from './install.js'; +export * from './list.js'; +export * from './remove.js'; +export * from './search.js'; +export * from './update.js'; + diff --git a/cli/src/commands/package/info.ts b/cli/src/commands/package/info.ts new file mode 100644 index 00000000..47fdd0a1 --- /dev/null +++ b/cli/src/commands/package/info.ts @@ -0,0 +1,52 @@ +import chalk from 'chalk'; +import { readLockfile } from '../../lib/package/lockfile.js'; +import { trustBadge } from '../../lib/trust.js'; +import { loadRegistryOrExit, resolvePackageProjectDir } from './shared.js'; + +export type PackageInfoOptions = { + offline?: boolean; + dir?: string; + registryUrl?: string; +}; + +export async function packageInfoCommand(name: string, opts: PackageInfoOptions = {}): Promise { + const projectDir = resolvePackageProjectDir(opts.dir); + const lockfile = readLockfile(projectDir); + + const registry = await loadRegistryOrExit({ offline: opts.offline, registryUrl: opts.registryUrl }); + if (!registry) return; + + const entry = registry.packages[name]; + if (!entry) { + console.log(chalk.red(`Package "${name}" not found.`)); + process.exitCode = 1; + return; + } + + const installed = lockfile.packages[name]; + console.log(); + console.log(`${chalk.bold(name)} ${trustBadge(entry.trust)}`); + console.log(chalk.dim(entry.description)); + console.log(); + console.log(` Source: ${chalk.cyan(`github.com/${entry.source.repo}`)}`); + console.log(` Version: ${entry.source.tag}`); + console.log(` Tags: ${entry.tags.join(', ') || '—'}`); + if (entry.license) console.log(` License: ${entry.license}`); + if (entry.homepage) console.log(` Docs: ${chalk.cyan(entry.homepage)}`); + if (installed) { + console.log(` Status: ${chalk.green('installed')} @ ${installed.version}`); + } + if (entry.subpackages?.length) { + console.log(` Modules: ${entry.subpackages.join(', ')}`); + } + console.log(); + console.log(' Files to install:'); + for (const f of entry.install.files) { + console.log(` ${chalk.dim(f.name)} → ${f.target}`); + } + console.log(); + console.log(' Usage:'); + console.log(` ${chalk.cyan(entry.example ?? `local lib = require("${entry.require}")`)}`); + console.log(); +} + diff --git a/cli/src/commands/package/install.ts b/cli/src/commands/package/install.ts new file mode 100644 index 00000000..fc4829ae --- /dev/null +++ b/cli/src/commands/package/install.ts @@ -0,0 +1,212 @@ +import chalk from 'chalk'; +import ora from 'ora'; +import { auditLockfile } from '../../lib/package/audit.js'; +import { installFromUrl, restorePackage } from '../../lib/package/install.js'; +import { readLockfile, writeLockfile } from '../../lib/package/lockfile.js'; +import { resolveMany } from '../../lib/package/resolve.js'; +import { keyValueRows, statusLine, style } from '../../lib/output.js'; +import { trustBadge } from '../../lib/trust.js'; +import { confirmAction } from '../../ui/confirm.js'; +import { showInstallProgress } from '../../ui/package-progress.js'; +import { loadRegistryOrExit, resolvePackageProjectDir } from './shared.js'; + +export type PackageInstallOptions = { + dryRun?: boolean; + allowUntrusted?: boolean; + target?: string; + fromUrl?: string; + dir?: string; + offline?: boolean; + yes?: boolean; + registryUrl?: string; +}; + +export async function packageInstallCommand(names: string[], opts: PackageInstallOptions = {}): Promise { + const projectDir = resolvePackageProjectDir(opts.dir); + + if (opts.fromUrl) { + if (!opts.target) { + console.log(); + console.log(statusLine('error', '--target is required with --from-url')); + process.exitCode = 1; + return; + } + + if (!opts.allowUntrusted) { + console.log(); + console.log(style.warning('Installing from untrusted URL')); + for (const row of keyValueRows([ + ['URL', opts.fromUrl], + ['Target', opts.target], + ['Trust', 'experimental; not reviewed by the Feather team'], + ])) { + console.log(row); + } + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + console.log(); + console.log(style.danger('Use --allow-untrusted to confirm this source in non-interactive mode.')); + process.exitCode = 1; + return; + } + + const confirmed = await confirmAction({ + title: 'feather package install', + label: 'Install this unreviewed URL?', + hint: 'Only continue if you trust the source and target path.', + danger: true, + rows: [`URL: ${opts.fromUrl}`, `Target: ${opts.target}`], + }); + if (!confirmed) { + console.log(style.muted('Install cancelled.')); + return; + } + } + + const spinner = ora(`Fetching ${opts.fromUrl}…`).start(); + const lockfile = readLockfile(projectDir); + const result = await installFromUrl(lockfile, { + projectDir, + url: opts.fromUrl, + target: opts.target, + dryRun: opts.dryRun, + }); + + if (!result.ok) { + spinner.fail(result.error ?? 'Install failed'); + process.exitCode = 1; + return; + } + + if (opts.dryRun) { + spinner.stop(); + console.log(); + console.log(style.warning('Dry run: no files written')); + for (const row of keyValueRows([ + ['URL', opts.fromUrl], + ['SHA-256', result.sha256], + ['Size', `${result.size} bytes`], + ['Target', result.target], + ['Trust', 'experimental; not reviewed'], + ])) { + console.log(row); + } + return; + } + + spinner.succeed('Installed from URL (experimental)'); + console.log(); + for (const row of keyValueRows([ + ['SHA-256', result.sha256], + ['Target', result.target], + ['Trust', style.warning('experimental; not reviewed by the Feather team')], + ])) { + console.log(row); + } + writeLockfile(projectDir, lockfile); + return; + } + + if (names.length === 0) { + const lockfile = readLockfile(projectDir); + const entries = Object.entries(lockfile.packages).filter(([, e]) => !e.parent); + + if (entries.length === 0) { + console.log(chalk.dim('feather.lock.json is empty. Run `feather package install ` to add packages.')); + return; + } + + console.log(); + const auditResults = await auditLockfile(projectDir, lockfile); + const broken = new Set(auditResults.filter((r) => r.status !== 'verified').map((r) => r.id)); + + if (broken.size === 0) { + console.log(chalk.green(`✔ All ${entries.length} package(s) already up to date.`)); + return; + } + + let failed = false; + for (const [id, entry] of entries) { + if (!broken.has(id)) { + console.log(chalk.dim(` ${id} @ ${entry.version} — up to date`)); + continue; + } + const spinner = ora(` ${id} @ ${entry.version}`).start(); + const result = await restorePackage(id, entry, { projectDir, dryRun: opts.dryRun }); + if (result.ok) { + spinner.succeed(` ${id} @ ${entry.version}`); + } else { + spinner.fail(` ${id}: ${result.error}`); + failed = true; + } + } + + console.log(); + if (failed) process.exitCode = 1; + return; + } + + const registry = await loadRegistryOrExit({ offline: opts.offline, registryUrl: opts.registryUrl }); + if (!registry) return; + + const lockfile = readLockfile(projectDir); + const { resolved, errors } = resolveMany(names, registry); + + if (errors.length) { + for (const e of errors) console.log(chalk.red(` ✖ ${e}`)); + process.exitCode = 1; + return; + } + + const toInstall = resolved.filter((pkg) => { + if (pkg.versionOverride) return true; + const existing = lockfile.packages[pkg.id]; + if (existing && existing.version === pkg.entry.source.tag) { + console.log(chalk.dim(` ${pkg.id} is already installed at ${existing.version}`)); + return false; + } + return true; + }); + + if (toInstall.length === 0) return; + + for (const pkg of toInstall) { + if (pkg.entry.trust === 'experimental' && !opts.allowUntrusted) { + console.log(chalk.red(` "${pkg.id}" requires --allow-untrusted (trust: experimental)`)); + process.exitCode = 1; + return; + } + if (pkg.versionOverride && !opts.allowUntrusted) { + console.log( + chalk.red( + ` "${pkg.id}@${pkg.versionOverride}" requires --allow-untrusted — this version has not been reviewed by Feather`, + ), + ); + process.exitCode = 1; + return; + } + } + + if (opts.dryRun) { + console.log(); + for (const pkg of toInstall) { + const displayVersion = pkg.versionOverride ?? pkg.entry.source.tag; + console.log(` ${chalk.bold(pkg.id)} ${trustBadge(pkg.versionOverride ? 'experimental' : pkg.entry.trust)}`); + console.log(` Source: github.com/${pkg.entry.source.repo} Version: ${displayVersion}`); + for (const f of pkg.files) { + console.log(` ${chalk.dim(f.name)} → ${f.target}`); + } + console.log(); + } + console.log(chalk.yellow('Dry run — no files written.')); + return; + } + + const results = await showInstallProgress({ packages: toInstall, lockfile, projectDir, targetOverride: opts.target }); + if (results.every((r) => r.ok)) { + writeLockfile(projectDir, lockfile); + } else { + process.exitCode = 1; + } +} + diff --git a/cli/src/commands/package/list.ts b/cli/src/commands/package/list.ts new file mode 100644 index 00000000..3b3c966b --- /dev/null +++ b/cli/src/commands/package/list.ts @@ -0,0 +1,77 @@ +import chalk from 'chalk'; +import { readLockfile, writeLockfile } from '../../lib/package/lockfile.js'; +import { resolveMany } from '../../lib/package/resolve.js'; +import { trustBadge } from '../../lib/trust.js'; +import { showPackageBrowser } from '../../ui/package-workflow.js'; +import { showInstallProgress } from '../../ui/package-progress.js'; +import { packageRemoveCommand } from './remove.js'; +import { loadRegistryOrExit, resolvePackageProjectDir } from './shared.js'; + +export type PackageListOptions = { + installed?: boolean; + offline?: boolean; + refresh?: boolean; + dir?: string; + registryUrl?: string; +}; + +export async function packageListCommand(opts: PackageListOptions = {}): Promise { + const projectDir = resolvePackageProjectDir(opts.dir); + + if (opts.installed) { + const lockfile = readLockfile(projectDir); + const entries = Object.entries(lockfile.packages); + if (entries.length === 0) { + console.log(chalk.dim('No packages installed. Run `feather package install `.')); + return; + } + for (const [id, entry] of entries) { + console.log(` ${trustBadge(entry.trust)} ${chalk.bold(id)} @ ${entry.version}`); + } + console.log(chalk.dim(`\n${entries.length} package(s) installed.`)); + return; + } + + const registry = await loadRegistryOrExit({ + offline: opts.offline, + refresh: opts.refresh, + registryUrl: opts.registryUrl, + }); + if (!registry) return; + + const lockfile = readLockfile(projectDir); + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + const entries = Object.entries(registry.packages).filter(([, e]) => !e.parent); + for (const [id, entry] of entries.sort(([a], [b]) => a.localeCompare(b))) { + const installed = lockfile.packages[id]; + const installedLabel = installed ? chalk.cyan(` (installed ${installed.version})`) : ''; + console.log(` ${trustBadge(entry.trust)} ${chalk.bold(id)}${installedLabel} ${chalk.dim(entry.description)}`); + } + console.log(chalk.dim(`\n${entries.length} available.`)); + return; + } + + const result = await showPackageBrowser({ registry, lockfile }); + if (result.action === 'cancel') return; + + const { resolved, errors } = resolveMany([result.id], registry); + if (errors.length) { + for (const e of errors) console.log(chalk.red(` ✖ ${e}`)); + process.exitCode = 1; + return; + } + + if (result.action === 'remove') { + await packageRemoveCommand(result.id, { dir: opts.dir }); + return; + } + + const installResults = await showInstallProgress({ packages: resolved, lockfile, projectDir }); + if (installResults.every((r) => r.ok)) { + writeLockfile(projectDir, lockfile); + } else { + process.exitCode = 1; + } +} + diff --git a/cli/src/commands/package/remove.ts b/cli/src/commands/package/remove.ts new file mode 100644 index 00000000..2afc8c71 --- /dev/null +++ b/cli/src/commands/package/remove.ts @@ -0,0 +1,61 @@ +import { existsSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import chalk from 'chalk'; +import { readLockfile, removeFromLockfile, writeLockfile } from '../../lib/package/lockfile.js'; +import { icon, style } from '../../lib/output.js'; +import { confirmAction } from '../../ui/confirm.js'; +import { resolvePackageProjectDir } from './shared.js'; + +export type PackageRemoveOptions = { + dir?: string; + yes?: boolean; +}; + +export async function packageRemoveCommand(name: string, opts: PackageRemoveOptions = {}): Promise { + const projectDir = resolvePackageProjectDir(opts.dir); + const lockfile = readLockfile(projectDir); + + const entry = lockfile.packages[name]; + if (!entry) { + console.log(chalk.red(`"${name}" is not installed.`)); + process.exitCode = 1; + return; + } + + const existingFiles = entry.files.filter((file) => existsSync(join(projectDir, file.target))); + if (!opts.yes && (!process.stdin.isTTY || !process.stdout.isTTY)) { + console.log(style.danger(`Refusing to remove "${name}" without --yes in non-interactive mode.`)); + process.exitCode = 1; + return; + } + + if (!opts.yes) { + const confirmed = await confirmAction({ + title: 'feather package remove', + label: `Remove package "${name}"?`, + hint: 'This deletes installed files and updates feather.lock.json.', + danger: true, + rows: [ + ...existingFiles.map((file) => file.target), + 'feather.lock.json', + ], + }); + if (!confirmed) { + console.log(chalk.dim('Package remove cancelled.')); + return; + } + } + + for (const file of entry.files) { + const abs = join(projectDir, file.target); + if (existsSync(abs)) { + rmSync(abs); + console.log(style.muted(` removed ${file.target}`)); + } + } + + removeFromLockfile(lockfile, name); + writeLockfile(projectDir, lockfile); + console.log(` ${icon.success} ${chalk.bold(name)} removed.`); +} + diff --git a/cli/src/commands/package/search.ts b/cli/src/commands/package/search.ts new file mode 100644 index 00000000..860aff9a --- /dev/null +++ b/cli/src/commands/package/search.ts @@ -0,0 +1,39 @@ +import chalk from 'chalk'; +import { trustBadge } from '../../lib/trust.js'; +import { loadRegistryOrExit } from './shared.js'; + +export type PackageSearchOptions = { + offline?: boolean; + registryUrl?: string; +}; + +export async function packageSearchCommand(query: string | undefined, opts: PackageSearchOptions = {}): Promise { + const registry = await loadRegistryOrExit(opts); + if (!registry) return; + + const q = query?.toLowerCase(); + const entries = Object.entries(registry.packages).filter(([, entry]) => !entry.parent); + + const matches = q + ? entries.filter( + ([id, entry]) => + id.includes(q) || entry.description.toLowerCase().includes(q) || entry.tags.some((t) => t.includes(q)), + ) + : entries; + + if (matches.length === 0) { + console.log(chalk.dim(`No packages found${q ? ` matching "${query}"` : ''}.`)); + return; + } + + const maxId = Math.max(...matches.map(([id]) => id.length)); + for (const [id, entry] of matches.sort(([a], [b]) => a.localeCompare(b))) { + const badge = trustBadge(entry.trust); + console.log(` ${chalk.bold(id.padEnd(maxId + 2))} ${badge} ${chalk.dim(entry.description)}`); + if (entry.subpackages?.length) { + console.log(chalk.dim(` ${''.padEnd(maxId + 2)} subpackages: ${entry.subpackages.join(', ')}`)); + } + } + console.log(chalk.dim(`\n${matches.length} package(s). Run \`feather package info \` for details.`)); +} + diff --git a/cli/src/commands/package/shared.ts b/cli/src/commands/package/shared.ts new file mode 100644 index 00000000..55cdf1fa --- /dev/null +++ b/cli/src/commands/package/shared.ts @@ -0,0 +1,41 @@ +import { resolve } from 'node:path'; +import ora from 'ora'; +import { findProjectDir } from '../../lib/paths.js'; +import { loadRegistry, type Registry, type RegistryLoadOptions } from '../../lib/package/registry.js'; +import { statusLine, style } from '../../lib/output.js'; + +export function resolvePackageProjectDir(dir?: string): string { + return dir ? resolve(dir) : findProjectDir(); +} + +export async function loadRegistryOrExit( + opts: RegistryLoadOptions, + message = 'Loading registry…', +): Promise { + const spinner = ora(message).start(); + try { + const registry = await loadRegistry(opts); + spinner.stop(); + return registry; + } catch (err) { + spinner.fail(`Failed to load registry: ${(err as Error).message}`); + process.exitCode = 1; + return null; + } +} + +export function ensurePackageAddInteractive(): boolean { + if (process.stdin.isTTY && process.stdout.isTTY && typeof process.stdin.setRawMode === 'function') { + return true; + } + + console.log(statusLine('error', '`feather package add` requires an interactive terminal.')); + console.log( + style.muted( + 'Run it from a real TTY, or use `feather package install --from-url --target --allow-untrusted` for scripts.', + ), + ); + process.exitCode = 1; + return false; +} + diff --git a/cli/src/commands/package/update.ts b/cli/src/commands/package/update.ts new file mode 100644 index 00000000..6d3ae368 --- /dev/null +++ b/cli/src/commands/package/update.ts @@ -0,0 +1,63 @@ +import chalk from 'chalk'; +import { readLockfile, writeLockfile } from '../../lib/package/lockfile.js'; +import type { ResolvedPackage } from '../../lib/package/resolve.js'; +import { showInstallProgress } from '../../ui/package-progress.js'; +import { loadRegistryOrExit, resolvePackageProjectDir } from './shared.js'; + +export type PackageUpdateOptions = { + dryRun?: boolean; + dir?: string; + offline?: boolean; + registryUrl?: string; +}; + +export async function packageUpdateCommand(name: string | undefined, opts: PackageUpdateOptions = {}): Promise { + const projectDir = resolvePackageProjectDir(opts.dir); + const lockfile = readLockfile(projectDir); + + const installed = Object.entries(lockfile.packages); + if (installed.length === 0) { + console.log(chalk.dim('No packages installed.')); + return; + } + + const registry = await loadRegistryOrExit({ offline: opts.offline, registryUrl: opts.registryUrl }); + if (!registry) return; + + const targets = name ? installed.filter(([id]) => id === name) : installed; + + if (name && targets.length === 0) { + console.log(chalk.red(`"${name}" is not installed.`)); + process.exitCode = 1; + return; + } + + const toUpdate: ResolvedPackage[] = []; + for (const [id, current] of targets) { + if (current.trust === 'experimental') { + console.log(chalk.dim(` Skipping "${id}" (experimental — re-install with --from-url to update)`)); + continue; + } + const entry = registry.packages[id]; + if (!entry) { + console.log(chalk.yellow(` "${id}" not found in registry — skipping`)); + continue; + } + if (entry.source.tag === current.version) { + console.log(chalk.dim(` ${id} is already up to date (${current.version})`)); + continue; + } + console.log(` ${chalk.bold(id)}: ${current.version} → ${entry.source.tag}`); + toUpdate.push({ id, entry, files: entry.install.files }); + } + + if (opts.dryRun || toUpdate.length === 0) return; + + const results = await showInstallProgress({ packages: toUpdate, lockfile, projectDir }); + if (results.every((r) => r.ok)) { + writeLockfile(projectDir, lockfile); + } else { + process.exitCode = 1; + } +} + diff --git a/cli/src/commands/plugin.ts b/cli/src/commands/plugin.ts index c7fb242f..cf1ca558 100644 --- a/cli/src/commands/plugin.ts +++ b/cli/src/commands/plugin.ts @@ -1,301 +1,2 @@ -import { existsSync, rmSync } from "node:fs"; -import { join, resolve } from "node:path"; -import chalk from "chalk"; -import ora from "ora"; -import { - fetchManifest, - getLocalPluginIds, - getPluginIds, - installPlugin, - installPluginsFromLocal, - normalizeInstallDir, -} from "../lib/install.js"; -import { choosePluginUpdateWorkflow, choosePluginWorkflow } from "../ui/plugin-workflow.js"; -import { confirmAction } from "../ui/confirm.js"; -import { findProjectDir, resolveLocalLuaRoot } from "../lib/paths.js"; -import { findInstalledPluginDirs, readPluginManifest } from "../lib/plugin-utils.js"; -import { icon, statusLine, style, table } from "../lib/output.js"; +export * from './plugin/index.js'; -function pluginsDir(projectDir: string, installDir = "feather"): string { - return join(projectDir, normalizeInstallDir(installDir), "plugins"); -} - -function getInstalledPluginIds(projectDir: string, installDir = "feather"): string[] { - const dirPath = pluginsDir(projectDir, installDir); - if (!existsSync(dirPath)) return []; - - return findInstalledPluginDirs(dirPath) - .map((dir) => readPluginManifest(dir)?.id) - .filter((id): id is string => Boolean(id)) - .sort(); -} - -export async function pluginListCommand(dir?: string, installDir = "feather"): Promise { - const projectDir = dir ? resolve(dir) : findProjectDir(); - const dirPath = pluginsDir(projectDir, installDir); - - if (!existsSync(dirPath)) { - console.log(chalk.dim("No plugins directory found. Run `feather init` first.")); - return; - } - - const dirs = findInstalledPluginDirs(dirPath); - - if (dirs.length === 0) { - console.log(chalk.dim("No plugins installed.")); - return; - } - - console.log(chalk.bold(`\nInstalled plugins (${dirs.length})\n`)); - const rows = dirs.map((dir) => { - const meta = readPluginManifest(dir); - return { - id: meta?.id ?? dir.replace(dirPath, "").replace(/^[/\\]/, ""), - version: meta?.version ?? "", - name: meta?.name ?? "", - }; - }); - for (const line of table({ - columns: [ - { key: "id", label: "ID", color: (value) => chalk.cyan(value) }, - { key: "version", label: "VERSION", color: (value) => chalk.dim(value) }, - { key: "name", label: "NAME" }, - ], - rows, - })) { - console.log(line); - } - console.log(); -} - -export async function pluginInstallCommand( - pluginId: string, - opts: { dir?: string; branch?: string; installDir?: string; remote?: boolean; localSrc?: string } -): Promise { - const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); - const branch = opts.branch ?? "main"; - const installDir = opts.installDir ?? "feather"; - - if (!opts.remote) { - const sourceRoot = resolveLocalLuaRoot(opts); - const available = getLocalPluginIds(sourceRoot); - if (!available.includes(pluginId)) { - console.error(chalk.red(`Unknown plugin: ${pluginId}`)); - console.log(chalk.dim("Available: " + available.join(", "))); - process.exit(1); - } - - const spinner = ora(`Copying ${pluginId}…`).start(); - try { - installPluginsFromLocal([pluginId], sourceRoot, projectDir, installDir); - spinner.succeed(`Installed ${pluginId}`); - } catch (err) { - spinner.fail((err as Error).message); - process.exit(1); - } - return; - } - - const spinner = ora("Fetching manifest…").start(); - let entries: Awaited>; - try { - entries = await fetchManifest(branch); - spinner.succeed("Manifest loaded"); - } catch (err) { - spinner.fail((err as Error).message); - process.exit(1); - } - - const available = getPluginIds(entries); - if (!available.includes(pluginId)) { - console.error(chalk.red(`Unknown plugin: ${pluginId}`)); - console.log(chalk.dim("Available: " + available.join(", "))); - process.exit(1); - } - - const installSpinner = ora(`Installing ${pluginId}…`).start(); - try { - await installPlugin(pluginId, entries, projectDir, branch, undefined, installDir); - installSpinner.succeed(`Installed ${pluginId}`); - } catch (err) { - installSpinner.fail((err as Error).message); - process.exit(1); - } -} - -export async function pluginRemoveCommand( - pluginId: string, - opts: { dir?: string; installDir?: string; yes?: boolean }, -): Promise { - const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); - const pluginDir = join(pluginsDir(projectDir, opts.installDir), pluginId.replace(/\./g, "/")); - - if (!existsSync(pluginDir)) { - console.error(statusLine("error", `Plugin not found: ${pluginId}`)); - process.exit(1); - } - - if (!opts.yes && (!process.stdin.isTTY || !process.stdout.isTTY)) { - console.log(style.danger(`Refusing to remove "${pluginId}" without --yes in non-interactive mode.`)); - process.exitCode = 1; - return; - } - - if (!opts.yes) { - const confirmed = await confirmAction({ - title: "feather plugin remove", - label: `Remove plugin "${pluginId}"?`, - hint: "This recursively deletes the installed plugin directory.", - danger: true, - rows: [pluginDir], - }); - if (!confirmed) { - console.log(chalk.dim("Plugin remove cancelled.")); - return; - } - } - - rmSync(pluginDir, { recursive: true, force: true }); - console.log(`${icon.success} Removed ${pluginId}`); -} - -export async function pluginUpdateCommand( - pluginId: string | undefined, - opts: { dir?: string; branch?: string; installDir?: string; remote?: boolean; localSrc?: string; yes?: boolean } -): Promise { - const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); - const branch = opts.branch ?? "main"; - const installDir = opts.installDir ?? "feather"; - const dirPath = pluginsDir(projectDir, installDir); - - const hasExplicitSource = opts.remote === true || !!opts.localSrc || opts.yes === true; - if (!pluginId && process.stdin.isTTY && !hasExplicitSource) { - const installedIds = getInstalledPluginIds(projectDir, installDir); - if (installedIds.length === 0) { - console.log(chalk.dim("No plugins installed.")); - return; - } - - const result = await choosePluginUpdateWorkflow({ - installedIds, - defaultBranch: branch, - }); - - if (result.action === "cancel") return; - if (result.action !== "update" || result.pluginIds.length === 0) { - console.log(chalk.dim("No plugins selected.")); - return; - } - - for (const id of result.pluginIds) { - await pluginUpdateCommand(id, { - dir: projectDir, - branch: result.branch, - installDir, - remote: result.source === "remote", - localSrc: opts.localSrc, - yes: true, - }); - } - return; - } - - if (!opts.remote) { - const sourceRoot = resolveLocalLuaRoot(opts); - const available = getLocalPluginIds(sourceRoot); - const ids = pluginId ? [pluginId] : available.filter((id) => existsSync(join(dirPath, id.replace(/\./g, "/")))); - - for (const id of ids) { - const s = ora(`Updating ${id}…`).start(); - try { - installPluginsFromLocal([id], sourceRoot, projectDir, installDir); - s.succeed(`Updated ${id}`); - } catch (err) { - s.fail(`${id}: ${(err as Error).message}`); - } - } - return; - } - - const spinner = ora("Fetching manifest…").start(); - let entries: Awaited>; - try { - entries = await fetchManifest(branch); - spinner.succeed("Manifest loaded"); - } catch (err) { - spinner.fail((err as Error).message); - process.exit(1); - } - - const ids = pluginId ? [pluginId] : getPluginIds(entries).filter((id) => - existsSync(join(dirPath, id.replace(/\./g, "/"))) - ); - - for (const id of ids) { - const s = ora(`Updating ${id}…`).start(); - try { - await installPlugin(id, entries, projectDir, branch, undefined, installDir); - s.succeed(`Updated ${id}`); - } catch (err) { - s.fail(`${id}: ${(err as Error).message}`); - } - } -} - -export async function pluginWorkflowCommand(opts: { - dir?: string; - branch?: string; - installDir?: string; - remote?: boolean; - localSrc?: string; -}): Promise { - const projectDir = opts.dir ? resolve(opts.dir) : findProjectDir(); - const installDir = opts.installDir ?? "feather"; - const installedIds = getInstalledPluginIds(projectDir, installDir); - - const result = await choosePluginWorkflow({ - installedIds, - defaultBranch: opts.branch ?? "main", - }); - - if (result.action === "cancel") return; - if (result.action === "list") { - await pluginListCommand(projectDir, installDir); - return; - } - - if (result.pluginIds.length === 0) { - console.log(chalk.dim("No plugins selected.")); - return; - } - - if (result.action === "install") { - for (const id of result.pluginIds) { - await pluginInstallCommand(id, { - dir: projectDir, - branch: result.branch, - installDir, - remote: result.source === "remote", - localSrc: opts.localSrc, - }); - } - return; - } - - if (result.action === "update") { - for (const id of result.pluginIds) { - await pluginUpdateCommand(id, { - dir: projectDir, - branch: result.branch, - installDir, - remote: result.source === "remote", - localSrc: opts.localSrc, - }); - } - return; - } - - for (const id of result.pluginIds) { - await pluginRemoveCommand(id, { dir: projectDir, installDir, yes: true }); - } -} diff --git a/cli/src/commands/plugin/index.ts b/cli/src/commands/plugin/index.ts new file mode 100644 index 00000000..0b166320 --- /dev/null +++ b/cli/src/commands/plugin/index.ts @@ -0,0 +1,6 @@ +export * from './install.js'; +export * from './list.js'; +export * from './remove.js'; +export * from './update.js'; +export * from './workflow.js'; + diff --git a/cli/src/commands/plugin/install.ts b/cli/src/commands/plugin/install.ts new file mode 100644 index 00000000..776d670b --- /dev/null +++ b/cli/src/commands/plugin/install.ts @@ -0,0 +1,64 @@ +import chalk from 'chalk'; +import ora from 'ora'; +import { + fetchManifest, + getLocalPluginIds, + getPluginIds, + installPlugin, + installPluginsFromLocal, +} from '../../lib/install.js'; +import { resolveLocalLuaRoot } from '../../lib/paths.js'; +import { type PluginSourceOptions, resolvePluginProjectDir } from './shared.js'; + +export async function pluginInstallCommand(pluginId: string, opts: PluginSourceOptions): Promise { + const projectDir = resolvePluginProjectDir(opts.dir); + const branch = opts.branch ?? 'main'; + const installDir = opts.installDir ?? 'feather'; + + if (!opts.remote) { + const sourceRoot = resolveLocalLuaRoot(opts); + const available = getLocalPluginIds(sourceRoot); + if (!available.includes(pluginId)) { + console.error(chalk.red(`Unknown plugin: ${pluginId}`)); + console.log(chalk.dim('Available: ' + available.join(', '))); + process.exit(1); + } + + const spinner = ora(`Copying ${pluginId}…`).start(); + try { + installPluginsFromLocal([pluginId], sourceRoot, projectDir, installDir); + spinner.succeed(`Installed ${pluginId}`); + } catch (err) { + spinner.fail((err as Error).message); + process.exit(1); + } + return; + } + + const spinner = ora('Fetching manifest…').start(); + let entries: Awaited>; + try { + entries = await fetchManifest(branch); + spinner.succeed('Manifest loaded'); + } catch (err) { + spinner.fail((err as Error).message); + process.exit(1); + } + + const available = getPluginIds(entries); + if (!available.includes(pluginId)) { + console.error(chalk.red(`Unknown plugin: ${pluginId}`)); + console.log(chalk.dim('Available: ' + available.join(', '))); + process.exit(1); + } + + const installSpinner = ora(`Installing ${pluginId}…`).start(); + try { + await installPlugin(pluginId, entries, projectDir, branch, undefined, installDir); + installSpinner.succeed(`Installed ${pluginId}`); + } catch (err) { + installSpinner.fail((err as Error).message); + process.exit(1); + } +} + diff --git a/cli/src/commands/plugin/list.ts b/cli/src/commands/plugin/list.ts new file mode 100644 index 00000000..97886f13 --- /dev/null +++ b/cli/src/commands/plugin/list.ts @@ -0,0 +1,44 @@ +import { existsSync } from 'node:fs'; +import chalk from 'chalk'; +import { findInstalledPluginDirs, readPluginManifest } from '../../lib/plugin-utils.js'; +import { table } from '../../lib/output.js'; +import { pluginsDir, resolvePluginProjectDir } from './shared.js'; + +export async function pluginListCommand(dir?: string, installDir = 'feather'): Promise { + const projectDir = resolvePluginProjectDir(dir); + const dirPath = pluginsDir(projectDir, installDir); + + if (!existsSync(dirPath)) { + console.log(chalk.dim('No plugins directory found. Run `feather init` first.')); + return; + } + + const dirs = findInstalledPluginDirs(dirPath); + + if (dirs.length === 0) { + console.log(chalk.dim('No plugins installed.')); + return; + } + + console.log(chalk.bold(`\nInstalled plugins (${dirs.length})\n`)); + const rows = dirs.map((dir) => { + const meta = readPluginManifest(dir); + return { + id: meta?.id ?? dir.replace(dirPath, '').replace(/^[/\\]/, ''), + version: meta?.version ?? '', + name: meta?.name ?? '', + }; + }); + for (const line of table({ + columns: [ + { key: 'id', label: 'ID', color: (value) => chalk.cyan(value) }, + { key: 'version', label: 'VERSION', color: (value) => chalk.dim(value) }, + { key: 'name', label: 'NAME' }, + ], + rows, + })) { + console.log(line); + } + console.log(); +} + diff --git a/cli/src/commands/plugin/remove.ts b/cli/src/commands/plugin/remove.ts new file mode 100644 index 00000000..6e58b7b6 --- /dev/null +++ b/cli/src/commands/plugin/remove.ts @@ -0,0 +1,43 @@ +import { existsSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import chalk from 'chalk'; +import { icon, statusLine, style } from '../../lib/output.js'; +import { confirmAction } from '../../ui/confirm.js'; +import { pluginsDir, resolvePluginProjectDir } from './shared.js'; + +export async function pluginRemoveCommand( + pluginId: string, + opts: { dir?: string; installDir?: string; yes?: boolean }, +): Promise { + const projectDir = resolvePluginProjectDir(opts.dir); + const pluginDir = join(pluginsDir(projectDir, opts.installDir), pluginId.replace(/\./g, '/')); + + if (!existsSync(pluginDir)) { + console.error(statusLine('error', `Plugin not found: ${pluginId}`)); + process.exit(1); + } + + if (!opts.yes && (!process.stdin.isTTY || !process.stdout.isTTY)) { + console.log(style.danger(`Refusing to remove "${pluginId}" without --yes in non-interactive mode.`)); + process.exitCode = 1; + return; + } + + if (!opts.yes) { + const confirmed = await confirmAction({ + title: 'feather plugin remove', + label: `Remove plugin "${pluginId}"?`, + hint: 'This recursively deletes the installed plugin directory.', + danger: true, + rows: [pluginDir], + }); + if (!confirmed) { + console.log(chalk.dim('Plugin remove cancelled.')); + return; + } + } + + rmSync(pluginDir, { recursive: true, force: true }); + console.log(`${icon.success} Removed ${pluginId}`); +} + diff --git a/cli/src/commands/plugin/shared.ts b/cli/src/commands/plugin/shared.ts new file mode 100644 index 00000000..e390e9d8 --- /dev/null +++ b/cli/src/commands/plugin/shared.ts @@ -0,0 +1,32 @@ +import { existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { normalizeInstallDir } from '../../lib/install.js'; +import { findProjectDir } from '../../lib/paths.js'; +import { findInstalledPluginDirs, readPluginManifest } from '../../lib/plugin-utils.js'; + +export type PluginSourceOptions = { + dir?: string; + branch?: string; + installDir?: string; + remote?: boolean; + localSrc?: string; +}; + +export function resolvePluginProjectDir(dir?: string): string { + return dir ? resolve(dir) : findProjectDir(); +} + +export function pluginsDir(projectDir: string, installDir = 'feather'): string { + return join(projectDir, normalizeInstallDir(installDir), 'plugins'); +} + +export function getInstalledPluginIds(projectDir: string, installDir = 'feather'): string[] { + const dirPath = pluginsDir(projectDir, installDir); + if (!existsSync(dirPath)) return []; + + return findInstalledPluginDirs(dirPath) + .map((dir) => readPluginManifest(dir)?.id) + .filter((id): id is string => Boolean(id)) + .sort(); +} + diff --git a/cli/src/commands/plugin/update.ts b/cli/src/commands/plugin/update.ts new file mode 100644 index 00000000..d89c2598 --- /dev/null +++ b/cli/src/commands/plugin/update.ts @@ -0,0 +1,98 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import chalk from 'chalk'; +import ora from 'ora'; +import { + fetchManifest, + getLocalPluginIds, + getPluginIds, + installPlugin, + installPluginsFromLocal, +} from '../../lib/install.js'; +import { resolveLocalLuaRoot } from '../../lib/paths.js'; +import { choosePluginUpdateWorkflow } from '../../ui/plugin-workflow.js'; +import { getInstalledPluginIds, pluginsDir, resolvePluginProjectDir } from './shared.js'; + +export async function pluginUpdateCommand( + pluginId: string | undefined, + opts: { dir?: string; branch?: string; installDir?: string; remote?: boolean; localSrc?: string; yes?: boolean }, +): Promise { + const projectDir = resolvePluginProjectDir(opts.dir); + const branch = opts.branch ?? 'main'; + const installDir = opts.installDir ?? 'feather'; + const dirPath = pluginsDir(projectDir, installDir); + + const hasExplicitSource = opts.remote === true || !!opts.localSrc || opts.yes === true; + if (!pluginId && process.stdin.isTTY && !hasExplicitSource) { + const installedIds = getInstalledPluginIds(projectDir, installDir); + if (installedIds.length === 0) { + console.log(chalk.dim('No plugins installed.')); + return; + } + + const result = await choosePluginUpdateWorkflow({ + installedIds, + defaultBranch: branch, + }); + + if (result.action === 'cancel') return; + if (result.action !== 'update' || result.pluginIds.length === 0) { + console.log(chalk.dim('No plugins selected.')); + return; + } + + for (const id of result.pluginIds) { + await pluginUpdateCommand(id, { + dir: projectDir, + branch: result.branch, + installDir, + remote: result.source === 'remote', + localSrc: opts.localSrc, + yes: true, + }); + } + return; + } + + if (!opts.remote) { + const sourceRoot = resolveLocalLuaRoot(opts); + const available = getLocalPluginIds(sourceRoot); + const ids = pluginId ? [pluginId] : available.filter((id) => existsSync(join(dirPath, id.replace(/\./g, '/')))); + + for (const id of ids) { + const s = ora(`Updating ${id}…`).start(); + try { + installPluginsFromLocal([id], sourceRoot, projectDir, installDir); + s.succeed(`Updated ${id}`); + } catch (err) { + s.fail(`${id}: ${(err as Error).message}`); + } + } + return; + } + + const spinner = ora('Fetching manifest…').start(); + let entries: Awaited>; + try { + entries = await fetchManifest(branch); + spinner.succeed('Manifest loaded'); + } catch (err) { + spinner.fail((err as Error).message); + process.exit(1); + } + + const ids = pluginId ? [pluginId] : getPluginIds(entries).filter((id) => + existsSync(join(dirPath, id.replace(/\./g, '/'))) + ); + + for (const id of ids) { + const s = ora(`Updating ${id}…`).start(); + try { + await installPlugin(id, entries, projectDir, branch, undefined, installDir); + s.succeed(`Updated ${id}`); + } catch (err) { + s.fail(`${id}: ${(err as Error).message}`); + } + } +} + diff --git a/cli/src/commands/plugin/workflow.ts b/cli/src/commands/plugin/workflow.ts new file mode 100644 index 00000000..7328ec80 --- /dev/null +++ b/cli/src/commands/plugin/workflow.ts @@ -0,0 +1,66 @@ +import chalk from 'chalk'; +import { choosePluginWorkflow } from '../../ui/plugin-workflow.js'; +import { pluginInstallCommand } from './install.js'; +import { pluginListCommand } from './list.js'; +import { pluginRemoveCommand } from './remove.js'; +import { getInstalledPluginIds, resolvePluginProjectDir } from './shared.js'; +import { pluginUpdateCommand } from './update.js'; + +export async function pluginWorkflowCommand(opts: { + dir?: string; + branch?: string; + installDir?: string; + remote?: boolean; + localSrc?: string; +}): Promise { + const projectDir = resolvePluginProjectDir(opts.dir); + const installDir = opts.installDir ?? 'feather'; + const installedIds = getInstalledPluginIds(projectDir, installDir); + + const result = await choosePluginWorkflow({ + installedIds, + defaultBranch: opts.branch ?? 'main', + }); + + if (result.action === 'cancel') return; + if (result.action === 'list') { + await pluginListCommand(projectDir, installDir); + return; + } + + if (result.pluginIds.length === 0) { + console.log(chalk.dim('No plugins selected.')); + return; + } + + if (result.action === 'install') { + for (const id of result.pluginIds) { + await pluginInstallCommand(id, { + dir: projectDir, + branch: result.branch, + installDir, + remote: result.source === 'remote', + localSrc: opts.localSrc, + }); + } + return; + } + + if (result.action === 'update') { + for (const id of result.pluginIds) { + await pluginUpdateCommand(id, { + dir: projectDir, + branch: result.branch, + installDir, + remote: result.source === 'remote', + localSrc: opts.localSrc, + }); + } + return; + } + + for (const id of result.pluginIds) { + await pluginRemoveCommand(id, { dir: projectDir, installDir, yes: true }); + } +} + diff --git a/cli/src/hooks/use-text-input.tsx b/cli/src/hooks/use-text-input.tsx index d4cb9004..611e4884 100644 --- a/cli/src/hooks/use-text-input.tsx +++ b/cli/src/hooks/use-text-input.tsx @@ -1,5 +1,5 @@ import { useInput } from 'ink'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; type InkKey = Parameters[0]>[1]; @@ -12,6 +12,10 @@ function useTextInput(initial: string) { setCursor(next.length); }; + useEffect(() => { + reset(initial); + }, [initial]); + const handleKey = (input: string, key: InkKey) => { if (key.leftArrow) { setCursor((c) => Math.max(0, c - 1)); diff --git a/cli/src/ui/package-add.tsx b/cli/src/ui/package-add.tsx index 84ea37c0..9dfb65ae 100644 --- a/cli/src/ui/package-add.tsx +++ b/cli/src/ui/package-add.tsx @@ -375,6 +375,7 @@ function Wizard({ projectDir, lockfile }: { projectDir: string; lockfile: Lockfi if (step === 'id') { return ( Date: Sat, 16 May 2026 00:20:26 -0400 Subject: [PATCH 23/68] cli: improve package add codebase --- cli/src/lib/package/custom-add.ts | 129 +++++++++++ cli/src/lib/package/install.ts | 52 +++-- cli/src/lib/package/target.ts | 13 ++ cli/src/ui/init-mode.tsx | 299 +++++++++++++++++++++---- cli/src/ui/package-add-url.tsx | 359 ------------------------------ cli/src/ui/package-add.tsx | 52 ++--- cli/test/package.test.mjs | 138 ++++++++++++ 7 files changed, 584 insertions(+), 458 deletions(-) create mode 100644 cli/src/lib/package/custom-add.ts create mode 100644 cli/src/lib/package/target.ts delete mode 100644 cli/src/ui/package-add-url.tsx diff --git a/cli/src/lib/package/custom-add.ts b/cli/src/lib/package/custom-add.ts new file mode 100644 index 00000000..369ebb2b --- /dev/null +++ b/cli/src/lib/package/custom-add.ts @@ -0,0 +1,129 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { sha256Buffer } from "./checksum.js"; +import { addToLockfile, type Lockfile, writeLockfile } from "./lockfile.js"; +import { resolveProjectTarget } from "./target.js"; + +export type CustomPackageFile = { + name: string; + url?: string; + target: string; + sha256: string; +}; + +export type CustomPackageInstallResult = { + ok: boolean; + files: CustomPackageFile[]; + error?: string; +}; + +export type CustomRepoPackageInput = { + id: string; + repoName: string; + tag: string; + baseUrl: string; + selectedFiles: string[]; + targetMap: Record; + projectDir: string; + lockfile: Lockfile; + onFileStart?: (name: string) => void; +}; + +export type CustomUrlFileInput = { + name: string; + url: string; + sha256: string; + target: string; + buffer: Buffer; +}; + +export type CustomUrlPackageInput = { + id: string; + urlFiles: CustomUrlFileInput[]; + projectDir: string; + lockfile: Lockfile; +}; + +function validateTargets(projectDir: string, files: Array<{ target: string }>): string[] | { error: string } { + const targets: string[] = []; + for (const file of files) { + const absoluteTarget = resolveProjectTarget(projectDir, file.target); + if (!absoluteTarget) return { error: `Target path escapes project root: ${file.target}` }; + targets.push(absoluteTarget); + } + return targets; +} + +export async function installCustomRepoPackage(input: CustomRepoPackageInput): Promise { + const plannedFiles = input.selectedFiles.map((name) => ({ + name, + target: input.targetMap[name] ?? name, + })); + const targets = validateTargets(input.projectDir, plannedFiles); + if ("error" in targets) return { ok: false, files: [], error: targets.error }; + + const lockedFiles: CustomPackageFile[] = []; + + for (const [index, file] of plannedFiles.entries()) { + input.onFileStart?.(file.name); + + const url = input.baseUrl + file.name; + let res: Response; + try { + res = await fetch(url, { signal: AbortSignal.timeout(30_000) }); + } catch (err) { + return { ok: false, files: lockedFiles, error: `Network error: ${(err as Error).message}` }; + } + if (!res.ok) return { ok: false, files: lockedFiles, error: `HTTP ${res.status} fetching ${url}` }; + + const buffer = Buffer.from(await res.arrayBuffer()); + const sha256 = sha256Buffer(buffer); + const absoluteTarget = targets[index]!; + mkdirSync(dirname(absoluteTarget), { recursive: true }); + writeFileSync(absoluteTarget, buffer); + lockedFiles.push({ name: file.name, url, target: file.target, sha256 }); + } + + addToLockfile(input.lockfile, input.id, { + version: input.tag, + trust: "experimental", + source: { repo: input.repoName, tag: input.tag }, + files: lockedFiles, + }); + writeLockfile(input.projectDir, input.lockfile); + + return { ok: true, files: lockedFiles }; +} + +export async function installCustomUrlPackage(input: CustomUrlPackageInput): Promise { + if (input.urlFiles.length === 0) return { ok: false, files: [], error: "No URL files selected" }; + + const targets = validateTargets(input.projectDir, input.urlFiles); + if ("error" in targets) return { ok: false, files: [], error: targets.error }; + + const lockedFiles = input.urlFiles.map((file) => { + const actualSha256 = sha256Buffer(file.buffer); + return { + name: file.name, + url: file.url, + target: file.target, + sha256: actualSha256, + }; + }); + + for (const [index, file] of input.urlFiles.entries()) { + const absoluteTarget = targets[index]!; + mkdirSync(dirname(absoluteTarget), { recursive: true }); + writeFileSync(absoluteTarget, file.buffer); + } + + addToLockfile(input.lockfile, input.id, { + version: "url", + trust: "experimental", + source: { url: input.urlFiles[0]!.url }, + files: lockedFiles, + }); + writeLockfile(input.projectDir, input.lockfile); + + return { ok: true, files: lockedFiles }; +} diff --git a/cli/src/lib/package/install.ts b/cli/src/lib/package/install.ts index dbb985cc..3dc3f357 100644 --- a/cli/src/lib/package/install.ts +++ b/cli/src/lib/package/install.ts @@ -1,7 +1,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, join, resolve as resolvePath } from "node:path"; +import { dirname, join } from "node:path"; import { sha256Buffer } from "./checksum.js"; import { addToLockfile, type Lockfile, type LockfileEntry } from "./lockfile.js"; +import { resolveProjectTarget } from "./target.js"; import type { ResolvedPackage } from "./resolve.js"; export type InstallOptions = { @@ -58,12 +59,6 @@ async function downloadLive(url: string, repo: string, version: string): Promise return Buffer.from(await res.arrayBuffer()); } -function safeTarget(projectDir: string, relTarget: string): string | null { - const abs = resolvePath(projectDir, relTarget); - if (!abs.startsWith(resolvePath(projectDir))) return null; - return abs; -} - export async function installPackage( pkg: ResolvedPackage, lockfile: Lockfile, @@ -80,16 +75,29 @@ export async function installPackage( ? src.baseUrl.replace(src.tag, pkg.versionOverride) : (src.baseUrl ?? ''); - for (const file of pkg.files) { - const relTarget = targetOverride + const plannedFiles = pkg.files.map((file) => ({ + file, + relTarget: targetOverride ? join(targetOverride, file.name.split("/").pop()!) - : file.target; + : file.target, + })); + const resolvedTargets: string[] = []; - const absTarget = safeTarget(projectDir, relTarget); + for (const { file, relTarget } of plannedFiles) { + const absTarget = resolveProjectTarget(projectDir, relTarget); if (!absTarget) { - fileResults.push({ name: file.name, target: relTarget, sha256: "", ok: false, error: "Target path escapes project root" }); - continue; + return { + id: pkg.id, + ok: false, + files: [{ name: file.name, target: relTarget, sha256: "", ok: false, error: "Target path escapes project root" }], + error: "Target path escapes project root", + }; } + resolvedTargets.push(absTarget); + } + + for (const [index, { file, relTarget }] of plannedFiles.entries()) { + const absTarget = resolvedTargets[index]!; const url = file.url ?? baseUrl + file.name; @@ -179,7 +187,7 @@ export async function installFromUrl( return { ok: true, sha256: hash, size: buf.byteLength, target }; } - const absTarget = safeTarget(projectDir, target); + const absTarget = resolveProjectTarget(projectDir, target); if (!absTarget) return { ok: false, sha256: hash, size: buf.byteLength, target, error: "Target path escapes project root" }; mkdirSync(dirname(absTarget), { recursive: true }); @@ -210,13 +218,23 @@ export async function restorePackage( ): Promise { const { projectDir, dryRun, onFileStart, onFileComplete } = opts; const fileResults: InstallFileResult[] = []; + const resolvedTargets: string[] = []; for (const file of entry.files) { - const absTarget = safeTarget(projectDir, file.target); + const absTarget = resolveProjectTarget(projectDir, file.target); if (!absTarget) { - fileResults.push({ name: file.name, target: file.target, sha256: "", ok: false, error: "Target path escapes project root" }); - continue; + return { + id, + ok: false, + files: [{ name: file.name, target: file.target, sha256: "", ok: false, error: "Target path escapes project root" }], + error: "Target path escapes project root", + }; } + resolvedTargets.push(absTarget); + } + + for (const [index, file] of entry.files.entries()) { + const absTarget = resolvedTargets[index]!; // Skip files already on disk with the correct locked checksum if (!dryRun && existsSync(absTarget)) { diff --git a/cli/src/lib/package/target.ts b/cli/src/lib/package/target.ts new file mode 100644 index 00000000..55992db7 --- /dev/null +++ b/cli/src/lib/package/target.ts @@ -0,0 +1,13 @@ +import { isAbsolute, relative, resolve } from "node:path"; + +export function resolveProjectTarget(projectDir: string, target: string): string | null { + if (!target || isAbsolute(target)) return null; + + const root = resolve(projectDir); + const absoluteTarget = resolve(root, target); + const relativeTarget = relative(root, absoluteTarget); + + if (relativeTarget.startsWith("..") || isAbsolute(relativeTarget)) return null; + return absoluteTarget; +} + diff --git a/cli/src/ui/init-mode.tsx b/cli/src/ui/init-mode.tsx index 8b62bc9c..5bfeb05a 100644 --- a/cli/src/ui/init-mode.tsx +++ b/cli/src/ui/init-mode.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from "react"; +import React, { type ReactNode, useMemo, useState } from "react"; import { Box, Text, render, useApp, useInput } from "ink"; import { copyToClipboard } from "../lib/clipboard.js"; import { pluginCatalog } from "../generated/plugin-catalog.js"; @@ -50,6 +50,35 @@ type Option = { description?: string; }; +type Tone = "info" | "success" | "warning" | "danger"; + +type SummaryRow = { + id: string; + label: string; + value: ReactNode; + tone?: Tone; +}; + +const dangerousInsecureConnection = "__DANGEROUS_INSECURE_CONNECTION__"; +const dangerousPluginIds = new Set(["console", "hot-reload"]); +const dangerousToggleIds = new Set(["debugger", "captureScreenshot"]); +const warningToggleIds = new Set(["autoRegisterErrorHandler", "writeToDisk"]); + +const toneColor = (tone?: Tone) => { + if (tone === "danger") return "red"; + if (tone === "warning") return "yellow"; + if (tone === "success") return "green"; + if (tone === "info") return "cyan"; + return undefined; +}; + +const pluginTone = (value: string): Tone | undefined => (dangerousPluginIds.has(value) ? "danger" : undefined); +const toggleTone = (value: string): Tone | undefined => { + if (dangerousToggleIds.has(value)) return "danger"; + if (warningToggleIds.has(value)) return "warning"; + return undefined; +}; + const modes: Option[] = [ { value: "cli", @@ -164,11 +193,80 @@ const parseCapabilities = (value: string): string[] | "all" => { .filter(Boolean); }; -function cursorLine(active: boolean, text: string, description?: string) { +function DangerousName({ children }: { children: string }) { + return ( + + {children} + + ); +} + +function NameList({ + values, + getTone, +}: { + values: string[]; + getTone?: (value: string) => Tone | undefined; +}) { + if (values.length === 0) return (none); + + return ( + <> + {values.map((value, index) => ( + + {index > 0 ? , : null} + {value} + + ))} + + ); +} + +function SummaryValue({ value, tone }: { value: ReactNode; tone?: Tone }) { + if (typeof value === "string") return {value}; + return <>{value}; +} + +function SummaryRows({ rows }: { rows: SummaryRow[] }) { + return ( + + {rows.map((row) => ( + + {row.label.padEnd(17)} + + + ))} + + ); +} + +function InfoPanel({ + title, + tone = "info", + children, +}: { + title: string; + tone?: Tone; + children: ReactNode; +}) { + return ( + + + {title} + + + {children} + + + ); +} + +function cursorLine(active: boolean, text: string, description?: string, tone?: Tone) { return ( - - {active ? "❯" : " "} {text} + + {active ? "❯" : " "}{" "} + {text} {description ? {description} : null} @@ -204,17 +302,21 @@ function SingleSelect({ title, options, selected, + getTone, }: { title: string; options: Option[]; selected: number; + getTone?: (option: Option) => Tone | undefined; }) { return ( {title} {options.map((option, index) => ( - {cursorLine(index === selected, `${index + 1}. ${option.label}`, option.description)} + + {cursorLine(index === selected, `${index + 1}. ${option.label}`, option.description, getTone?.(option))} + ))} ↑↓ or j/k navigate · 1-{options.length} jump · Enter select @@ -227,12 +329,14 @@ function MultiSelect({ options, selected, cursor, + getTone, hint = "↑↓ or j/k navigate · Space toggle · a select all · Enter confirm", }: { title: string; options: Option[]; selected: Set; cursor: number; + getTone?: (option: Option) => Tone | undefined; hint?: string; }) { return ( @@ -240,14 +344,20 @@ function MultiSelect({ {title} {options.length === 0 ? No options available. : null} - {options.map((option, index) => ( - - - {index === cursor ? "❯" : " "} {selected.has(option.value) ? "◉" : "○"} {option.label} - - {option.description ? {option.description} : null} - - ))} + {options.map((option, index) => { + const tone = getTone?.(option); + return ( + + + + {index === cursor ? "❯" : " "} {selected.has(option.value) ? "◉" : "○"} + {" "} + {option.label} + + {option.description ? {option.description} : null} + + ); + })} {hint} @@ -602,28 +712,58 @@ function InitSetupPrompt({ if (key.return) finish(); }); - const summary = useMemo(() => { - const rows = [ - `Mode: ${modes[modeIndex].label}`, - installsFiles ? `Install dir: ${installDir || "feather"}/` : "Install dir: bundled CLI runtime", - installsFiles ? `Source: ${installSources[sourceIndex].label}` : undefined, - installsFiles && installSource === "remote" ? `Branch: ${branch || "main"}` : undefined, - installsFiles ? `Install plugins: ${installPlugins ? "yes" : "no"}` : undefined, - `Session: ${sessionName || "(default)"}`, - pluginPromptsEnabled ? `Include: ${include.size > 0 ? [...include].join(", ") : "(none)"}` : undefined, - pluginPromptsEnabled ? `Exclude: ${exclude.size > 0 ? [...exclude].join(", ") : "(none)"}` : undefined, - advanced ? "Advanced config: yes" : "Advanced config: no", - ].filter(Boolean) as string[]; + const summary = useMemo(() => { + const includeList = [...include]; + const excludeList = [...exclude]; + const rows: SummaryRow[] = [ + { id: "mode", label: "Mode", value: modes[modeIndex].label }, + { + id: "install-dir", + label: "Install dir", + value: installsFiles ? `${installDir || "feather"}/` : "bundled CLI runtime", + }, + installsFiles ? { id: "source", label: "Source", value: installSources[sourceIndex].label } : undefined, + installsFiles && installSource === "remote" ? { id: "branch", label: "Branch", value: branch || "main" } : undefined, + installsFiles ? { id: "plugins", label: "Install plugins", value: installPlugins ? "yes" : "no" } : undefined, + { id: "session", label: "Session", value: sessionName || "(default)" }, + pluginPromptsEnabled + ? { id: "include", label: "Include", value: } + : undefined, + pluginPromptsEnabled + ? { id: "exclude", label: "Exclude", value: } + : undefined, + { id: "advanced", label: "Advanced config", value: advanced ? "yes" : "no", tone: advanced ? "warning" : undefined }, + ].filter(Boolean) as SummaryRow[]; + if (needsApiKey) { - rows.push( - apiKeyCopied === true - ? "Console API key: set and copied to clipboard" - : apiKeyCopied === false - ? "Console API key: set (clipboard copy unavailable)" - : "Console API key: set", - ); + rows.push({ + id: "api-key", + label: "Console API key", + value: + apiKeyCopied === true + ? "set and copied to clipboard" + : apiKeyCopied === false + ? "set (clipboard copy unavailable)" + : "set", + tone: "success", + }); } - rows.push(appIdInput.trim() ? `App ID: ${appIdInput.trim()}` : "App ID: not set → __DANGEROUS_INSECURE_CONNECTION__ = true (any Feather desktop can connect)"); + rows.push( + appIdInput.trim() + ? { id: "app-id", label: "App ID", value: appIdInput.trim(), tone: "success" } + : { + id: "app-id", + label: "App ID", + value: ( + <> + not set → + {dangerousInsecureConnection} + = true + + ), + tone: "danger", + }, + ); return rows; }, [ advanced, @@ -646,7 +786,17 @@ function InitSetupPrompt({ if (phase === "mode") return ; if (phase === "source") return ; if (phase === "installPlugins") return ; - if (phase === "include") return ; + if (phase === "include") { + return ( + pluginTone(option.value)} + /> + ); + } if (phase === "exclude") { return ( pluginTone(option.value)} /> ); } @@ -670,20 +821,34 @@ function InitSetupPrompt({ /> ); } - if (phase === "toggles") return ; + if (phase === "toggles") { + return ( + toggleTone(option.value)} + /> + ); + } if (phase === "apiKey") { return ; } if (phase === "appId") { return ( - - Find it in the Feather desktop app → Settings → Security → Desktop App ID. - If skipped, __DANGEROUS_INSECURE_CONNECTION__ = true is written to feather.config.lua so any Feather desktop can send commands to the game. + + + Feather desktop app → Settings → Security → Desktop App ID. + + + + Writes {dangerousInsecureConnection} + = true to feather.config.lua. + + Use only for trusted local development. Any Feather desktop can connect to the game. + ); } @@ -692,14 +857,52 @@ function InitSetupPrompt({ return ; } + const enabledAdvancedOptions = advanced ? configToggles.filter((option) => toggles.has(option.value)).map((option) => option.value) : []; + const writesRuntimeFiles = mode !== "cli"; + const patchesMainLua = mode === "auto"; + return ( - Ready to initialize Feather - - {summary.map((row) => ( - {row} - ))} - + + Ready to initialize Feather + + + + {writesRuntimeFiles ? ( + <> + + Runtime files will be written under {installDir || "feather"}/. + + {patchesMainLua ? ( + main.lua will be patched with a USE_DEBUGGER-guarded require. + ) : ( + feather.debugger.lua will be created for manual loading. + )} + + ) : ( + No runtime files will be copied; the CLI runtime is used at launch. + )} + + + {appIdInput.trim() ? ( + Desktop connections are limited to the configured App ID. + ) : ( + <> + + {dangerousInsecureConnection} + is enabled. + + Any Feather desktop can send commands to this game while it is running. + + )} + + {advanced ? ( + + + Enabled: + + + ) : null} Press Enter to continue. ); diff --git a/cli/src/ui/package-add-url.tsx b/cli/src/ui/package-add-url.tsx deleted file mode 100644 index 816e8a4e..00000000 --- a/cli/src/ui/package-add-url.tsx +++ /dev/null @@ -1,359 +0,0 @@ -import { render, Text, Box, useInput, useApp } from 'ink'; -import { useState, useEffect } from 'react'; -import { mkdirSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import type { Lockfile } from '../lib/package/lockfile.js'; -import { addToLockfile, writeLockfile } from '../lib/package/lockfile.js'; -import { - type UrlFile, - Header, - TextInputStep, - FileFetchStep, - FileMoreStep, -} from './components.js'; - -type Step = - | 'id' - | 'file-url' - | 'file-fetch' - | 'file-target' - | 'file-more' - | 'require' - | 'confirm' - | 'write' - | 'done' - | 'error'; - -const TOTAL = 5; - -function fileNameFromUrl(url: string): string { - try { - const path = new URL(url).pathname; - return path.split('/').filter(Boolean).pop() ?? 'file.lua'; - } catch { - return url.split('/').pop() ?? 'file.lua'; - } -} - -function ConfirmStep({ - id, - urlFiles, - onConfirm, - onAbort, -}: { - id: string; - urlFiles: UrlFile[]; - onConfirm: () => void; - onAbort: () => void; -}) { - useInput((input, key) => { - if (input === 'y' || input === 'Y' || key.return) onConfirm(); - else if (input === 'n' || input === 'N' || key.escape) onAbort(); - }); - - return ( - -
- {' '}Review before installing - - - {' '}Package: {id} - - - {' '}Trust: experimental ⚠ - - {' '}These files have NOT been reviewed by the Feather team. - - - {urlFiles.map((f) => ( - - - {' '} - {f.name} → {f.target} - - - {' sha256: '} - {f.sha256.slice(0, 24)}… - - - ))} - - - {' y/Enter = install · n/Esc = abort'} - - - ); -} - -function WriteStep({ - id, - urlFiles, - projectDir, - lockfile, - onDone, - onError, -}: { - id: string; - urlFiles: UrlFile[]; - projectDir: string; - lockfile: Lockfile; - onDone: () => void; - onError: (msg: string) => void; -}) { - const [status, setStatus] = useState<'running' | 'done' | 'error'>('running'); - const [error, setError] = useState(''); - - useEffect(() => { - const run = async () => { - for (const f of urlFiles) { - const abs = join(projectDir, f.target); - mkdirSync(dirname(abs), { recursive: true }); - writeFileSync(abs, f.buffer); - } - addToLockfile(lockfile, id, { - version: 'url', - trust: 'experimental', - source: { url: urlFiles[0]!.url }, - files: urlFiles.map((f) => ({ name: f.name, url: f.url, target: f.target, sha256: f.sha256 })), - }); - writeLockfile(projectDir, lockfile); - setStatus('done'); - onDone(); - }; - run().catch((err: Error) => { - setError(err.message); - setStatus('error'); - onError(err.message); - }); - }, []); - - return ( - - {status === 'running' && Installing…} - {status === 'done' && ✔ Done} - {status === 'error' && ✖ {error}} - - ); -} - -function DoneStep({ - id, - urlFiles, - require: requirePath, - onExit, -}: { - id: string; - urlFiles: UrlFile[]; - require: string; - onExit: () => void; -}) { - useInput((_, key) => { - if (key.return || key.escape) onExit(); - }); - - return ( - - - ✔ Installed - - {urlFiles.map((f) => ( - - {' '} - {f.name} → {f.target} - - ))} - - - Usage:{' '} - - local {id.replace(/[.-]/g, '_')} = require('{requirePath}') - - - - - Trust: experimental ⚠ — not reviewed by the Feather team - - - Press Enter to exit - - - ); -} - -function Wizard({ projectDir, lockfile }: { projectDir: string; lockfile: Lockfile }) { - const { exit } = useApp(); - const [step, setStep] = useState('id'); - const [id, setId] = useState(''); - const [requirePath, setRequirePath] = useState(''); - const [urlFiles, setUrlFiles] = useState([]); - const [currentUrl, setCurrentUrl] = useState(''); - const [currentSha, setCurrentSha] = useState(''); - const [currentBuffer, setCurrentBuffer] = useState(Buffer.alloc(0)); - const [errorMsg, setErrorMsg] = useState(''); - - const handleError = (msg: string) => { - setErrorMsg(msg); - setStep('error'); - }; - - if (step === 'id') { - return ( - { - if (!v) return 'Required'; - if (!/^[a-z0-9][a-z0-9.-]*$/.test(v)) return 'Use only a-z, 0-9, dots, hyphens'; - if (lockfile.packages[v]) return `"${v}" is already installed`; - return null; - }} - onSubmit={(v) => { - setId(v); - setStep('file-url'); - }} - /> - ); - } - - if (step === 'file-url') { - const n = urlFiles.length; - return ( - { - if (!v) return 'Required'; - try { - new URL(v); - } catch { - return 'Must be a valid URL'; - } - return null; - }} - onSubmit={(url) => { - setCurrentUrl(url); - setStep('file-fetch'); - }} - /> - ); - } - - if (step === 'file-fetch') { - return ( - { - setCurrentSha(sha256); - setCurrentBuffer(buffer); - setStep('file-target'); - }} - onError={handleError} - /> - ); - } - - if (step === 'file-target') { - const name = fileNameFromUrl(currentUrl); - const suggested = urlFiles.length === 0 ? `lib/${name}` : `lib/${id}/${name}`; - return ( - { - if (!v) return 'Required'; - if (!v.endsWith('.lua')) return 'Must end in .lua'; - return null; - }} - onSubmit={(target) => { - setUrlFiles((fs) => [...fs, { name, url: currentUrl, sha256: currentSha, target, buffer: currentBuffer }]); - setStep('file-more'); - }} - /> - ); - } - - if (step === 'file-more') { - return ( - setStep('file-url')} - onNo={() => setStep('require')} - /> - ); - } - - if (step === 'require') { - const firstName = urlFiles[0]?.name ?? `${id}.lua`; - const suggested = `lib.${firstName.replace(/\.lua$/, '').replace(/\//g, '.')}`; - return ( - (v ? null : 'Required')} - onSubmit={(v) => { - setRequirePath(v); - setStep('confirm'); - }} - /> - ); - } - - if (step === 'confirm') { - return ( - setStep('write')} - onAbort={() => { - setErrorMsg('Aborted.'); - setStep('error'); - }} - /> - ); - } - - if (step === 'write') { - return ( - setStep('done')} - onError={handleError} - /> - ); - } - - if (step === 'done') { - return ; - } - - return ( - - - ✖ Error - - {errorMsg} - - ); -} - -export async function showAddFromUrlWizard(opts: { projectDir: string; lockfile: Lockfile }): Promise { - const { waitUntilExit } = render(, { - alternateScreen: true, - }); - await waitUntilExit(); -} diff --git a/cli/src/ui/package-add.tsx b/cli/src/ui/package-add.tsx index 9dfb65ae..38143076 100644 --- a/cli/src/ui/package-add.tsx +++ b/cli/src/ui/package-add.tsx @@ -1,10 +1,7 @@ import { render, Text, Box, useInput, useApp } from 'ink'; import { useState, useEffect } from 'react'; -import { mkdirSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { sha256Buffer } from '../lib/package/checksum.js'; import type { Lockfile } from '../lib/package/lockfile.js'; -import { addToLockfile, writeLockfile } from '../lib/package/lockfile.js'; +import { installCustomRepoPackage, installCustomUrlPackage } from '../lib/package/custom-add.js'; import { type UrlFile, TextInputStep, @@ -206,26 +203,18 @@ function InstallStep({ const [current, setCurrent] = useState(''); useEffect(() => { const run = async () => { - const lockedFiles: { name: string; url: string; target: string; sha256: string }[] = []; - for (const name of selectedFiles) { - setCurrent(name); - const url = baseUrl + name; - const res = await fetch(url, { signal: AbortSignal.timeout(30_000) }); - if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`); - const buf = Buffer.from(await res.arrayBuffer()); - const hash = sha256Buffer(buf); - const target = targetMap[name]!; - mkdirSync(dirname(join(projectDir, target)), { recursive: true }); - writeFileSync(join(projectDir, target), buf); - lockedFiles.push({ name, url, target, sha256: hash }); - } - addToLockfile(lockfile, id, { - version: tag, - trust: 'experimental', - source: { repo: repoName, tag }, - files: lockedFiles, + const result = await installCustomRepoPackage({ + id, + repoName, + tag, + baseUrl, + selectedFiles, + targetMap, + projectDir, + lockfile, + onFileStart: setCurrent, }); - writeLockfile(projectDir, lockfile); + if (!result.ok) throw new Error(result.error ?? 'Install failed'); onDone(); }; run().catch((err: Error) => onError(err.message)); @@ -254,18 +243,13 @@ function WriteStep({ }) { useEffect(() => { const run = async () => { - for (const f of urlFiles) { - const abs = join(projectDir, f.target); - mkdirSync(dirname(abs), { recursive: true }); - writeFileSync(abs, f.buffer); - } - addToLockfile(lockfile, id, { - version: 'url', - trust: 'experimental', - source: { url: urlFiles[0]!.url }, - files: urlFiles.map((f) => ({ name: f.name, url: f.url, target: f.target, sha256: f.sha256 })), + const result = await installCustomUrlPackage({ + id, + urlFiles, + projectDir, + lockfile, }); - writeLockfile(projectDir, lockfile); + if (!result.ok) throw new Error(result.error ?? 'Install failed'); onDone(); }; run().catch((err: Error) => onError(err.message)); diff --git a/cli/test/package.test.mjs b/cli/test/package.test.mjs index 4e89c15b..1282421c 100644 --- a/cli/test/package.test.mjs +++ b/cli/test/package.test.mjs @@ -657,3 +657,141 @@ test('audit --json: url package included in output', () => { assert.ok(entry, 'my-helper should appear in audit output'); assert.equal(entry.status, 'verified'); }); + +async function withFetchMock(mock, runTest) { + const originalFetch = globalThis.fetch; + globalThis.fetch = mock; + try { + await runTest(); + } finally { + globalThis.fetch = originalFetch; + } +} + +function emptyLockfile() { + return { lockfileVersion: 1, generatedAt: new Date(0).toISOString(), packages: {} }; +} + +test('custom add: repo install writes selected files and lockfile metadata', async () => { + const dir = makeTmp(); + const { installCustomRepoPackage } = await import('../dist/lib/package/custom-add.js'); + const files = new Map([ + ['https://raw.githubusercontent.com/me/pkg/abc123/init.lua', 'return "init"'], + ['https://raw.githubusercontent.com/me/pkg/abc123/util.lua', 'return "util"'], + ]); + + await withFetchMock( + async (url) => { + const body = files.get(String(url)); + return body === undefined ? new Response('missing', { status: 404 }) : new Response(body); + }, + async () => { + const result = await installCustomRepoPackage({ + id: 'my-pkg', + repoName: 'me/pkg', + tag: 'v1.0.0', + baseUrl: 'https://raw.githubusercontent.com/me/pkg/abc123/', + selectedFiles: ['init.lua', 'util.lua'], + targetMap: { 'init.lua': 'lib/my-pkg/init.lua', 'util.lua': 'lib/my-pkg/util.lua' }, + projectDir: dir, + lockfile: emptyLockfile(), + }); + + assert.equal(result.ok, true); + assert.equal(readFileSync(join(dir, 'lib', 'my-pkg', 'init.lua'), 'utf8'), 'return "init"'); + assert.equal(readFileSync(join(dir, 'lib', 'my-pkg', 'util.lua'), 'utf8'), 'return "util"'); + + const lock = JSON.parse(readFileSync(join(dir, 'feather.lock.json'), 'utf8')); + assert.equal(lock.packages['my-pkg'].version, 'v1.0.0'); + assert.equal(lock.packages['my-pkg'].trust, 'experimental'); + assert.deepEqual(lock.packages['my-pkg'].source, { repo: 'me/pkg', tag: 'v1.0.0' }); + assert.equal(lock.packages['my-pkg'].files.length, 2); + assert.equal(lock.packages['my-pkg'].files[0].url, 'https://raw.githubusercontent.com/me/pkg/abc123/init.lua'); + }, + ); +}); + +test('custom add: URL install writes buffered files and lockfile metadata', async () => { + const dir = makeTmp(); + const { installCustomUrlPackage } = await import('../dist/lib/package/custom-add.js'); + const buffer = Buffer.from('return "helper"'); + const result = await installCustomUrlPackage({ + id: 'my-helper', + urlFiles: [ + { + name: 'helper.lua', + url: 'https://example.com/helper.lua', + sha256: 'stale-sha-is-recomputed', + target: 'lib/helper.lua', + buffer, + }, + ], + projectDir: dir, + lockfile: emptyLockfile(), + }); + + assert.equal(result.ok, true); + assert.equal(readFileSync(join(dir, 'lib', 'helper.lua'), 'utf8'), 'return "helper"'); + const lock = JSON.parse(readFileSync(join(dir, 'feather.lock.json'), 'utf8')); + assert.equal(lock.packages['my-helper'].version, 'url'); + assert.equal(lock.packages['my-helper'].trust, 'experimental'); + assert.deepEqual(lock.packages['my-helper'].source, { url: 'https://example.com/helper.lua' }); + assert.equal(lock.packages['my-helper'].files[0].sha256, sha256(buffer)); +}); + +test('custom add: escaping target is rejected before fetch or write', async () => { + const dir = makeTmp(); + const { installCustomRepoPackage } = await import('../dist/lib/package/custom-add.js'); + let fetchCalled = false; + + await withFetchMock( + async () => { + fetchCalled = true; + return new Response('return {}'); + }, + async () => { + const result = await installCustomRepoPackage({ + id: 'escape', + repoName: 'me/pkg', + tag: 'v1.0.0', + baseUrl: 'https://raw.githubusercontent.com/me/pkg/abc123/', + selectedFiles: ['escape.lua'], + targetMap: { 'escape.lua': '../escape.lua' }, + projectDir: dir, + lockfile: emptyLockfile(), + }); + + assert.equal(result.ok, false); + assert.equal(fetchCalled, false); + assert.ok(result.error.includes('escapes project root')); + assert.equal(existsSync(join(dir, 'feather.lock.json')), false); + assert.equal(existsSync(join(dir, '..', 'escape.lua')), false); + }, + ); +}); + +test('custom add: failed repo fetch does not write lockfile', async () => { + const dir = makeTmp(); + const { installCustomRepoPackage } = await import('../dist/lib/package/custom-add.js'); + + await withFetchMock( + async () => new Response('missing', { status: 500 }), + async () => { + const result = await installCustomRepoPackage({ + id: 'broken', + repoName: 'me/pkg', + tag: 'v1.0.0', + baseUrl: 'https://raw.githubusercontent.com/me/pkg/abc123/', + selectedFiles: ['broken.lua'], + targetMap: { 'broken.lua': 'lib/broken.lua' }, + projectDir: dir, + lockfile: emptyLockfile(), + }); + + assert.equal(result.ok, false); + assert.ok(result.error.includes('HTTP 500')); + assert.equal(existsSync(join(dir, 'lib', 'broken.lua')), false); + assert.equal(existsSync(join(dir, 'feather.lock.json')), false); + }, + ); +}); From 4722d92a32b18faa0cdfdfe99bebd3d22540f5d5 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 00:27:13 -0400 Subject: [PATCH 24/68] cli: standarize output --- cli/src/commands/doctor/index.ts | 68 +++++++++++++++++- cli/src/commands/init.ts | 28 ++++---- cli/src/commands/package/audit.ts | 65 +++++++++-------- cli/src/commands/package/info.ts | 22 +++--- cli/src/commands/package/install.ts | 54 ++++++-------- cli/src/commands/package/list.ts | 23 +++--- cli/src/commands/package/remove.ts | 15 ++-- cli/src/commands/package/search.ts | 11 ++- cli/src/commands/package/shared.ts | 11 ++- cli/src/commands/package/update.ts | 20 +++--- cli/src/commands/plugin/install.ts | 25 +++---- cli/src/commands/plugin/remove.ts | 14 ++-- cli/src/commands/plugin/update.ts | 17 +++-- cli/src/commands/remove.ts | 11 ++- cli/src/commands/run.ts | 22 +++--- cli/src/commands/update.ts | 19 +++-- cli/src/index.ts | 107 +++++++++++----------------- cli/src/lib/command.ts | 51 +++++++++++++ cli/src/lib/output.ts | 33 +++++++++ 19 files changed, 349 insertions(+), 267 deletions(-) create mode 100644 cli/src/lib/command.ts diff --git a/cli/src/commands/doctor/index.ts b/cli/src/commands/doctor/index.ts index 2de8c2de..2a58301b 100644 --- a/cli/src/commands/doctor/index.ts +++ b/cli/src/commands/doctor/index.ts @@ -3,6 +3,10 @@ import { join, resolve } from 'node:path'; import { findLoveBinary, getLoveVersion } from '../../lib/love.js'; import { loadConfig } from '../../lib/config.js'; import { normalizeInstallDir } from '../../lib/install.js'; +import { fail } from '../../lib/command.js'; +import { printJson } from '../../lib/output.js'; +import { auditLockfile } from '../../lib/package/audit.js'; +import { readLockfile } from '../../lib/package/lockfile.js'; import { parseManagedValue, findInstalledPluginDirs, readPluginManifest } from '../../lib/plugin-utils.js'; import { add, @@ -183,6 +187,16 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) if (configSource) { const activeConfigSource = uncommentedLua(configSource); + const insecureConnection = luaBoolEnabled(activeConfigSource, '__DANGEROUS_INSECURE_CONNECTION__'); + add( + checks, + 'Safety', + '__DANGEROUS_INSECURE_CONNECTION__', + insecureConnection ? 'warn' : 'pass', + insecureConnection ? 'enabled' : 'disabled', + insecureConnection ? 'Set a desktop App ID in feather.config.lua before sharing or shipping this project.' : undefined, + ); + const captureScreenshot = luaBoolEnabled(activeConfigSource, 'captureScreenshot'); add( checks, @@ -234,6 +248,55 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) isWeakApiKey(apiKey) ? 'Set a strong per-session or config API key when using Console.' : undefined, ); } + + const debuggerEnabled = luaBoolEnabled(activeConfigSource, 'debugger'); + add( + checks, + 'Safety', + 'Step debugger', + debuggerEnabled ? 'warn' : 'pass', + debuggerEnabled ? 'enabled' : 'disabled', + debuggerEnabled ? 'Enable only for trusted development sessions.' : undefined, + ); + + const writeToDisk = luaBoolEnabled(activeConfigSource, 'writeToDisk'); + add( + checks, + 'Safety', + 'Disk logging', + writeToDisk ? 'warn' : 'pass', + writeToDisk ? 'enabled' : 'disabled', + writeToDisk ? 'Review generated log files before committing or sharing the project.' : undefined, + ); + } + + const lockfilePath = join(projectDir, 'feather.lock.json'); + if (existsSync(lockfilePath)) { + try { + const lockfile = readLockfile(projectDir); + const auditResults = await auditLockfile(projectDir, lockfile); + const badPackages = new Set(auditResults.filter((result) => result.status !== 'verified').map((result) => result.id)); + add( + checks, + 'Packages', + 'Package lockfile', + 'pass', + `${Object.keys(lockfile.packages).length} package(s)`, + undefined, + ); + add( + checks, + 'Packages', + 'Package file integrity', + badPackages.size === 0 ? 'pass' : 'warn', + badPackages.size === 0 ? 'all verified' : `${badPackages.size} package(s) need attention`, + badPackages.size === 0 ? undefined : 'Run `feather package audit`, then `feather package install` to restore missing or modified files.', + ); + } catch (err) { + add(checks, 'Packages', 'Package lockfile', 'fail', (err as Error).message, 'Fix or delete feather.lock.json and reinstall packages.'); + } + } else { + add(checks, 'Packages', 'Package lockfile', 'info', 'not present', 'Run `feather package install ` to add project dependencies.'); } const configuredPort = opts.port ?? (typeof config?.port === 'number' ? config.port : 4004); @@ -257,13 +320,12 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) const warnings = checks.filter((check) => check.severity === 'warn'); if (opts.json) { - console.log(JSON.stringify({ projectDir, installDir: effectiveInstallDir, failures: failures.length, warnings: warnings.length, checks }, null, 2)); + printJson({ projectDir, installDir: effectiveInstallDir, failures: failures.length, warnings: warnings.length, checks }); } else { renderReport(checks, projectDir); } if (failures.length > 0) { - process.exitCode = 1; + fail('', { silent: true }); } } - diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index 1f4bea97..ad81c6be 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -1,7 +1,5 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { basename, join, resolve } from 'node:path'; -import chalk from 'chalk'; -import ora from 'ora'; import { fetchManifest, getLocalPluginIds, @@ -16,7 +14,8 @@ import { configTemplate, luaKey, luaValue } from '../lib/config.js'; import { chooseInitMode, type InitMode, type InitSetup } from '../ui/init-mode.js'; import { pluginCatalog } from '../generated/plugin-catalog.js'; import { resolveLocalLuaRoot } from '../lib/paths.js'; -import { icon, statusLine, style } from '../lib/output.js'; +import { fail } from '../lib/command.js'; +import { createSpinner, icon, style } from '../lib/output.js'; export interface InitOptions { branch?: string; @@ -79,8 +78,7 @@ export async function initCommand(dir: string, opts: InitOptions): Promise const target = resolve(dir); if (!existsSync(join(target, 'main.lua'))) { - console.error(statusLine('error', `No main.lua found in ${target}. Is this a love2d project?`)); - process.exit(1); + fail(`No main.lua found in ${target}. Is this a love2d project?`); } const setup: InitSetup = @@ -124,17 +122,17 @@ export async function initCommand(dir: string, opts: InitOptions): Promise if (!alreadyInstalled) { if (useRemote) { - const spinner = ora('Fetching manifest…').start(); + const spinner = createSpinner('Fetching manifest…').start(); try { entries = await fetchManifest(branch); spinner.succeed(`Manifest loaded (${entries.length} files)`); } catch (err) { spinner.fail(`Could not fetch manifest: ${(err as Error).message}`); - process.exit(1); + fail((err as Error).message, { cause: err, silent: true }); } - const coreSpinner = ora('Installing feather core from GitHub…').start(); + const coreSpinner = createSpinner('Installing feather core from GitHub…').start(); try { await installCore(entries, target, branch, (f) => { coreSpinner.text = `Installing ${f}…`; @@ -142,14 +140,14 @@ export async function initCommand(dir: string, opts: InitOptions): Promise coreSpinner.succeed('Feather core installed'); } catch (err) { coreSpinner.fail(`Core install failed: ${(err as Error).message}`); - process.exit(1); + fail((err as Error).message, { cause: err, silent: true }); } if (!pluginsDisabled) { const excluded = new Set(setup.exclude); const pluginIds = (opts.plugins ?? getPluginIds(entries)).filter((id) => !excluded.has(id)); installedPluginIds = pluginIds; - const pluginSpinner = ora(`Installing ${pluginIds.length} plugins from GitHub…`).start(); + const pluginSpinner = createSpinner(`Installing ${pluginIds.length} plugins from GitHub…`).start(); let failed = 0; for (const id of pluginIds) { try { @@ -159,11 +157,11 @@ export async function initCommand(dir: string, opts: InitOptions): Promise failed++; } } - pluginSpinner.succeed(`Plugins installed${failed > 0 ? chalk.yellow(` (${failed} failed)`) : ''}`); + pluginSpinner.succeed(`Plugins installed${failed > 0 ? style.warning(` (${failed} failed)`) : ''}`); } } else { const sourceRoot = resolveLocalLuaRoot(opts); - const coreSpinner = ora(`Copying feather core from ${sourceRoot}…`).start(); + const coreSpinner = createSpinner(`Copying feather core from ${sourceRoot}…`).start(); try { installCoreFromLocal(sourceRoot, target, installDir, (f) => { coreSpinner.text = `Copying ${f}…`; @@ -171,14 +169,14 @@ export async function initCommand(dir: string, opts: InitOptions): Promise coreSpinner.succeed('Feather core installed'); } catch (err) { coreSpinner.fail(`Core install failed: ${(err as Error).message}`); - process.exit(1); + fail((err as Error).message, { cause: err, silent: true }); } if (!pluginsDisabled) { const excluded = new Set(setup.exclude); const pluginIds = (opts.plugins ?? getLocalPluginIds(sourceRoot)).filter((id) => !excluded.has(id)); installedPluginIds = pluginIds; - const pluginSpinner = ora(`Copying ${pluginIds.length} plugins…`).start(); + const pluginSpinner = createSpinner(`Copying ${pluginIds.length} plugins…`).start(); let failed = 0; for (const id of pluginIds) { try { @@ -188,7 +186,7 @@ export async function initCommand(dir: string, opts: InitOptions): Promise failed++; } } - pluginSpinner.succeed(`Plugins installed${failed > 0 ? chalk.yellow(` (${failed} failed)`) : ''}`); + pluginSpinner.succeed(`Plugins installed${failed > 0 ? style.warning(` (${failed} failed)`) : ''}`); } } } diff --git a/cli/src/commands/package/audit.ts b/cli/src/commands/package/audit.ts index 19dbb1b5..e5bcf887 100644 --- a/cli/src/commands/package/audit.ts +++ b/cli/src/commands/package/audit.ts @@ -1,7 +1,7 @@ -import chalk from 'chalk'; -import ora from 'ora'; import { auditLockfile } from '../../lib/package/audit.js'; import { readLockfile } from '../../lib/package/lockfile.js'; +import { fail } from '../../lib/command.js'; +import { createSpinner, icon, printJson, statusLine, style, table } from '../../lib/output.js'; import { resolvePackageProjectDir } from './shared.js'; export type PackageAuditOptions = { @@ -15,49 +15,56 @@ export async function packageAuditCommand(opts: PackageAuditOptions = {}): Promi const entries = Object.values(lockfile.packages); if (entries.length === 0) { - console.log(chalk.dim('No packages installed.')); + console.log(style.muted('No packages installed.')); return; } - const spinner = ora('Auditing…').start(); + const spinner = createSpinner('Auditing…').start(); const results = await auditLockfile(projectDir, lockfile); spinner.stop(); if (opts.json) { - console.log(JSON.stringify(results, null, 2)); - if (results.some((r) => r.status !== 'verified')) process.exitCode = 1; + printJson(results); + if (results.some((r) => r.status !== 'verified')) fail('', { silent: true }); return; } - console.log(chalk.bold(`\nAuditing ${entries.length} installed package(s)…\n`)); - - const maxId = Math.max(...results.map((r) => r.id.length)); - for (const r of results) { - if (r.status === 'verified') { - console.log(` ${chalk.green('✔')} ${r.id.padEnd(maxId + 2)} ${chalk.dim(r.target)} ${chalk.green('verified')}`); - } else if (r.status === 'missing') { - console.log( - ` ${chalk.yellow('!')} ${r.id.padEnd(maxId + 2)} ${chalk.dim(r.target)} ${chalk.yellow('missing')}`, - ); - } else { - console.log( - ` ${chalk.red('✖')} ${r.id.padEnd(maxId + 2)} ${chalk.dim(r.target)} ${chalk.red('MODIFIED ← SHA-256 mismatch')}`, - ); - } + console.log(style.heading(`\nAuditing ${entries.length} installed package(s)…\n`)); + + for (const line of table({ + columns: [ + { + key: 'status', + label: '', + color: (value, row) => + row.status === 'verified' ? style.success(value) : row.status === 'missing' ? style.warning(value) : style.danger(value), + }, + { key: 'id', label: 'PACKAGE', color: (value) => style.info(value) }, + { key: 'target', label: 'TARGET', color: (value) => style.muted(value) }, + { + key: 'message', + label: 'RESULT', + color: (value, row) => + row.status === 'verified' ? style.success(value) : row.status === 'missing' ? style.warning(value) : style.danger(value), + }, + ], + rows: results.map((result) => ({ + status: result.status === 'verified' ? icon.success : result.status === 'missing' ? icon.warning : icon.error, + id: result.id, + target: result.target, + message: result.status === 'modified' ? 'MODIFIED ← SHA-256 mismatch' : result.status, + })), + })) { + console.log(line); } const bad = results.filter((r) => r.status !== 'verified'); console.log(); if (bad.length === 0) { - console.log(chalk.green.bold('All packages verified.')); + console.log(statusLine('success', 'All packages verified.')); } else { - console.log( - chalk.red.bold( - `${bad.length} issue(s) found. Re-install affected packages with \`feather package install \`.`, - ), - ); - process.exitCode = 1; + console.log(style.danger(`${bad.length} issue(s) found. Re-install affected packages with \`feather package install \`.`)); + fail('', { silent: true }); } console.log(); } - diff --git a/cli/src/commands/package/info.ts b/cli/src/commands/package/info.ts index 47fdd0a1..bfa990e6 100644 --- a/cli/src/commands/package/info.ts +++ b/cli/src/commands/package/info.ts @@ -1,5 +1,6 @@ -import chalk from 'chalk'; import { readLockfile } from '../../lib/package/lockfile.js'; +import { fail } from '../../lib/command.js'; +import { style } from '../../lib/output.js'; import { trustBadge } from '../../lib/trust.js'; import { loadRegistryOrExit, resolvePackageProjectDir } from './shared.js'; @@ -18,23 +19,21 @@ export async function packageInfoCommand(name: string, opts: PackageInfoOptions const entry = registry.packages[name]; if (!entry) { - console.log(chalk.red(`Package "${name}" not found.`)); - process.exitCode = 1; - return; + fail(`Package "${name}" not found.`); } const installed = lockfile.packages[name]; console.log(); - console.log(`${chalk.bold(name)} ${trustBadge(entry.trust)}`); - console.log(chalk.dim(entry.description)); + console.log(`${style.heading(name)} ${trustBadge(entry.trust)}`); + console.log(style.muted(entry.description)); console.log(); - console.log(` Source: ${chalk.cyan(`github.com/${entry.source.repo}`)}`); + console.log(` Source: ${style.info(`github.com/${entry.source.repo}`)}`); console.log(` Version: ${entry.source.tag}`); console.log(` Tags: ${entry.tags.join(', ') || '—'}`); if (entry.license) console.log(` License: ${entry.license}`); - if (entry.homepage) console.log(` Docs: ${chalk.cyan(entry.homepage)}`); + if (entry.homepage) console.log(` Docs: ${style.info(entry.homepage)}`); if (installed) { - console.log(` Status: ${chalk.green('installed')} @ ${installed.version}`); + console.log(` Status: ${style.success('installed')} @ ${installed.version}`); } if (entry.subpackages?.length) { console.log(` Modules: ${entry.subpackages.join(', ')}`); @@ -42,11 +41,10 @@ export async function packageInfoCommand(name: string, opts: PackageInfoOptions console.log(); console.log(' Files to install:'); for (const f of entry.install.files) { - console.log(` ${chalk.dim(f.name)} → ${f.target}`); + console.log(` ${style.muted(f.name)} → ${f.target}`); } console.log(); console.log(' Usage:'); - console.log(` ${chalk.cyan(entry.example ?? `local lib = require("${entry.require}")`)}`); + console.log(` ${style.info(entry.example ?? `local lib = require("${entry.require}")`)}`); console.log(); } - diff --git a/cli/src/commands/package/install.ts b/cli/src/commands/package/install.ts index fc4829ae..530ee8e0 100644 --- a/cli/src/commands/package/install.ts +++ b/cli/src/commands/package/install.ts @@ -1,10 +1,9 @@ -import chalk from 'chalk'; -import ora from 'ora'; import { auditLockfile } from '../../lib/package/audit.js'; import { installFromUrl, restorePackage } from '../../lib/package/install.js'; import { readLockfile, writeLockfile } from '../../lib/package/lockfile.js'; import { resolveMany } from '../../lib/package/resolve.js'; -import { keyValueRows, statusLine, style } from '../../lib/output.js'; +import { fail } from '../../lib/command.js'; +import { createSpinner, icon, keyValueRows, statusLine, style } from '../../lib/output.js'; import { trustBadge } from '../../lib/trust.js'; import { confirmAction } from '../../ui/confirm.js'; import { showInstallProgress } from '../../ui/package-progress.js'; @@ -28,8 +27,7 @@ export async function packageInstallCommand(names: string[], opts: PackageInstal if (!opts.target) { console.log(); console.log(statusLine('error', '--target is required with --from-url')); - process.exitCode = 1; - return; + fail('', { silent: true }); } if (!opts.allowUntrusted) { @@ -46,8 +44,7 @@ export async function packageInstallCommand(names: string[], opts: PackageInstal if (!process.stdin.isTTY || !process.stdout.isTTY) { console.log(); console.log(style.danger('Use --allow-untrusted to confirm this source in non-interactive mode.')); - process.exitCode = 1; - return; + fail('', { silent: true }); } const confirmed = await confirmAction({ @@ -63,7 +60,7 @@ export async function packageInstallCommand(names: string[], opts: PackageInstal } } - const spinner = ora(`Fetching ${opts.fromUrl}…`).start(); + const spinner = createSpinner(`Fetching ${opts.fromUrl}…`).start(); const lockfile = readLockfile(projectDir); const result = await installFromUrl(lockfile, { projectDir, @@ -74,8 +71,7 @@ export async function packageInstallCommand(names: string[], opts: PackageInstal if (!result.ok) { spinner.fail(result.error ?? 'Install failed'); - process.exitCode = 1; - return; + fail(result.error ?? 'Install failed', { silent: true }); } if (opts.dryRun) { @@ -112,7 +108,7 @@ export async function packageInstallCommand(names: string[], opts: PackageInstal const entries = Object.entries(lockfile.packages).filter(([, e]) => !e.parent); if (entries.length === 0) { - console.log(chalk.dim('feather.lock.json is empty. Run `feather package install ` to add packages.')); + console.log(style.muted('feather.lock.json is empty. Run `feather package install ` to add packages.')); return; } @@ -121,17 +117,17 @@ export async function packageInstallCommand(names: string[], opts: PackageInstal const broken = new Set(auditResults.filter((r) => r.status !== 'verified').map((r) => r.id)); if (broken.size === 0) { - console.log(chalk.green(`✔ All ${entries.length} package(s) already up to date.`)); + console.log(style.success(`✔ All ${entries.length} package(s) already up to date.`)); return; } let failed = false; for (const [id, entry] of entries) { if (!broken.has(id)) { - console.log(chalk.dim(` ${id} @ ${entry.version} — up to date`)); + console.log(style.muted(` ${id} @ ${entry.version} — up to date`)); continue; } - const spinner = ora(` ${id} @ ${entry.version}`).start(); + const spinner = createSpinner(` ${id} @ ${entry.version}`).start(); const result = await restorePackage(id, entry, { projectDir, dryRun: opts.dryRun }); if (result.ok) { spinner.succeed(` ${id} @ ${entry.version}`); @@ -142,7 +138,7 @@ export async function packageInstallCommand(names: string[], opts: PackageInstal } console.log(); - if (failed) process.exitCode = 1; + if (failed) fail('', { silent: true }); return; } @@ -153,16 +149,15 @@ export async function packageInstallCommand(names: string[], opts: PackageInstal const { resolved, errors } = resolveMany(names, registry); if (errors.length) { - for (const e of errors) console.log(chalk.red(` ✖ ${e}`)); - process.exitCode = 1; - return; + for (const e of errors) console.log(` ${icon.error} ${style.danger(e)}`); + fail('', { silent: true }); } const toInstall = resolved.filter((pkg) => { if (pkg.versionOverride) return true; const existing = lockfile.packages[pkg.id]; if (existing && existing.version === pkg.entry.source.tag) { - console.log(chalk.dim(` ${pkg.id} is already installed at ${existing.version}`)); + console.log(style.muted(` ${pkg.id} is already installed at ${existing.version}`)); return false; } return true; @@ -172,18 +167,10 @@ export async function packageInstallCommand(names: string[], opts: PackageInstal for (const pkg of toInstall) { if (pkg.entry.trust === 'experimental' && !opts.allowUntrusted) { - console.log(chalk.red(` "${pkg.id}" requires --allow-untrusted (trust: experimental)`)); - process.exitCode = 1; - return; + fail(`"${pkg.id}" requires --allow-untrusted (trust: experimental)`); } if (pkg.versionOverride && !opts.allowUntrusted) { - console.log( - chalk.red( - ` "${pkg.id}@${pkg.versionOverride}" requires --allow-untrusted — this version has not been reviewed by Feather`, - ), - ); - process.exitCode = 1; - return; + fail(`"${pkg.id}@${pkg.versionOverride}" requires --allow-untrusted — this version has not been reviewed by Feather`); } } @@ -191,14 +178,14 @@ export async function packageInstallCommand(names: string[], opts: PackageInstal console.log(); for (const pkg of toInstall) { const displayVersion = pkg.versionOverride ?? pkg.entry.source.tag; - console.log(` ${chalk.bold(pkg.id)} ${trustBadge(pkg.versionOverride ? 'experimental' : pkg.entry.trust)}`); + console.log(` ${style.heading(pkg.id)} ${trustBadge(pkg.versionOverride ? 'experimental' : pkg.entry.trust)}`); console.log(` Source: github.com/${pkg.entry.source.repo} Version: ${displayVersion}`); for (const f of pkg.files) { - console.log(` ${chalk.dim(f.name)} → ${f.target}`); + console.log(` ${style.muted(f.name)} → ${f.target}`); } console.log(); } - console.log(chalk.yellow('Dry run — no files written.')); + console.log(style.warning('Dry run — no files written.')); return; } @@ -206,7 +193,6 @@ export async function packageInstallCommand(names: string[], opts: PackageInstal if (results.every((r) => r.ok)) { writeLockfile(projectDir, lockfile); } else { - process.exitCode = 1; + fail('', { silent: true }); } } - diff --git a/cli/src/commands/package/list.ts b/cli/src/commands/package/list.ts index 3b3c966b..9422d0c8 100644 --- a/cli/src/commands/package/list.ts +++ b/cli/src/commands/package/list.ts @@ -1,6 +1,7 @@ -import chalk from 'chalk'; import { readLockfile, writeLockfile } from '../../lib/package/lockfile.js'; import { resolveMany } from '../../lib/package/resolve.js'; +import { fail } from '../../lib/command.js'; +import { icon, style } from '../../lib/output.js'; import { trustBadge } from '../../lib/trust.js'; import { showPackageBrowser } from '../../ui/package-workflow.js'; import { showInstallProgress } from '../../ui/package-progress.js'; @@ -22,13 +23,13 @@ export async function packageListCommand(opts: PackageListOptions = {}): Promise const lockfile = readLockfile(projectDir); const entries = Object.entries(lockfile.packages); if (entries.length === 0) { - console.log(chalk.dim('No packages installed. Run `feather package install `.')); + console.log(style.muted('No packages installed. Run `feather package install `.')); return; } for (const [id, entry] of entries) { - console.log(` ${trustBadge(entry.trust)} ${chalk.bold(id)} @ ${entry.version}`); + console.log(` ${trustBadge(entry.trust)} ${style.heading(id)} @ ${entry.version}`); } - console.log(chalk.dim(`\n${entries.length} package(s) installed.`)); + console.log(style.muted(`\n${entries.length} package(s) installed.`)); return; } @@ -45,10 +46,10 @@ export async function packageListCommand(opts: PackageListOptions = {}): Promise const entries = Object.entries(registry.packages).filter(([, e]) => !e.parent); for (const [id, entry] of entries.sort(([a], [b]) => a.localeCompare(b))) { const installed = lockfile.packages[id]; - const installedLabel = installed ? chalk.cyan(` (installed ${installed.version})`) : ''; - console.log(` ${trustBadge(entry.trust)} ${chalk.bold(id)}${installedLabel} ${chalk.dim(entry.description)}`); + const installedLabel = installed ? style.info(` (installed ${installed.version})`) : ''; + console.log(` ${trustBadge(entry.trust)} ${style.heading(id)}${installedLabel} ${style.muted(entry.description)}`); } - console.log(chalk.dim(`\n${entries.length} available.`)); + console.log(style.muted(`\n${entries.length} available.`)); return; } @@ -57,9 +58,8 @@ export async function packageListCommand(opts: PackageListOptions = {}): Promise const { resolved, errors } = resolveMany([result.id], registry); if (errors.length) { - for (const e of errors) console.log(chalk.red(` ✖ ${e}`)); - process.exitCode = 1; - return; + for (const e of errors) console.log(` ${icon.error} ${style.danger(e)}`); + fail('', { silent: true }); } if (result.action === 'remove') { @@ -71,7 +71,6 @@ export async function packageListCommand(opts: PackageListOptions = {}): Promise if (installResults.every((r) => r.ok)) { writeLockfile(projectDir, lockfile); } else { - process.exitCode = 1; + fail('', { silent: true }); } } - diff --git a/cli/src/commands/package/remove.ts b/cli/src/commands/package/remove.ts index 2afc8c71..2603f0c8 100644 --- a/cli/src/commands/package/remove.ts +++ b/cli/src/commands/package/remove.ts @@ -1,7 +1,7 @@ import { existsSync, rmSync } from 'node:fs'; import { join } from 'node:path'; -import chalk from 'chalk'; import { readLockfile, removeFromLockfile, writeLockfile } from '../../lib/package/lockfile.js'; +import { fail } from '../../lib/command.js'; import { icon, style } from '../../lib/output.js'; import { confirmAction } from '../../ui/confirm.js'; import { resolvePackageProjectDir } from './shared.js'; @@ -17,16 +17,12 @@ export async function packageRemoveCommand(name: string, opts: PackageRemoveOpti const entry = lockfile.packages[name]; if (!entry) { - console.log(chalk.red(`"${name}" is not installed.`)); - process.exitCode = 1; - return; + fail(`"${name}" is not installed.`); } const existingFiles = entry.files.filter((file) => existsSync(join(projectDir, file.target))); if (!opts.yes && (!process.stdin.isTTY || !process.stdout.isTTY)) { - console.log(style.danger(`Refusing to remove "${name}" without --yes in non-interactive mode.`)); - process.exitCode = 1; - return; + fail(`Refusing to remove "${name}" without --yes in non-interactive mode.`); } if (!opts.yes) { @@ -41,7 +37,7 @@ export async function packageRemoveCommand(name: string, opts: PackageRemoveOpti ], }); if (!confirmed) { - console.log(chalk.dim('Package remove cancelled.')); + console.log(style.muted('Package remove cancelled.')); return; } } @@ -56,6 +52,5 @@ export async function packageRemoveCommand(name: string, opts: PackageRemoveOpti removeFromLockfile(lockfile, name); writeLockfile(projectDir, lockfile); - console.log(` ${icon.success} ${chalk.bold(name)} removed.`); + console.log(` ${icon.success} ${style.heading(name)} removed.`); } - diff --git a/cli/src/commands/package/search.ts b/cli/src/commands/package/search.ts index 860aff9a..a6adf8aa 100644 --- a/cli/src/commands/package/search.ts +++ b/cli/src/commands/package/search.ts @@ -1,4 +1,4 @@ -import chalk from 'chalk'; +import { style } from '../../lib/output.js'; import { trustBadge } from '../../lib/trust.js'; import { loadRegistryOrExit } from './shared.js'; @@ -22,18 +22,17 @@ export async function packageSearchCommand(query: string | undefined, opts: Pack : entries; if (matches.length === 0) { - console.log(chalk.dim(`No packages found${q ? ` matching "${query}"` : ''}.`)); + console.log(style.muted(`No packages found${q ? ` matching "${query}"` : ''}.`)); return; } const maxId = Math.max(...matches.map(([id]) => id.length)); for (const [id, entry] of matches.sort(([a], [b]) => a.localeCompare(b))) { const badge = trustBadge(entry.trust); - console.log(` ${chalk.bold(id.padEnd(maxId + 2))} ${badge} ${chalk.dim(entry.description)}`); + console.log(` ${style.heading(id.padEnd(maxId + 2))} ${badge} ${style.muted(entry.description)}`); if (entry.subpackages?.length) { - console.log(chalk.dim(` ${''.padEnd(maxId + 2)} subpackages: ${entry.subpackages.join(', ')}`)); + console.log(style.muted(` ${''.padEnd(maxId + 2)} subpackages: ${entry.subpackages.join(', ')}`)); } } - console.log(chalk.dim(`\n${matches.length} package(s). Run \`feather package info \` for details.`)); + console.log(style.muted(`\n${matches.length} package(s). Run \`feather package info \` for details.`)); } - diff --git a/cli/src/commands/package/shared.ts b/cli/src/commands/package/shared.ts index 55cdf1fa..1ae10f54 100644 --- a/cli/src/commands/package/shared.ts +++ b/cli/src/commands/package/shared.ts @@ -1,8 +1,8 @@ import { resolve } from 'node:path'; -import ora from 'ora'; import { findProjectDir } from '../../lib/paths.js'; import { loadRegistry, type Registry, type RegistryLoadOptions } from '../../lib/package/registry.js'; -import { statusLine, style } from '../../lib/output.js'; +import { fail } from '../../lib/command.js'; +import { createSpinner, statusLine, style } from '../../lib/output.js'; export function resolvePackageProjectDir(dir?: string): string { return dir ? resolve(dir) : findProjectDir(); @@ -12,14 +12,14 @@ export async function loadRegistryOrExit( opts: RegistryLoadOptions, message = 'Loading registry…', ): Promise { - const spinner = ora(message).start(); + const spinner = createSpinner(message).start(); try { const registry = await loadRegistry(opts); spinner.stop(); return registry; } catch (err) { spinner.fail(`Failed to load registry: ${(err as Error).message}`); - process.exitCode = 1; + fail((err as Error).message, { silent: true, cause: err }); return null; } } @@ -35,7 +35,6 @@ export function ensurePackageAddInteractive(): boolean { 'Run it from a real TTY, or use `feather package install --from-url --target --allow-untrusted` for scripts.', ), ); - process.exitCode = 1; + fail('', { silent: true }); return false; } - diff --git a/cli/src/commands/package/update.ts b/cli/src/commands/package/update.ts index 6d3ae368..d41be601 100644 --- a/cli/src/commands/package/update.ts +++ b/cli/src/commands/package/update.ts @@ -1,6 +1,7 @@ -import chalk from 'chalk'; import { readLockfile, writeLockfile } from '../../lib/package/lockfile.js'; import type { ResolvedPackage } from '../../lib/package/resolve.js'; +import { fail } from '../../lib/command.js'; +import { style } from '../../lib/output.js'; import { showInstallProgress } from '../../ui/package-progress.js'; import { loadRegistryOrExit, resolvePackageProjectDir } from './shared.js'; @@ -17,7 +18,7 @@ export async function packageUpdateCommand(name: string | undefined, opts: Packa const installed = Object.entries(lockfile.packages); if (installed.length === 0) { - console.log(chalk.dim('No packages installed.')); + console.log(style.muted('No packages installed.')); return; } @@ -27,27 +28,25 @@ export async function packageUpdateCommand(name: string | undefined, opts: Packa const targets = name ? installed.filter(([id]) => id === name) : installed; if (name && targets.length === 0) { - console.log(chalk.red(`"${name}" is not installed.`)); - process.exitCode = 1; - return; + fail(`"${name}" is not installed.`); } const toUpdate: ResolvedPackage[] = []; for (const [id, current] of targets) { if (current.trust === 'experimental') { - console.log(chalk.dim(` Skipping "${id}" (experimental — re-install with --from-url to update)`)); + console.log(style.muted(` Skipping "${id}" (experimental — re-install with --from-url to update)`)); continue; } const entry = registry.packages[id]; if (!entry) { - console.log(chalk.yellow(` "${id}" not found in registry — skipping`)); + console.log(style.warning(` "${id}" not found in registry — skipping`)); continue; } if (entry.source.tag === current.version) { - console.log(chalk.dim(` ${id} is already up to date (${current.version})`)); + console.log(style.muted(` ${id} is already up to date (${current.version})`)); continue; } - console.log(` ${chalk.bold(id)}: ${current.version} → ${entry.source.tag}`); + console.log(` ${style.heading(id)}: ${current.version} → ${entry.source.tag}`); toUpdate.push({ id, entry, files: entry.install.files }); } @@ -57,7 +56,6 @@ export async function packageUpdateCommand(name: string | undefined, opts: Packa if (results.every((r) => r.ok)) { writeLockfile(projectDir, lockfile); } else { - process.exitCode = 1; + fail('', { silent: true }); } } - diff --git a/cli/src/commands/plugin/install.ts b/cli/src/commands/plugin/install.ts index 776d670b..d93747c7 100644 --- a/cli/src/commands/plugin/install.ts +++ b/cli/src/commands/plugin/install.ts @@ -1,5 +1,3 @@ -import chalk from 'chalk'; -import ora from 'ora'; import { fetchManifest, getLocalPluginIds, @@ -7,6 +5,8 @@ import { installPlugin, installPluginsFromLocal, } from '../../lib/install.js'; +import { fail } from '../../lib/command.js'; +import { createSpinner } from '../../lib/output.js'; import { resolveLocalLuaRoot } from '../../lib/paths.js'; import { type PluginSourceOptions, resolvePluginProjectDir } from './shared.js'; @@ -19,46 +19,41 @@ export async function pluginInstallCommand(pluginId: string, opts: PluginSourceO const sourceRoot = resolveLocalLuaRoot(opts); const available = getLocalPluginIds(sourceRoot); if (!available.includes(pluginId)) { - console.error(chalk.red(`Unknown plugin: ${pluginId}`)); - console.log(chalk.dim('Available: ' + available.join(', '))); - process.exit(1); + fail(`Unknown plugin: ${pluginId}`, { details: ['Available: ' + available.join(', ')] }); } - const spinner = ora(`Copying ${pluginId}…`).start(); + const spinner = createSpinner(`Copying ${pluginId}…`).start(); try { installPluginsFromLocal([pluginId], sourceRoot, projectDir, installDir); spinner.succeed(`Installed ${pluginId}`); } catch (err) { spinner.fail((err as Error).message); - process.exit(1); + fail((err as Error).message, { cause: err, silent: true }); } return; } - const spinner = ora('Fetching manifest…').start(); + const spinner = createSpinner('Fetching manifest…').start(); let entries: Awaited>; try { entries = await fetchManifest(branch); spinner.succeed('Manifest loaded'); } catch (err) { spinner.fail((err as Error).message); - process.exit(1); + fail((err as Error).message, { cause: err, silent: true }); } const available = getPluginIds(entries); if (!available.includes(pluginId)) { - console.error(chalk.red(`Unknown plugin: ${pluginId}`)); - console.log(chalk.dim('Available: ' + available.join(', '))); - process.exit(1); + fail(`Unknown plugin: ${pluginId}`, { details: ['Available: ' + available.join(', ')] }); } - const installSpinner = ora(`Installing ${pluginId}…`).start(); + const installSpinner = createSpinner(`Installing ${pluginId}…`).start(); try { await installPlugin(pluginId, entries, projectDir, branch, undefined, installDir); installSpinner.succeed(`Installed ${pluginId}`); } catch (err) { installSpinner.fail((err as Error).message); - process.exit(1); + fail((err as Error).message, { cause: err, silent: true }); } } - diff --git a/cli/src/commands/plugin/remove.ts b/cli/src/commands/plugin/remove.ts index 6e58b7b6..508b72a3 100644 --- a/cli/src/commands/plugin/remove.ts +++ b/cli/src/commands/plugin/remove.ts @@ -1,7 +1,7 @@ import { existsSync, rmSync } from 'node:fs'; import { join } from 'node:path'; -import chalk from 'chalk'; -import { icon, statusLine, style } from '../../lib/output.js'; +import { fail } from '../../lib/command.js'; +import { icon, style } from '../../lib/output.js'; import { confirmAction } from '../../ui/confirm.js'; import { pluginsDir, resolvePluginProjectDir } from './shared.js'; @@ -13,14 +13,11 @@ export async function pluginRemoveCommand( const pluginDir = join(pluginsDir(projectDir, opts.installDir), pluginId.replace(/\./g, '/')); if (!existsSync(pluginDir)) { - console.error(statusLine('error', `Plugin not found: ${pluginId}`)); - process.exit(1); + fail(`Plugin not found: ${pluginId}`); } if (!opts.yes && (!process.stdin.isTTY || !process.stdout.isTTY)) { - console.log(style.danger(`Refusing to remove "${pluginId}" without --yes in non-interactive mode.`)); - process.exitCode = 1; - return; + fail(`Refusing to remove "${pluginId}" without --yes in non-interactive mode.`); } if (!opts.yes) { @@ -32,7 +29,7 @@ export async function pluginRemoveCommand( rows: [pluginDir], }); if (!confirmed) { - console.log(chalk.dim('Plugin remove cancelled.')); + console.log(style.muted('Plugin remove cancelled.')); return; } } @@ -40,4 +37,3 @@ export async function pluginRemoveCommand( rmSync(pluginDir, { recursive: true, force: true }); console.log(`${icon.success} Removed ${pluginId}`); } - diff --git a/cli/src/commands/plugin/update.ts b/cli/src/commands/plugin/update.ts index d89c2598..e26c2072 100644 --- a/cli/src/commands/plugin/update.ts +++ b/cli/src/commands/plugin/update.ts @@ -1,7 +1,5 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import chalk from 'chalk'; -import ora from 'ora'; import { fetchManifest, getLocalPluginIds, @@ -9,6 +7,8 @@ import { installPlugin, installPluginsFromLocal, } from '../../lib/install.js'; +import { fail } from '../../lib/command.js'; +import { createSpinner, style } from '../../lib/output.js'; import { resolveLocalLuaRoot } from '../../lib/paths.js'; import { choosePluginUpdateWorkflow } from '../../ui/plugin-workflow.js'; import { getInstalledPluginIds, pluginsDir, resolvePluginProjectDir } from './shared.js'; @@ -26,7 +26,7 @@ export async function pluginUpdateCommand( if (!pluginId && process.stdin.isTTY && !hasExplicitSource) { const installedIds = getInstalledPluginIds(projectDir, installDir); if (installedIds.length === 0) { - console.log(chalk.dim('No plugins installed.')); + console.log(style.muted('No plugins installed.')); return; } @@ -37,7 +37,7 @@ export async function pluginUpdateCommand( if (result.action === 'cancel') return; if (result.action !== 'update' || result.pluginIds.length === 0) { - console.log(chalk.dim('No plugins selected.')); + console.log(style.muted('No plugins selected.')); return; } @@ -60,7 +60,7 @@ export async function pluginUpdateCommand( const ids = pluginId ? [pluginId] : available.filter((id) => existsSync(join(dirPath, id.replace(/\./g, '/')))); for (const id of ids) { - const s = ora(`Updating ${id}…`).start(); + const s = createSpinner(`Updating ${id}…`).start(); try { installPluginsFromLocal([id], sourceRoot, projectDir, installDir); s.succeed(`Updated ${id}`); @@ -71,14 +71,14 @@ export async function pluginUpdateCommand( return; } - const spinner = ora('Fetching manifest…').start(); + const spinner = createSpinner('Fetching manifest…').start(); let entries: Awaited>; try { entries = await fetchManifest(branch); spinner.succeed('Manifest loaded'); } catch (err) { spinner.fail((err as Error).message); - process.exit(1); + fail((err as Error).message, { cause: err, silent: true }); } const ids = pluginId ? [pluginId] : getPluginIds(entries).filter((id) => @@ -86,7 +86,7 @@ export async function pluginUpdateCommand( ); for (const id of ids) { - const s = ora(`Updating ${id}…`).start(); + const s = createSpinner(`Updating ${id}…`).start(); try { await installPlugin(id, entries, projectDir, branch, undefined, installDir); s.succeed(`Updated ${id}`); @@ -95,4 +95,3 @@ export async function pluginUpdateCommand( } } } - diff --git a/cli/src/commands/remove.ts b/cli/src/commands/remove.ts index c208c6eb..1a28ee76 100644 --- a/cli/src/commands/remove.ts +++ b/cli/src/commands/remove.ts @@ -1,9 +1,9 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; -import chalk from "chalk"; import { normalizeInstallDir } from "../lib/install.js"; import { chooseRemoveWorkflow, type RemoveTarget } from "../ui/remove-workflow.js"; import { parseManagedValue } from "../lib/plugin-utils.js"; +import { fail } from "../lib/command.js"; import { icon, style } from "../lib/output.js"; export interface RemoveOptions { @@ -140,13 +140,12 @@ export async function removeCommand(dir: string, opts: RemoveOptions): Promise { +export async function runCommand(gamePath: string | undefined, opts: RunOptions): Promise { if (!gamePath) { if (!process.stdin.isTTY) { - console.error(statusLine("error", "Game path is required. Use `feather run `.")); - process.exit(1); + fail("Game path is required. Use `feather run `."); } const result = await chooseRunWorkflow(); @@ -46,21 +46,18 @@ export async function runCommand(gamePath: string | undefined, opts: RunOptions) const absGame = resolve(gamePath); if (!existsSync(absGame)) { - console.error(statusLine("error", `Game path not found: ${absGame}`)); - process.exit(1); + fail(`Game path not found: ${absGame}`); } if (!existsSync(`${absGame}/main.lua`)) { - console.error(statusLine("error", `No main.lua found in: ${absGame}`)); - process.exit(1); + fail(`No main.lua found in: ${absGame}`); } let loveBin: string; try { loveBin = findLoveBinary(opts.love); } catch (err) { - console.error(statusLine("error", (err as Error).message)); - process.exit(1); + fail((err as Error).message, { cause: err }); } const userConfig = loadConfig(absGame, opts.config) ?? undefined; @@ -94,9 +91,8 @@ export async function runCommand(gamePath: string | undefined, opts: RunOptions) shim.cleanup(); if (result.error) { - console.error(statusLine("error", `Failed to launch love: ${result.error.message}`)); - process.exit(1); + fail(`Failed to launch love: ${result.error.message}`, { cause: result.error }); } - process.exit(result.status ?? 0); + return result.status ?? 0; } diff --git a/cli/src/commands/update.ts b/cli/src/commands/update.ts index 53757b5f..fa328530 100644 --- a/cli/src/commands/update.ts +++ b/cli/src/commands/update.ts @@ -1,10 +1,10 @@ import { existsSync } from "node:fs"; import { join, resolve } from "node:path"; -import ora from "ora"; import { fetchManifest, installCore, installCoreFromLocal, normalizeInstallDir } from "../lib/install.js"; import { chooseCoreUpdateWorkflow } from "../ui/update-workflow.js"; import { resolveLocalLuaRoot } from "../lib/paths.js"; -import { statusLine, style } from "../lib/output.js"; +import { fail } from "../lib/command.js"; +import { createSpinner, style } from "../lib/output.js"; export async function updateCommand( dir: string, @@ -14,8 +14,7 @@ export async function updateCommand( const installDir = normalizeInstallDir(opts.installDir); if (!existsSync(join(target, installDir, "init.lua"))) { - console.error(statusLine("error", `Feather is not installed in ${target}. Run \`feather init\` first.`)); - process.exit(1); + fail(`Feather is not installed in ${target}. Run \`feather init\` first.`); } let branch = opts.branch ?? "main"; @@ -34,7 +33,7 @@ export async function updateCommand( if (!useRemote) { const sourceRoot = resolveLocalLuaRoot(opts); - const spinner = ora("Updating feather core from local copy…").start(); + const spinner = createSpinner("Updating feather core from local copy…").start(); try { installCoreFromLocal(sourceRoot, target, installDir, (file) => { spinner.text = `Updating ${file}…`; @@ -42,14 +41,14 @@ export async function updateCommand( spinner.succeed("Feather core updated"); } catch (err) { spinner.fail((err as Error).message); - process.exit(1); + fail((err as Error).message, { cause: err, silent: true }); } console.log(`\n${style.heading("Done!")} Feather core is up to date.\n`); return; } - const spinner = ora("Fetching manifest…").start(); + const spinner = createSpinner("Fetching manifest…").start(); let entries: Awaited>; try { @@ -57,10 +56,10 @@ export async function updateCommand( spinner.succeed(`Manifest loaded (${entries.length} files)`); } catch (err) { spinner.fail((err as Error).message); - process.exit(1); + fail((err as Error).message, { cause: err, silent: true }); } - const updateSpinner = ora("Updating feather core…").start(); + const updateSpinner = createSpinner("Updating feather core…").start(); try { await installCore(entries, target, branch, (f) => { updateSpinner.text = `Updating ${f}…`; @@ -68,7 +67,7 @@ export async function updateCommand( updateSpinner.succeed("Feather core updated"); } catch (err) { updateSpinner.fail((err as Error).message); - process.exit(1); + fail((err as Error).message, { cause: err, silent: true }); } console.log(`\n${style.heading("Done!")} Feather core is up to date.\n`); diff --git a/cli/src/index.ts b/cli/src/index.ts index 2b12cc9f..0ba24d09 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node import { Command } from 'commander'; +import { runCliAction } from './lib/command.js'; import { runCommand } from './commands/run.js'; import { initCommand } from './commands/init.js'; import { removeCommand } from './commands/remove.js'; @@ -48,8 +49,7 @@ program .option('--config ', 'Path to feather.config.lua') .option('--feather-path ', 'Use a local feather install instead of the bundled one') .option('--plugins-dir ', 'Use a custom plugins directory instead of the bundled one') - .action(async (gamePath: string | undefined, gameArgs: string[], opts) => { - await runCommand(gamePath, { + .action((gamePath: string | undefined, gameArgs: string[], opts) => runCliAction(() => runCommand(gamePath, { love: opts.love as string | undefined, sessionName: opts.sessionName as string | undefined, noPlugins: opts.plugins === false, @@ -57,8 +57,7 @@ program featherPath: opts.featherPath as string | undefined, pluginsDir: opts.pluginsDir as string | undefined, gameArgs, - }); - }); + }))); program .command('init [dir]') @@ -71,8 +70,7 @@ program .option('--plugins ', 'Comma-separated list of plugins to install') .option('--mode ', 'Setup mode: cli, auto, or manual', parseInitMode) .option('-y, --yes', 'Skip confirmation prompts') - .action(async (dir: string | undefined, opts) => { - await initCommand(dir ?? '.', { + .action((dir: string | undefined, opts) => runCliAction(() => initCommand(dir ?? '.', { branch: opts.branch as string, remote: opts.remote as boolean | undefined, localSrc: opts.localSrc as string | undefined, @@ -84,8 +82,7 @@ program : undefined, mode: opts.mode as InitMode | undefined, yes: opts.yes as boolean, - }); - }); + }))); program .command('remove [dir]') @@ -97,8 +94,7 @@ program .option('--keep-manual', 'Keep feather.debugger.lua') .option('--keep-runtime', 'Keep installed Feather runtime/plugins') .option('-y, --yes', 'Skip interactive confirmation') - .action(async (dir: string | undefined, opts) => { - await removeCommand(dir ?? '.', { + .action((dir: string | undefined, opts) => runCliAction(() => removeCommand(dir ?? '.', { installDir: opts.installDir as string | undefined, dryRun: opts.dryRun as boolean | undefined, keepConfig: opts.keepConfig as boolean | undefined, @@ -106,8 +102,7 @@ program keepManual: opts.keepManual as boolean | undefined, keepRuntime: opts.keepRuntime as boolean | undefined, yes: opts.yes as boolean | undefined, - }); - }); + }))); program .command('doctor [dir]') @@ -116,14 +111,12 @@ program .option('--host ', 'Host to check for the Feather desktop WebSocket', '127.0.0.1') .option('--port ', 'Port to check for the Feather desktop WebSocket', (value) => Number(value)) .option('--json', 'Print machine-readable diagnostics') - .action(async (dir: string | undefined, opts) => { - await doctorCommand(dir, { + .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, - }); - }); + }))); program .command('update [dir]') @@ -133,15 +126,13 @@ program .option('--local-src ', 'Copy Lua runtime from a local src-lua style directory') .option('--install-dir ', 'Feather install directory', 'feather') .option('-y, --yes', 'Skip interactive confirmation and use the selected/default source') - .action((dir: string | undefined, opts) => { - updateCommand(dir ?? '.', { + .action((dir: string | undefined, opts) => runCliAction(() => updateCommand(dir ?? '.', { branch: opts.branch as string, remote: opts.remote as boolean | undefined, localSrc: opts.localSrc as string | undefined, installDir: opts.installDir as string, yes: opts.yes as boolean | undefined, - }); - }); + }))); const plugin = program .command('plugin') @@ -151,15 +142,13 @@ const plugin = program .option('--branch ', 'GitHub branch to download from when using --remote', 'main') .option('--local-src ', 'Copy plugins from a local src-lua style directory') .option('--install-dir ', 'Feather install directory', 'feather') - .action(async (opts) => { - await pluginWorkflowCommand({ + .action((opts) => runCliAction(() => pluginWorkflowCommand({ dir: opts.dir as string | undefined, branch: opts.branch as string, installDir: opts.installDir as string, remote: opts.remote as boolean | undefined, localSrc: opts.localSrc as string | undefined, - }); - }); + }))); const pluginCommandOptions = (opts: Record) => ({ ...opts, ...plugin.opts() }); @@ -167,10 +156,10 @@ plugin .command('list [dir]') .description('List installed plugins') .option('--install-dir ', 'Feather install directory', 'feather') - .action(async (dir: string | undefined, opts) => { + .action((dir: string | undefined, opts) => runCliAction(() => { const merged = pluginCommandOptions(opts); - await pluginListCommand(dir ?? (merged.dir as string | undefined), merged.installDir as string); - }); + return pluginListCommand(dir ?? (merged.dir as string | undefined), merged.installDir as string); + })); plugin .command('install ') @@ -180,16 +169,16 @@ plugin .option('--branch ', 'GitHub branch to download from when using --remote', 'main') .option('--local-src ', 'Copy plugins from a local src-lua style directory') .option('--install-dir ', 'Feather install directory', 'feather') - .action(async (id: string, opts) => { + .action((id: string, opts) => runCliAction(() => { const merged = pluginCommandOptions(opts); - await pluginInstallCommand(id, { + return pluginInstallCommand(id, { dir: merged.dir as string | undefined, branch: merged.branch as string, installDir: merged.installDir as string, remote: merged.remote as boolean | undefined, localSrc: merged.localSrc as string | undefined, }); - }); + })); plugin .command('remove ') @@ -197,14 +186,14 @@ plugin .option('--dir ', 'Project directory (default: current directory)') .option('--install-dir ', 'Feather install directory', 'feather') .option('-y, --yes', 'Skip interactive confirmation') - .action(async (id: string, opts) => { + .action((id: string, opts) => runCliAction(() => { const merged = pluginCommandOptions(opts); - await pluginRemoveCommand(id, { + return pluginRemoveCommand(id, { dir: merged.dir as string | undefined, installDir: merged.installDir as string, yes: merged.yes as boolean | undefined, }); - }); + })); plugin .command('update [id]') @@ -215,9 +204,9 @@ plugin .option('--local-src ', 'Copy plugins from a local src-lua style directory') .option('--install-dir ', 'Feather install directory', 'feather') .option('-y, --yes', 'Skip interactive selection and update all installed plugins when no id is given') - .action(async (id: string | undefined, opts) => { + .action((id: string | undefined, opts) => runCliAction(() => { const merged = pluginCommandOptions(opts); - await pluginUpdateCommand(id, { + return pluginUpdateCommand(id, { dir: merged.dir as string | undefined, branch: merged.branch as string, installDir: merged.installDir as string, @@ -225,7 +214,7 @@ plugin localSrc: merged.localSrc as string | undefined, yes: merged.yes as boolean | undefined, }); - }); + })); const pkg = program.command('package').description('Install and manage LÖVE packages from the Feather catalog'); @@ -233,21 +222,17 @@ pkg .command('add') .description('Interactively install a custom dependency from URL(s)') .option('--dir ', 'Project directory') - .action(async (opts) => { - await packageAddCommand({ dir: opts.dir as string | undefined }); - }); + .action((opts) => runCliAction(() => packageAddCommand({ dir: opts.dir as string | undefined }))); pkg .command('search [query]') .description('Search the package catalog') .option('--offline', 'Use bundled registry snapshot') .option('--registry ', 'Override registry URL') - .action(async (query: string | undefined, opts) => { - await packageSearchCommand(query, { + .action((query: string | undefined, opts) => runCliAction(() => packageSearchCommand(query, { offline: opts.offline as boolean | undefined, registryUrl: opts.registry as string | undefined, - }); - }); + }))); pkg .command('list') @@ -257,15 +242,13 @@ pkg .option('--refresh', 'Force a fresh registry fetch ignoring cache') .option('--dir ', 'Project directory') .option('--registry ', 'Override registry URL') - .action(async (opts) => { - await packageListCommand({ + .action((opts) => runCliAction(() => packageListCommand({ installed: opts.installed as boolean | undefined, offline: opts.offline as boolean | undefined, refresh: opts.refresh as boolean | undefined, dir: opts.dir as string | undefined, registryUrl: opts.registry as string | undefined, - }); - }); + }))); pkg .command('info ') @@ -273,13 +256,11 @@ pkg .option('--offline', 'Use bundled registry snapshot') .option('--dir ', 'Project directory') .option('--registry ', 'Override registry URL') - .action(async (name: string, opts) => { - await packageInfoCommand(name, { + .action((name: string, opts) => runCliAction(() => packageInfoCommand(name, { offline: opts.offline as boolean | undefined, dir: opts.dir as string | undefined, registryUrl: opts.registry as string | undefined, - }); - }); + }))); pkg .command('install [names...]') @@ -292,8 +273,7 @@ pkg .option('--dir ', 'Project directory') .option('-y, --yes', 'Skip confirmation prompts') .option('--registry ', 'Override registry URL') - .action(async (names: string[], opts) => { - await packageInstallCommand(names, { + .action((names: string[], opts) => runCliAction(() => packageInstallCommand(names, { dryRun: opts.dryRun as boolean | undefined, allowUntrusted: opts.allowUntrusted as boolean | undefined, target: opts.target as string | undefined, @@ -302,8 +282,7 @@ pkg dir: opts.dir as string | undefined, yes: opts.yes as boolean | undefined, registryUrl: opts.registry as string | undefined, - }); - }); + }))); pkg .command('update [name]') @@ -312,37 +291,31 @@ pkg .option('--offline', 'Use bundled registry snapshot') .option('--dir ', 'Project directory') .option('--registry ', 'Override registry URL') - .action(async (name: string | undefined, opts) => { - await packageUpdateCommand(name, { + .action((name: string | undefined, opts) => runCliAction(() => packageUpdateCommand(name, { dryRun: opts.dryRun as boolean | undefined, offline: opts.offline as boolean | undefined, dir: opts.dir as string | undefined, registryUrl: opts.registry as string | undefined, - }); - }); + }))); pkg .command('remove ') .description('Remove an installed package') .option('--dir ', 'Project directory') .option('-y, --yes', 'Skip confirmation prompt') - .action(async (name: string, opts) => { - await packageRemoveCommand(name, { + .action((name: string, opts) => runCliAction(() => packageRemoveCommand(name, { dir: opts.dir as string | undefined, yes: opts.yes as boolean | undefined, - }); - }); + }))); pkg .command('audit') .description('Verify SHA-256 checksums of all installed packages') .option('--dir ', 'Project directory') .option('--json', 'Output machine-readable JSON') - .action(async (opts) => { - await packageAuditCommand({ + .action((opts) => runCliAction(() => packageAuditCommand({ dir: opts.dir as string | undefined, json: opts.json as boolean | undefined, - }); - }); + }))); program.parse(); diff --git a/cli/src/lib/command.ts b/cli/src/lib/command.ts new file mode 100644 index 00000000..a069514c --- /dev/null +++ b/cli/src/lib/command.ts @@ -0,0 +1,51 @@ +import { statusLine, style } from './output.js'; + +export type CliErrorOptions = { + exitCode?: number; + details?: string[]; + cause?: unknown; + silent?: boolean; +}; + +export class CliError extends Error { + exitCode: number; + details: string[]; + silent: boolean; + + constructor(message: string, options: CliErrorOptions = {}) { + super(message); + this.name = 'CliError'; + this.exitCode = options.exitCode ?? 1; + this.details = options.details ?? []; + this.silent = options.silent ?? false; + if (options.cause !== undefined) this.cause = options.cause; + } +} + +export function fail(message: string, options: CliErrorOptions = {}): never { + throw new CliError(message, options); +} + +export async function runCliAction(action: () => Promise | void | number): Promise { + try { + const result = await action(); + if (typeof result === 'number') process.exitCode = result; + } catch (err) { + if (err instanceof CliError) { + if (!err.silent && err.message) { + console.error(statusLine('error', err.message)); + for (const detail of err.details) console.error(style.muted(` ${detail}`)); + } + process.exitCode = err.exitCode; + return; + } + + const message = err instanceof Error ? err.message : String(err); + console.error(statusLine('error', message || 'Unexpected error')); + if (process.env.FEATHER_DEBUG === '1' && err instanceof Error && err.stack) { + console.error(style.muted(err.stack)); + } + process.exitCode = 1; + } +} + diff --git a/cli/src/lib/output.ts b/cli/src/lib/output.ts index 787c4bc4..16233145 100644 --- a/cli/src/lib/output.ts +++ b/cli/src/lib/output.ts @@ -1,4 +1,5 @@ import chalk from 'chalk'; +import ora, { type Ora } from 'ora'; export const icon = { success: chalk.green('✔'), @@ -17,10 +18,42 @@ export const style = { info: chalk.cyan, }; +export function heading(message: string): string { + return style.heading(message); +} + export function statusLine(kind: keyof typeof icon, message: string): string { return `${icon[kind]} ${message}`; } +export function printJson(value: unknown): void { + console.log(JSON.stringify(value, null, 2)); +} + +export function section(title: string, lines: string[] = []): string[] { + return ['', heading(title), ...lines, '']; +} + +export function createSpinner(text: string): Ora { + return ora(text); +} + +export async function withSpinner( + text: string, + action: (spinner: Ora) => Promise, + messages?: { success?: string; fail?: (err: unknown) => string }, +): Promise { + const spinner = createSpinner(text).start(); + try { + const result = await action(spinner); + spinner.succeed(messages?.success ?? text); + return result; + } catch (err) { + spinner.fail(messages?.fail ? messages.fail(err) : (err as Error).message); + throw err; + } +} + export function keyValueRows(rows: Array<[string, string | number | undefined | null]>): string[] { const visible = rows.filter(([, value]) => value !== undefined && value !== null && value !== ''); const width = Math.max(0, ...visible.map(([key]) => key.length)); From 6473454f325d4749d1ebc0fd7a953314b93fdb45 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 00:30:39 -0400 Subject: [PATCH 25/68] cli: improve styling --- cli/src/commands/doctor/report.ts | 23 ++++----- cli/src/commands/plugin/list.ts | 14 +++--- cli/src/commands/plugin/workflow.ts | 5 +- cli/src/lib/package/registry.ts | 72 +++++++++++++++++++++++++++-- cli/src/lib/trust.ts | 8 ++-- 5 files changed, 91 insertions(+), 31 deletions(-) diff --git a/cli/src/commands/doctor/report.ts b/cli/src/commands/doctor/report.ts index c9947c5d..32f55d53 100644 --- a/cli/src/commands/doctor/report.ts +++ b/cli/src/commands/doctor/report.ts @@ -1,4 +1,3 @@ -import chalk from 'chalk'; import { icon as statusIcon, style } from '../../lib/output.js'; import { type DoctorCheck, type Severity, severityOrder } from './checks.js'; @@ -10,10 +9,9 @@ function icon(severity: Severity): string { } function colorLabel(severity: Severity, label: string): string { - if (severity === 'pass') return chalk.white(label); - if (severity === 'warn') return chalk.yellow(label); - if (severity === 'fail') return chalk.red(label); - return chalk.white(label); + if (severity === 'warn') return style.warning(label); + if (severity === 'fail') return style.danger(label); + return label; } export function renderReport(checks: DoctorCheck[], projectDir: string): void { @@ -22,10 +20,10 @@ export function renderReport(checks: DoctorCheck[], projectDir: string): void { const groups = [...new Set(checks.map((check) => check.group))]; for (const group of groups) { - console.log(chalk.bold(group)); + console.log(style.heading(group)); for (const check of checks.filter((item) => item.group === group).sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity])) { - console.log(` ${icon(check.severity)} ${colorLabel(check.severity, check.label)}${check.detail ? chalk.dim(` ${check.detail}`) : ''}`); - if (check.fix) console.log(chalk.dim(` → ${check.fix}`)); + console.log(` ${icon(check.severity)} ${colorLabel(check.severity, check.label)}${check.detail ? style.muted(` ${check.detail}`) : ''}`); + if (check.fix) console.log(style.muted(` → ${check.fix}`)); } console.log(); } @@ -35,12 +33,11 @@ export function renderReport(checks: DoctorCheck[], projectDir: string): void { const passed = checks.filter((check) => check.severity === 'pass'); if (failures.length > 0) { - console.log(chalk.red.bold(`Doctor found ${failures.length} blocker${failures.length === 1 ? '' : 's'}.`)); + console.log(style.danger(`Doctor found ${failures.length} blocker${failures.length === 1 ? '' : 's'}.`)); } else if (warnings.length > 0) { - console.log(chalk.yellow.bold(`Doctor passed with ${warnings.length} warning${warnings.length === 1 ? '' : 's'}.`)); + console.log(style.warning(`Doctor passed with ${warnings.length} warning${warnings.length === 1 ? '' : 's'}.`)); } else { - console.log(chalk.green.bold('Doctor found no problems.')); + console.log(style.success('Doctor found no problems.')); } - console.log(chalk.dim(`${passed.length} passed, ${warnings.length} warnings, ${failures.length} failures.\n`)); + console.log(style.muted(`${passed.length} passed, ${warnings.length} warnings, ${failures.length} failures.\n`)); } - diff --git a/cli/src/commands/plugin/list.ts b/cli/src/commands/plugin/list.ts index 97886f13..c00c431a 100644 --- a/cli/src/commands/plugin/list.ts +++ b/cli/src/commands/plugin/list.ts @@ -1,7 +1,6 @@ import { existsSync } from 'node:fs'; -import chalk from 'chalk'; import { findInstalledPluginDirs, readPluginManifest } from '../../lib/plugin-utils.js'; -import { table } from '../../lib/output.js'; +import { style, table } from '../../lib/output.js'; import { pluginsDir, resolvePluginProjectDir } from './shared.js'; export async function pluginListCommand(dir?: string, installDir = 'feather'): Promise { @@ -9,18 +8,18 @@ export async function pluginListCommand(dir?: string, installDir = 'feather'): P const dirPath = pluginsDir(projectDir, installDir); if (!existsSync(dirPath)) { - console.log(chalk.dim('No plugins directory found. Run `feather init` first.')); + console.log(style.muted('No plugins directory found. Run `feather init` first.')); return; } const dirs = findInstalledPluginDirs(dirPath); if (dirs.length === 0) { - console.log(chalk.dim('No plugins installed.')); + console.log(style.muted('No plugins installed.')); return; } - console.log(chalk.bold(`\nInstalled plugins (${dirs.length})\n`)); + console.log(style.heading(`\nInstalled plugins (${dirs.length})\n`)); const rows = dirs.map((dir) => { const meta = readPluginManifest(dir); return { @@ -31,8 +30,8 @@ export async function pluginListCommand(dir?: string, installDir = 'feather'): P }); for (const line of table({ columns: [ - { key: 'id', label: 'ID', color: (value) => chalk.cyan(value) }, - { key: 'version', label: 'VERSION', color: (value) => chalk.dim(value) }, + { key: 'id', label: 'ID', color: (value) => style.info(value) }, + { key: 'version', label: 'VERSION', color: (value) => style.muted(value) }, { key: 'name', label: 'NAME' }, ], rows, @@ -41,4 +40,3 @@ export async function pluginListCommand(dir?: string, installDir = 'feather'): P } console.log(); } - diff --git a/cli/src/commands/plugin/workflow.ts b/cli/src/commands/plugin/workflow.ts index 7328ec80..fec6b3c1 100644 --- a/cli/src/commands/plugin/workflow.ts +++ b/cli/src/commands/plugin/workflow.ts @@ -1,4 +1,4 @@ -import chalk from 'chalk'; +import { style } from '../../lib/output.js'; import { choosePluginWorkflow } from '../../ui/plugin-workflow.js'; import { pluginInstallCommand } from './install.js'; import { pluginListCommand } from './list.js'; @@ -29,7 +29,7 @@ export async function pluginWorkflowCommand(opts: { } if (result.pluginIds.length === 0) { - console.log(chalk.dim('No plugins selected.')); + console.log(style.muted('No plugins selected.')); return; } @@ -63,4 +63,3 @@ export async function pluginWorkflowCommand(opts: { await pluginRemoveCommand(id, { dir: projectDir, installDir, yes: true }); } } - diff --git a/cli/src/lib/package/registry.ts b/cli/src/lib/package/registry.ts index bcf906a7..7100b58a 100644 --- a/cli/src/lib/package/registry.ts +++ b/cli/src/lib/package/registry.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; import { fileURLToPath } from "node:url"; +import { resolveProjectTarget } from "./target.js"; export type PackageFile = { name: string; @@ -46,6 +47,71 @@ export type Registry = { const REGISTRY_URL = "https://raw.githubusercontent.com/Kyonru/feather/packages/registry.json"; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours +const TRUST_VALUES = new Set(["verified", "known", "experimental"]); +const SHA256_RE = /^[a-f0-9]{64}$/i; + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function validatePackageId(id: string): void { + if (!/^[a-z0-9][a-z0-9.-]*$/.test(id)) throw new Error(`Invalid package id: ${id}`); +} + +export function validateRegistry(value: unknown): Registry { + if (!isObject(value)) throw new Error("Registry must be an object"); + if (value.version !== 1) throw new Error("Registry version must be 1"); + if (typeof value.updatedAt !== "string") throw new Error("Registry updatedAt must be a string"); + if (!isObject(value.packages)) throw new Error("Registry packages must be an object"); + + const packages = value.packages as Record; + for (const [id, entry] of Object.entries(packages)) { + validatePackageId(id); + if (!isObject(entry)) throw new Error(`Package ${id} must be an object`); + if (entry.parent !== undefined && typeof entry.parent !== "string") throw new Error(`Package ${id} parent must be a string`); + if (entry.parent && !packages[entry.parent]) throw new Error(`Package ${id} parent ${entry.parent} is missing`); + if (typeof entry.type !== "string") throw new Error(`Package ${id} type is required`); + if (!TRUST_VALUES.has(entry.trust)) throw new Error(`Package ${id} has invalid trust`); + if (typeof entry.description !== "string") throw new Error(`Package ${id} description is required`); + if (!Array.isArray(entry.tags) || entry.tags.some((tag) => typeof tag !== "string")) { + throw new Error(`Package ${id} tags must be strings`); + } + if (!isObject(entry.source)) throw new Error(`Package ${id} source is required`); + if (typeof entry.source.repo !== "string" || !/^[^/]+\/[^/]+$/.test(entry.source.repo)) { + throw new Error(`Package ${id} source.repo must be owner/repo`); + } + if (typeof entry.source.tag !== "string" || !entry.source.tag) throw new Error(`Package ${id} source.tag is required`); + if (typeof entry.source.baseUrl !== "string" || !entry.source.baseUrl.startsWith("https://raw.githubusercontent.com/")) { + throw new Error(`Package ${id} source.baseUrl must be a raw GitHub URL`); + } + if (typeof entry.source.commitSha !== "string" || !/^[a-f0-9]{40}$/i.test(entry.source.commitSha)) { + throw new Error(`Package ${id} source.commitSha must be a 40-character SHA`); + } + if (!entry.source.baseUrl.includes(entry.source.commitSha)) { + throw new Error(`Package ${id} source.baseUrl must include source.commitSha`); + } + if (!isObject(entry.install) || !Array.isArray(entry.install.files) || entry.install.files.length === 0) { + throw new Error(`Package ${id} install.files is required`); + } + for (const file of entry.install.files) { + if (!isObject(file)) throw new Error(`Package ${id} install file must be an object`); + if (typeof file.name !== "string" || !file.name) throw new Error(`Package ${id} file.name is required`); + if (typeof file.target !== "string" || !resolveProjectTarget("/project", file.target)) { + throw new Error(`Package ${id} file target escapes project root: ${file.target}`); + } + if (typeof file.sha256 !== "string" || !SHA256_RE.test(file.sha256)) { + throw new Error(`Package ${id} file ${file.name} has invalid sha256`); + } + if (file.url !== undefined && typeof file.url !== "string") throw new Error(`Package ${id} file.url must be a string`); + } + if (typeof entry.require !== "string" || !entry.require) throw new Error(`Package ${id} require is required`); + if (entry.subpackages?.some((subpackage) => typeof subpackage !== "string" || packages[subpackage]?.parent !== id)) { + throw new Error(`Package ${id} has invalid subpackage references`); + } + } + + return value as Registry; +} function cacheDir(): string { return join(homedir(), ".feather"); @@ -67,7 +133,7 @@ function readCache(): Registry | null { if (!existsSync(path)) return null; try { const { fetchedAt, registry } = JSON.parse(readFileSync(path, "utf8")) as CacheFile; - if (Date.now() - new Date(fetchedAt).getTime() < CACHE_TTL_MS) return registry; + if (Date.now() - new Date(fetchedAt).getTime() < CACHE_TTL_MS) return validateRegistry(registry); } catch { // ignore corrupt cache } @@ -82,7 +148,7 @@ function writeCache(registry: Registry): void { } function readBundled(): Registry { - return JSON.parse(readFileSync(bundledRegistryPath(), "utf8")) as Registry; + return validateRegistry(JSON.parse(readFileSync(bundledRegistryPath(), "utf8"))); } export type RegistryLoadOptions = { @@ -103,7 +169,7 @@ export async function loadRegistry(opts: RegistryLoadOptions = {}): Promise Date: Sat, 16 May 2026 00:36:30 -0400 Subject: [PATCH 26/68] cli: improve command workflow code --- cli/src/commands/remove.ts | 4 + cli/src/ui/init-mode-workflow.tsx | 928 +++++++++++++++++++++++++++++ cli/src/ui/init-mode.tsx | 930 +----------------------------- cli/src/ui/package-add-helpers.ts | 27 + cli/src/ui/package-add-steps.tsx | 246 ++++++++ cli/src/ui/package-add.tsx | 277 +-------- cli/test/package.test.mjs | 194 ++++++- 7 files changed, 1387 insertions(+), 1219 deletions(-) create mode 100644 cli/src/ui/init-mode-workflow.tsx create mode 100644 cli/src/ui/package-add-helpers.ts create mode 100644 cli/src/ui/package-add-steps.tsx diff --git a/cli/src/commands/remove.ts b/cli/src/commands/remove.ts index 1a28ee76..d7e6587f 100644 --- a/cli/src/commands/remove.ts +++ b/cli/src/commands/remove.ts @@ -149,6 +149,10 @@ export async function removeCommand(dir: string, opts: RemoveOptions): Promise target.defaultSelected).map((target) => target.id); if (!opts.yes && process.stdin.isTTY) { const result = await chooseRemoveWorkflow(targets); diff --git a/cli/src/ui/init-mode-workflow.tsx b/cli/src/ui/init-mode-workflow.tsx new file mode 100644 index 00000000..5bfeb05a --- /dev/null +++ b/cli/src/ui/init-mode-workflow.tsx @@ -0,0 +1,928 @@ +import React, { type ReactNode, useMemo, useState } from "react"; +import { Box, Text, render, useApp, useInput } from "ink"; +import { copyToClipboard } from "../lib/clipboard.js"; +import { pluginCatalog } from "../generated/plugin-catalog.js"; + +export type InitMode = "cli" | "auto" | "manual"; + +export type InitSetup = { + mode: InitMode; + source: "local" | "remote"; + branch: string; + installDir: string; + installPlugins: boolean; + config: Record; + exclude: string[]; +}; + +type Phase = + | "mode" + | "installDir" + | "source" + | "branch" + | "sessionName" + | "installPlugins" + | "include" + | "exclude" + | "advanced" + | "host" + | "port" + | "modeConfig" + | "baseDir" + | "sampleRate" + | "updateInterval" + | "maxTempLogs" + | "outputDir" + | "retryInterval" + | "connectTimeout" + | "errorWait" + | "binaryTextThreshold" + | "deviceId" + | "capabilities" + | "toggles" + | "apiKey" + | "appId" + | "summary"; + +type Option = { + value: T; + label: string; + description?: string; +}; + +type Tone = "info" | "success" | "warning" | "danger"; + +type SummaryRow = { + id: string; + label: string; + value: ReactNode; + tone?: Tone; +}; + +const dangerousInsecureConnection = "__DANGEROUS_INSECURE_CONNECTION__"; +const dangerousPluginIds = new Set(["console", "hot-reload"]); +const dangerousToggleIds = new Set(["debugger", "captureScreenshot"]); +const warningToggleIds = new Set(["autoRegisterErrorHandler", "writeToDisk"]); + +const toneColor = (tone?: Tone) => { + if (tone === "danger") return "red"; + if (tone === "warning") return "yellow"; + if (tone === "success") return "green"; + if (tone === "info") return "cyan"; + return undefined; +}; + +const pluginTone = (value: string): Tone | undefined => (dangerousPluginIds.has(value) ? "danger" : undefined); +const toggleTone = (value: string): Tone | undefined => { + if (dangerousToggleIds.has(value)) return "danger"; + if (warningToggleIds.has(value)) return "warning"; + return undefined; +}; + +const modes: Option[] = [ + { + value: "cli", + label: "CLI injection", + description: "Create feather.config.lua only. Run with `feather run .`.", + }, + { + value: "auto", + label: "Auto require", + description: 'Patch main.lua with a USE_DEBUGGER-guarded require("feather.auto").', + }, + { + value: "manual", + label: "Manual setup", + description: "Create feather.debugger.lua and load it when USE_DEBUGGER is set.", + }, +]; + +const installSources: Option<"local" | "remote">[] = [ + { + value: "local", + label: "Bundled/local copy", + description: "Copy the CLI-bundled Lua runtime, or src-lua when running from the repo.", + }, + { + value: "remote", + label: "GitHub download", + description: "Fetch files from GitHub using the selected branch or tag.", + }, +]; + +const pluginToOption = (plugin: (typeof pluginCatalog)[number]): Option => ({ + value: plugin.id, + label: plugin.name, + description: plugin.description, +}); + +const optionalPlugins: Option[] = pluginCatalog.filter((plugin) => plugin.optIn).map(pluginToOption); +const skipPluginOptions: Option[] = pluginCatalog.map(pluginToOption); +const defaultSkippedPlugins = new Set(["console", "hot-reload", "hump.signal", "lua-state-machine"]); + +const configToggles: Option[] = [ + { value: "debug", label: "Enable Feather", description: "Set debug = true." }, + { value: "wrapPrint", label: "Wrap print()", description: "Send print output to Logs." }, + { value: "defaultObservers", label: "Default observers", description: "Capture built-in runtime observers." }, + { + value: "autoRegisterErrorHandler", + label: "Error handler", + description: "Capture Lua errors before LÖVE shows its handler.", + }, + { value: "writeToDisk", label: "Write logs to disk", description: "Persist .featherlog files." }, + { value: "debugger", label: "Step debugger", description: "Enable debugger commands by default." }, + { value: "captureScreenshot", label: "Error screenshots", description: "Capture screenshots on errors." }, + { value: "assetPreview", label: "Asset previews", description: "Track loaded assets for the Assets tab." }, +]; + +const numberPhases = new Set([ + "port", + "sampleRate", + "updateInterval", + "maxTempLogs", + "retryInterval", + "connectTimeout", + "errorWait", + "binaryTextThreshold", +]); + +const textPromptTitles: Partial> = { + installDir: "Install directory", + branch: "GitHub branch or tag", + sessionName: "Session name shown in Feather", + host: "Desktop host", + port: "Desktop WebSocket port", + baseDir: "Base directory for file links", + sampleRate: "Sample rate in seconds", + updateInterval: "Update interval in seconds", + maxTempLogs: "Max temporary logs", + outputDir: "Output directory for logs", + retryInterval: "Reconnect interval in seconds", + connectTimeout: "Connect timeout in seconds", + errorWait: "Error delivery wait in seconds", + binaryTextThreshold: "Binary text threshold in bytes", + deviceId: "Device ID override", + capabilities: 'Capabilities ("all" or comma-separated)', +}; + +const isStrongApiKey = (value: string) => { + if (value.length < 16) return false; + if (/^(password|secret|changeme|dev|test|console|apikey)$/i.test(value)) return false; + + const classes = [ + /[a-z]/.test(value), + /[A-Z]/.test(value), + /\d/.test(value), + /[^A-Za-z0-9]/.test(value), + ].filter(Boolean).length; + + return classes >= 3 || value.length >= 24; +}; + +const numericValue = (value: string, fallback: number) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +}; + +const parseCapabilities = (value: string): string[] | "all" => { + const trimmed = value.trim(); + if (!trimmed || trimmed.toLowerCase() === "all") return "all"; + return trimmed + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +}; + +function DangerousName({ children }: { children: string }) { + return ( + + {children} + + ); +} + +function NameList({ + values, + getTone, +}: { + values: string[]; + getTone?: (value: string) => Tone | undefined; +}) { + if (values.length === 0) return (none); + + return ( + <> + {values.map((value, index) => ( + + {index > 0 ? , : null} + {value} + + ))} + + ); +} + +function SummaryValue({ value, tone }: { value: ReactNode; tone?: Tone }) { + if (typeof value === "string") return {value}; + return <>{value}; +} + +function SummaryRows({ rows }: { rows: SummaryRow[] }) { + return ( + + {rows.map((row) => ( + + {row.label.padEnd(17)} + + + ))} + + ); +} + +function InfoPanel({ + title, + tone = "info", + children, +}: { + title: string; + tone?: Tone; + children: ReactNode; +}) { + return ( + + + {title} + + + {children} + + + ); +} + +function cursorLine(active: boolean, text: string, description?: string, tone?: Tone) { + return ( + + + {active ? "❯" : " "}{" "} + {text} + + {description ? {description} : null} + + ); +} + +function TextInputPrompt({ + title, + value, + placeholder, + secure, + error, +}: { + title: string; + value: string; + placeholder?: string; + secure?: boolean; + error?: string; +}) { + const shown = secure && value ? "•".repeat(value.length) : value; + return ( + + {title} + + {shown || placeholder || " "} + + {error ? {error} : ←→ move · Backspace delete · Enter confirm} + + ); +} + +function SingleSelect({ + title, + options, + selected, + getTone, +}: { + title: string; + options: Option[]; + selected: number; + getTone?: (option: Option) => Tone | undefined; +}) { + return ( + + {title} + + {options.map((option, index) => ( + + {cursorLine(index === selected, `${index + 1}. ${option.label}`, option.description, getTone?.(option))} + + ))} + + ↑↓ or j/k navigate · 1-{options.length} jump · Enter select + + ); +} + +function MultiSelect({ + title, + options, + selected, + cursor, + getTone, + hint = "↑↓ or j/k navigate · Space toggle · a select all · Enter confirm", +}: { + title: string; + options: Option[]; + selected: Set; + cursor: number; + getTone?: (option: Option) => Tone | undefined; + hint?: string; +}) { + return ( + + {title} + + {options.length === 0 ? No options available. : null} + {options.map((option, index) => { + const tone = getTone?.(option); + return ( + + + + {index === cursor ? "❯" : " "} {selected.has(option.value) ? "◉" : "○"} + {" "} + {option.label} + + {option.description ? {option.description} : null} + + ); + })} + + {hint} + + ); +} + +function ConfirmPrompt({ title, value }: { title: string; value: boolean }) { + return ( + + {title} + + Yes + / + No + + y/← = yes · n/→ = no · Enter confirm + + ); +} + +function InitSetupPrompt({ + defaultMode, + defaultName, + defaultBranch, + defaultInstallDir, + onComplete, +}: { + defaultMode: InitMode; + defaultName: string; + defaultBranch: string; + defaultInstallDir: string; + onComplete: (setup: InitSetup) => void; +}) { + const { exit } = useApp(); + const [phase, setPhase] = useState("mode"); + const [modeIndex, setModeIndex] = useState(Math.max(0, modes.findIndex((mode) => mode.value === defaultMode))); + const [sourceIndex, setSourceIndex] = useState(0); + const [installDir, setInstallDir] = useState(defaultInstallDir); + const [branch, setBranch] = useState(defaultBranch); + const [sessionName, setSessionName] = useState(defaultName); + const [installPlugins, setInstallPlugins] = useState(true); + const [includeCursor, setIncludeCursor] = useState(0); + const [excludeCursor, setExcludeCursor] = useState(0); + const [toggleCursor, setToggleCursor] = useState(0); + const [include, setInclude] = useState>(new Set()); + const [exclude, setExclude] = useState>(new Set(defaultSkippedPlugins)); + const [advanced, setAdvanced] = useState(false); + const [host, setHost] = useState("127.0.0.1"); + const [port, setPort] = useState("4004"); + const [baseDir, setBaseDir] = useState(""); + const [sampleRate, setSampleRate] = useState("1"); + const [updateInterval, setUpdateInterval] = useState("0.1"); + const [maxTempLogs, setMaxTempLogs] = useState("200"); + const [outputDir, setOutputDir] = useState("logs"); + const [retryInterval, setRetryInterval] = useState("5"); + const [connectTimeout, setConnectTimeout] = useState("2"); + const [errorWait, setErrorWait] = useState("3"); + const [binaryTextThreshold, setBinaryTextThreshold] = useState("4096"); + const [deviceId, setDeviceId] = useState(""); + const [capabilities, setCapabilities] = useState("all"); + const [socketModeIndex, setSocketModeIndex] = useState(0); + const [toggles, setToggles] = useState>( + new Set(["debug", "wrapPrint", "defaultObservers", "autoRegisterErrorHandler", "writeToDisk", "assetPreview"]), + ); + const [apiKey, setApiKey] = useState(""); + const [apiKeyCopied, setApiKeyCopied] = useState(null); + const [appIdInput, setAppIdInput] = useState(""); + const [error, setError] = useState(); + + const mode = modes[modeIndex].value; + const installSource = installSources[sourceIndex].value; + const installsFiles = mode !== "cli"; + const pluginPromptsEnabled = mode === "cli" || installPlugins; + const needsApiKey = pluginPromptsEnabled && include.has("console"); + + const textValues: Record void]> = { + installDir: [installDir, setInstallDir], + branch: [branch, setBranch], + sessionName: [sessionName, setSessionName], + host: [host, setHost], + port: [port, setPort], + baseDir: [baseDir, setBaseDir], + sampleRate: [sampleRate, setSampleRate], + updateInterval: [updateInterval, setUpdateInterval], + maxTempLogs: [maxTempLogs, setMaxTempLogs], + outputDir: [outputDir, setOutputDir], + retryInterval: [retryInterval, setRetryInterval], + connectTimeout: [connectTimeout, setConnectTimeout], + errorWait: [errorWait, setErrorWait], + binaryTextThreshold: [binaryTextThreshold, setBinaryTextThreshold], + deviceId: [deviceId, setDeviceId], + capabilities: [capabilities, setCapabilities], + }; + + const nextAfterPluginChoice = () => setPhase(pluginPromptsEnabled ? "include" : "advanced"); + const nextAfterAdvanced = () => setPhase(advanced ? "host" : needsApiKey ? "apiKey" : "appId"); + const nextAfterToggles = () => setPhase(needsApiKey ? "apiKey" : "appId"); + + const finish = () => { + const config: Record = {}; + if (sessionName.trim()) config.sessionName = sessionName.trim(); + if (pluginPromptsEnabled && include.size > 0) config.include = [...include]; + if (pluginPromptsEnabled && exclude.size > 0) config.exclude = [...exclude]; + + if (advanced) { + config.debug = toggles.has("debug"); + config.host = host.trim() || "127.0.0.1"; + config.port = numericValue(port, 4004); + config.mode = socketModeIndex === 0 ? "socket" : "disk"; + if (baseDir.trim()) config.baseDir = baseDir.trim(); + config.wrapPrint = toggles.has("wrapPrint"); + config.maxTempLogs = numericValue(maxTempLogs, 200); + config.sampleRate = numericValue(sampleRate, 1); + config.updateInterval = numericValue(updateInterval, 0.1); + config.defaultObservers = toggles.has("defaultObservers"); + config.errorWait = numericValue(errorWait, 3); + config.autoRegisterErrorHandler = toggles.has("autoRegisterErrorHandler"); + config.captureScreenshot = toggles.has("captureScreenshot"); + config.writeToDisk = toggles.has("writeToDisk"); + config.outputDir = outputDir.trim() || "logs"; + config.capabilities = parseCapabilities(capabilities); + config.retryInterval = numericValue(retryInterval, 5); + config.connectTimeout = numericValue(connectTimeout, 2); + config.debugger = toggles.has("debugger"); + config.assetPreview = toggles.has("assetPreview"); + config.binaryTextThreshold = numericValue(binaryTextThreshold, 4096); + if (deviceId.trim()) config.deviceId = deviceId.trim(); + } + + if (needsApiKey) { + config.apiKey = apiKey; + config.pluginOptions = { + console: { evalEnabled: true }, + }; + } + + if (appIdInput.trim()) { + config.appId = appIdInput.trim(); + } else { + config.__DANGEROUS_INSECURE_CONNECTION__ = true; + } + + onComplete({ + mode, + source: installSource, + branch: branch.trim() || "main", + installDir: installDir.trim() || "feather", + installPlugins, + config, + exclude: pluginPromptsEnabled ? [...exclude] : [], + }); + exit(); + }; + + const move = (delta: number, count: number, setter: (value: number | ((value: number) => number)) => void) => { + if (count <= 0) return; + setter((value: number) => (value + count + delta) % count); + }; + + const editText = (input: string, key: Parameters[0]>[1], value: string, setter: (value: string) => void) => { + if (key.backspace || key.delete) { + setter(value.slice(0, -1)); + return true; + } + if (input && !key.ctrl && !key.meta && !key.return) { + setter(value + input); + return true; + } + return false; + }; + + const advanceTextPhase = () => { + const order: Phase[] = [ + "host", + "port", + "modeConfig", + "baseDir", + "sampleRate", + "updateInterval", + "maxTempLogs", + "outputDir", + "retryInterval", + "connectTimeout", + "errorWait", + "binaryTextThreshold", + "deviceId", + "capabilities", + "toggles", + ]; + const index = order.indexOf(phase); + setPhase(order[index + 1] ?? "summary"); + }; + + useInput((input, key) => { + setError(undefined); + + if (key.escape) { + exit(); + return; + } + + if (phase === "mode") { + if (key.upArrow || input === "k") move(-1, modes.length, setModeIndex); + else if (key.downArrow || input === "j") move(1, modes.length, setModeIndex); + else if (Number(input) >= 1 && Number(input) <= modes.length) setModeIndex(Number(input) - 1); + else if (key.return) setPhase(modes[modeIndex].value === "cli" ? "sessionName" : "installDir"); + return; + } + + if (phase === "installDir") { + if (key.return) setPhase("source"); + else editText(input, key, installDir, setInstallDir); + return; + } + + if (phase === "source") { + if (key.upArrow || input === "k") move(-1, installSources.length, setSourceIndex); + else if (key.downArrow || input === "j") move(1, installSources.length, setSourceIndex); + else if (Number(input) >= 1 && Number(input) <= installSources.length) setSourceIndex(Number(input) - 1); + else if (key.return) setPhase(installSources[sourceIndex].value === "remote" ? "branch" : "sessionName"); + return; + } + + if (phase === "branch") { + if (key.return) setPhase("sessionName"); + else editText(input, key, branch, setBranch); + return; + } + + if (phase === "sessionName") { + if (key.return) setPhase(installsFiles ? "installPlugins" : "include"); + else editText(input, key, sessionName, setSessionName); + return; + } + + if (phase === "installPlugins") { + if (input === "y" || key.leftArrow) setInstallPlugins(true); + else if (input === "n" || key.rightArrow) { + setInstallPlugins(false); + setInclude(new Set()); + setExclude(new Set()); + } else if (key.return) nextAfterPluginChoice(); + return; + } + + if (phase === "include") { + if (key.upArrow || input === "k") move(-1, optionalPlugins.length, setIncludeCursor); + else if (key.downArrow || input === "j") move(1, optionalPlugins.length, setIncludeCursor); + else if (input === " " && optionalPlugins[includeCursor]) { + setInclude((current) => { + const next = new Set(current); + const value = optionalPlugins[includeCursor].value; + if (next.has(value)) { + next.delete(value); + } else { + next.add(value); + setExclude((currentExclude) => { + const nextExclude = new Set(currentExclude); + nextExclude.delete(value); + return nextExclude; + }); + } + return next; + }); + } else if (key.return) setPhase("exclude"); + return; + } + + if (phase === "exclude") { + if (key.upArrow || input === "k") move(-1, skipPluginOptions.length, setExcludeCursor); + else if (key.downArrow || input === "j") move(1, skipPluginOptions.length, setExcludeCursor); + else if (input === " " && skipPluginOptions[excludeCursor]) { + setExclude((current) => { + const next = new Set(current); + const value = skipPluginOptions[excludeCursor].value; + if (next.has(value)) next.delete(value); + else { + next.add(value); + setInclude((currentInclude) => { + const nextInclude = new Set(currentInclude); + nextInclude.delete(value); + return nextInclude; + }); + } + return next; + }); + } else if (key.return) setPhase("advanced"); + return; + } + + if (phase === "advanced") { + if (input === "y" || key.leftArrow) setAdvanced(true); + else if (input === "n" || key.rightArrow) setAdvanced(false); + else if (key.return) nextAfterAdvanced(); + return; + } + + if (phase === "modeConfig") { + if (key.upArrow || input === "k") move(-1, 2, setSocketModeIndex); + else if (key.downArrow || input === "j") move(1, 2, setSocketModeIndex); + else if (key.return) advanceTextPhase(); + return; + } + + if (phase === "toggles") { + if (key.upArrow || input === "k") move(-1, configToggles.length, setToggleCursor); + else if (key.downArrow || input === "j") move(1, configToggles.length, setToggleCursor); + else if (input === " ") { + setToggles((current) => { + const next = new Set(current); + const value = configToggles[toggleCursor].value; + if (next.has(value)) next.delete(value); + else next.add(value); + return next; + }); + } else if (key.return) nextAfterToggles(); + return; + } + + if (phase === "apiKey") { + if (key.return) { + if (!isStrongApiKey(apiKey)) { + setError("Use at least 16 chars and mix upper/lowercase, numbers, or symbols."); + return; + } + setApiKeyCopied(copyToClipboard(apiKey)); + setPhase("appId"); + } else { + editText(input, key, apiKey, setApiKey); + } + return; + } + + if (phase === "appId") { + if (key.return) { + setPhase("summary"); + } else { + editText(input, key, appIdInput, setAppIdInput); + } + return; + } + + if (textValues[phase]) { + const [value, setter] = textValues[phase]; + if (key.return) advanceTextPhase(); + else if (!numberPhases.has(phase) || /^[\d.]*$/.test(input) || key.backspace || key.delete) { + editText(input, key, value, setter); + } + return; + } + + if (key.return) finish(); + }); + + const summary = useMemo(() => { + const includeList = [...include]; + const excludeList = [...exclude]; + const rows: SummaryRow[] = [ + { id: "mode", label: "Mode", value: modes[modeIndex].label }, + { + id: "install-dir", + label: "Install dir", + value: installsFiles ? `${installDir || "feather"}/` : "bundled CLI runtime", + }, + installsFiles ? { id: "source", label: "Source", value: installSources[sourceIndex].label } : undefined, + installsFiles && installSource === "remote" ? { id: "branch", label: "Branch", value: branch || "main" } : undefined, + installsFiles ? { id: "plugins", label: "Install plugins", value: installPlugins ? "yes" : "no" } : undefined, + { id: "session", label: "Session", value: sessionName || "(default)" }, + pluginPromptsEnabled + ? { id: "include", label: "Include", value: } + : undefined, + pluginPromptsEnabled + ? { id: "exclude", label: "Exclude", value: } + : undefined, + { id: "advanced", label: "Advanced config", value: advanced ? "yes" : "no", tone: advanced ? "warning" : undefined }, + ].filter(Boolean) as SummaryRow[]; + + if (needsApiKey) { + rows.push({ + id: "api-key", + label: "Console API key", + value: + apiKeyCopied === true + ? "set and copied to clipboard" + : apiKeyCopied === false + ? "set (clipboard copy unavailable)" + : "set", + tone: "success", + }); + } + rows.push( + appIdInput.trim() + ? { id: "app-id", label: "App ID", value: appIdInput.trim(), tone: "success" } + : { + id: "app-id", + label: "App ID", + value: ( + <> + not set → + {dangerousInsecureConnection} + = true + + ), + tone: "danger", + }, + ); + return rows; + }, [ + advanced, + apiKeyCopied, + appIdInput, + branch, + exclude, + include, + installDir, + installPlugins, + installsFiles, + installSource, + modeIndex, + needsApiKey, + pluginPromptsEnabled, + sessionName, + sourceIndex, + ]); + + if (phase === "mode") return ; + if (phase === "source") return ; + if (phase === "installPlugins") return ; + if (phase === "include") { + return ( + pluginTone(option.value)} + /> + ); + } + if (phase === "exclude") { + return ( + pluginTone(option.value)} + /> + ); + } + if (phase === "advanced") return ; + if (phase === "modeConfig") { + return ( + + ); + } + if (phase === "toggles") { + return ( + toggleTone(option.value)} + /> + ); + } + if (phase === "apiKey") { + return ; + } + if (phase === "appId") { + return ( + + + + Feather desktop app → Settings → Security → Desktop App ID. + + + + Writes {dangerousInsecureConnection} + = true to feather.config.lua. + + Use only for trusted local development. Any Feather desktop can connect to the game. + + + ); + } + if (textValues[phase]) { + const [value] = textValues[phase]; + return ; + } + + const enabledAdvancedOptions = advanced ? configToggles.filter((option) => toggles.has(option.value)).map((option) => option.value) : []; + const writesRuntimeFiles = mode !== "cli"; + const patchesMainLua = mode === "auto"; + + return ( + + + Ready to initialize Feather + + + + {writesRuntimeFiles ? ( + <> + + Runtime files will be written under {installDir || "feather"}/. + + {patchesMainLua ? ( + main.lua will be patched with a USE_DEBUGGER-guarded require. + ) : ( + feather.debugger.lua will be created for manual loading. + )} + + ) : ( + No runtime files will be copied; the CLI runtime is used at launch. + )} + + + {appIdInput.trim() ? ( + Desktop connections are limited to the configured App ID. + ) : ( + <> + + {dangerousInsecureConnection} + is enabled. + + Any Feather desktop can send commands to this game while it is running. + + )} + + {advanced ? ( + + + Enabled: + + + ) : null} + Press Enter to continue. + + ); +} + +export async function chooseInitMode( + defaultMode: InitMode = "auto", + defaultName = "My Game", + defaultBranch = "main", + defaultInstallDir = "feather", +): Promise { + return new Promise((resolve) => { + render( + , + ); + }); +} diff --git a/cli/src/ui/init-mode.tsx b/cli/src/ui/init-mode.tsx index 5bfeb05a..c9491db9 100644 --- a/cli/src/ui/init-mode.tsx +++ b/cli/src/ui/init-mode.tsx @@ -1,928 +1,2 @@ -import React, { type ReactNode, useMemo, useState } from "react"; -import { Box, Text, render, useApp, useInput } from "ink"; -import { copyToClipboard } from "../lib/clipboard.js"; -import { pluginCatalog } from "../generated/plugin-catalog.js"; - -export type InitMode = "cli" | "auto" | "manual"; - -export type InitSetup = { - mode: InitMode; - source: "local" | "remote"; - branch: string; - installDir: string; - installPlugins: boolean; - config: Record; - exclude: string[]; -}; - -type Phase = - | "mode" - | "installDir" - | "source" - | "branch" - | "sessionName" - | "installPlugins" - | "include" - | "exclude" - | "advanced" - | "host" - | "port" - | "modeConfig" - | "baseDir" - | "sampleRate" - | "updateInterval" - | "maxTempLogs" - | "outputDir" - | "retryInterval" - | "connectTimeout" - | "errorWait" - | "binaryTextThreshold" - | "deviceId" - | "capabilities" - | "toggles" - | "apiKey" - | "appId" - | "summary"; - -type Option = { - value: T; - label: string; - description?: string; -}; - -type Tone = "info" | "success" | "warning" | "danger"; - -type SummaryRow = { - id: string; - label: string; - value: ReactNode; - tone?: Tone; -}; - -const dangerousInsecureConnection = "__DANGEROUS_INSECURE_CONNECTION__"; -const dangerousPluginIds = new Set(["console", "hot-reload"]); -const dangerousToggleIds = new Set(["debugger", "captureScreenshot"]); -const warningToggleIds = new Set(["autoRegisterErrorHandler", "writeToDisk"]); - -const toneColor = (tone?: Tone) => { - if (tone === "danger") return "red"; - if (tone === "warning") return "yellow"; - if (tone === "success") return "green"; - if (tone === "info") return "cyan"; - return undefined; -}; - -const pluginTone = (value: string): Tone | undefined => (dangerousPluginIds.has(value) ? "danger" : undefined); -const toggleTone = (value: string): Tone | undefined => { - if (dangerousToggleIds.has(value)) return "danger"; - if (warningToggleIds.has(value)) return "warning"; - return undefined; -}; - -const modes: Option[] = [ - { - value: "cli", - label: "CLI injection", - description: "Create feather.config.lua only. Run with `feather run .`.", - }, - { - value: "auto", - label: "Auto require", - description: 'Patch main.lua with a USE_DEBUGGER-guarded require("feather.auto").', - }, - { - value: "manual", - label: "Manual setup", - description: "Create feather.debugger.lua and load it when USE_DEBUGGER is set.", - }, -]; - -const installSources: Option<"local" | "remote">[] = [ - { - value: "local", - label: "Bundled/local copy", - description: "Copy the CLI-bundled Lua runtime, or src-lua when running from the repo.", - }, - { - value: "remote", - label: "GitHub download", - description: "Fetch files from GitHub using the selected branch or tag.", - }, -]; - -const pluginToOption = (plugin: (typeof pluginCatalog)[number]): Option => ({ - value: plugin.id, - label: plugin.name, - description: plugin.description, -}); - -const optionalPlugins: Option[] = pluginCatalog.filter((plugin) => plugin.optIn).map(pluginToOption); -const skipPluginOptions: Option[] = pluginCatalog.map(pluginToOption); -const defaultSkippedPlugins = new Set(["console", "hot-reload", "hump.signal", "lua-state-machine"]); - -const configToggles: Option[] = [ - { value: "debug", label: "Enable Feather", description: "Set debug = true." }, - { value: "wrapPrint", label: "Wrap print()", description: "Send print output to Logs." }, - { value: "defaultObservers", label: "Default observers", description: "Capture built-in runtime observers." }, - { - value: "autoRegisterErrorHandler", - label: "Error handler", - description: "Capture Lua errors before LÖVE shows its handler.", - }, - { value: "writeToDisk", label: "Write logs to disk", description: "Persist .featherlog files." }, - { value: "debugger", label: "Step debugger", description: "Enable debugger commands by default." }, - { value: "captureScreenshot", label: "Error screenshots", description: "Capture screenshots on errors." }, - { value: "assetPreview", label: "Asset previews", description: "Track loaded assets for the Assets tab." }, -]; - -const numberPhases = new Set([ - "port", - "sampleRate", - "updateInterval", - "maxTempLogs", - "retryInterval", - "connectTimeout", - "errorWait", - "binaryTextThreshold", -]); - -const textPromptTitles: Partial> = { - installDir: "Install directory", - branch: "GitHub branch or tag", - sessionName: "Session name shown in Feather", - host: "Desktop host", - port: "Desktop WebSocket port", - baseDir: "Base directory for file links", - sampleRate: "Sample rate in seconds", - updateInterval: "Update interval in seconds", - maxTempLogs: "Max temporary logs", - outputDir: "Output directory for logs", - retryInterval: "Reconnect interval in seconds", - connectTimeout: "Connect timeout in seconds", - errorWait: "Error delivery wait in seconds", - binaryTextThreshold: "Binary text threshold in bytes", - deviceId: "Device ID override", - capabilities: 'Capabilities ("all" or comma-separated)', -}; - -const isStrongApiKey = (value: string) => { - if (value.length < 16) return false; - if (/^(password|secret|changeme|dev|test|console|apikey)$/i.test(value)) return false; - - const classes = [ - /[a-z]/.test(value), - /[A-Z]/.test(value), - /\d/.test(value), - /[^A-Za-z0-9]/.test(value), - ].filter(Boolean).length; - - return classes >= 3 || value.length >= 24; -}; - -const numericValue = (value: string, fallback: number) => { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : fallback; -}; - -const parseCapabilities = (value: string): string[] | "all" => { - const trimmed = value.trim(); - if (!trimmed || trimmed.toLowerCase() === "all") return "all"; - return trimmed - .split(",") - .map((item) => item.trim()) - .filter(Boolean); -}; - -function DangerousName({ children }: { children: string }) { - return ( - - {children} - - ); -} - -function NameList({ - values, - getTone, -}: { - values: string[]; - getTone?: (value: string) => Tone | undefined; -}) { - if (values.length === 0) return (none); - - return ( - <> - {values.map((value, index) => ( - - {index > 0 ? , : null} - {value} - - ))} - - ); -} - -function SummaryValue({ value, tone }: { value: ReactNode; tone?: Tone }) { - if (typeof value === "string") return {value}; - return <>{value}; -} - -function SummaryRows({ rows }: { rows: SummaryRow[] }) { - return ( - - {rows.map((row) => ( - - {row.label.padEnd(17)} - - - ))} - - ); -} - -function InfoPanel({ - title, - tone = "info", - children, -}: { - title: string; - tone?: Tone; - children: ReactNode; -}) { - return ( - - - {title} - - - {children} - - - ); -} - -function cursorLine(active: boolean, text: string, description?: string, tone?: Tone) { - return ( - - - {active ? "❯" : " "}{" "} - {text} - - {description ? {description} : null} - - ); -} - -function TextInputPrompt({ - title, - value, - placeholder, - secure, - error, -}: { - title: string; - value: string; - placeholder?: string; - secure?: boolean; - error?: string; -}) { - const shown = secure && value ? "•".repeat(value.length) : value; - return ( - - {title} - - {shown || placeholder || " "} - - {error ? {error} : ←→ move · Backspace delete · Enter confirm} - - ); -} - -function SingleSelect({ - title, - options, - selected, - getTone, -}: { - title: string; - options: Option[]; - selected: number; - getTone?: (option: Option) => Tone | undefined; -}) { - return ( - - {title} - - {options.map((option, index) => ( - - {cursorLine(index === selected, `${index + 1}. ${option.label}`, option.description, getTone?.(option))} - - ))} - - ↑↓ or j/k navigate · 1-{options.length} jump · Enter select - - ); -} - -function MultiSelect({ - title, - options, - selected, - cursor, - getTone, - hint = "↑↓ or j/k navigate · Space toggle · a select all · Enter confirm", -}: { - title: string; - options: Option[]; - selected: Set; - cursor: number; - getTone?: (option: Option) => Tone | undefined; - hint?: string; -}) { - return ( - - {title} - - {options.length === 0 ? No options available. : null} - {options.map((option, index) => { - const tone = getTone?.(option); - return ( - - - - {index === cursor ? "❯" : " "} {selected.has(option.value) ? "◉" : "○"} - {" "} - {option.label} - - {option.description ? {option.description} : null} - - ); - })} - - {hint} - - ); -} - -function ConfirmPrompt({ title, value }: { title: string; value: boolean }) { - return ( - - {title} - - Yes - / - No - - y/← = yes · n/→ = no · Enter confirm - - ); -} - -function InitSetupPrompt({ - defaultMode, - defaultName, - defaultBranch, - defaultInstallDir, - onComplete, -}: { - defaultMode: InitMode; - defaultName: string; - defaultBranch: string; - defaultInstallDir: string; - onComplete: (setup: InitSetup) => void; -}) { - const { exit } = useApp(); - const [phase, setPhase] = useState("mode"); - const [modeIndex, setModeIndex] = useState(Math.max(0, modes.findIndex((mode) => mode.value === defaultMode))); - const [sourceIndex, setSourceIndex] = useState(0); - const [installDir, setInstallDir] = useState(defaultInstallDir); - const [branch, setBranch] = useState(defaultBranch); - const [sessionName, setSessionName] = useState(defaultName); - const [installPlugins, setInstallPlugins] = useState(true); - const [includeCursor, setIncludeCursor] = useState(0); - const [excludeCursor, setExcludeCursor] = useState(0); - const [toggleCursor, setToggleCursor] = useState(0); - const [include, setInclude] = useState>(new Set()); - const [exclude, setExclude] = useState>(new Set(defaultSkippedPlugins)); - const [advanced, setAdvanced] = useState(false); - const [host, setHost] = useState("127.0.0.1"); - const [port, setPort] = useState("4004"); - const [baseDir, setBaseDir] = useState(""); - const [sampleRate, setSampleRate] = useState("1"); - const [updateInterval, setUpdateInterval] = useState("0.1"); - const [maxTempLogs, setMaxTempLogs] = useState("200"); - const [outputDir, setOutputDir] = useState("logs"); - const [retryInterval, setRetryInterval] = useState("5"); - const [connectTimeout, setConnectTimeout] = useState("2"); - const [errorWait, setErrorWait] = useState("3"); - const [binaryTextThreshold, setBinaryTextThreshold] = useState("4096"); - const [deviceId, setDeviceId] = useState(""); - const [capabilities, setCapabilities] = useState("all"); - const [socketModeIndex, setSocketModeIndex] = useState(0); - const [toggles, setToggles] = useState>( - new Set(["debug", "wrapPrint", "defaultObservers", "autoRegisterErrorHandler", "writeToDisk", "assetPreview"]), - ); - const [apiKey, setApiKey] = useState(""); - const [apiKeyCopied, setApiKeyCopied] = useState(null); - const [appIdInput, setAppIdInput] = useState(""); - const [error, setError] = useState(); - - const mode = modes[modeIndex].value; - const installSource = installSources[sourceIndex].value; - const installsFiles = mode !== "cli"; - const pluginPromptsEnabled = mode === "cli" || installPlugins; - const needsApiKey = pluginPromptsEnabled && include.has("console"); - - const textValues: Record void]> = { - installDir: [installDir, setInstallDir], - branch: [branch, setBranch], - sessionName: [sessionName, setSessionName], - host: [host, setHost], - port: [port, setPort], - baseDir: [baseDir, setBaseDir], - sampleRate: [sampleRate, setSampleRate], - updateInterval: [updateInterval, setUpdateInterval], - maxTempLogs: [maxTempLogs, setMaxTempLogs], - outputDir: [outputDir, setOutputDir], - retryInterval: [retryInterval, setRetryInterval], - connectTimeout: [connectTimeout, setConnectTimeout], - errorWait: [errorWait, setErrorWait], - binaryTextThreshold: [binaryTextThreshold, setBinaryTextThreshold], - deviceId: [deviceId, setDeviceId], - capabilities: [capabilities, setCapabilities], - }; - - const nextAfterPluginChoice = () => setPhase(pluginPromptsEnabled ? "include" : "advanced"); - const nextAfterAdvanced = () => setPhase(advanced ? "host" : needsApiKey ? "apiKey" : "appId"); - const nextAfterToggles = () => setPhase(needsApiKey ? "apiKey" : "appId"); - - const finish = () => { - const config: Record = {}; - if (sessionName.trim()) config.sessionName = sessionName.trim(); - if (pluginPromptsEnabled && include.size > 0) config.include = [...include]; - if (pluginPromptsEnabled && exclude.size > 0) config.exclude = [...exclude]; - - if (advanced) { - config.debug = toggles.has("debug"); - config.host = host.trim() || "127.0.0.1"; - config.port = numericValue(port, 4004); - config.mode = socketModeIndex === 0 ? "socket" : "disk"; - if (baseDir.trim()) config.baseDir = baseDir.trim(); - config.wrapPrint = toggles.has("wrapPrint"); - config.maxTempLogs = numericValue(maxTempLogs, 200); - config.sampleRate = numericValue(sampleRate, 1); - config.updateInterval = numericValue(updateInterval, 0.1); - config.defaultObservers = toggles.has("defaultObservers"); - config.errorWait = numericValue(errorWait, 3); - config.autoRegisterErrorHandler = toggles.has("autoRegisterErrorHandler"); - config.captureScreenshot = toggles.has("captureScreenshot"); - config.writeToDisk = toggles.has("writeToDisk"); - config.outputDir = outputDir.trim() || "logs"; - config.capabilities = parseCapabilities(capabilities); - config.retryInterval = numericValue(retryInterval, 5); - config.connectTimeout = numericValue(connectTimeout, 2); - config.debugger = toggles.has("debugger"); - config.assetPreview = toggles.has("assetPreview"); - config.binaryTextThreshold = numericValue(binaryTextThreshold, 4096); - if (deviceId.trim()) config.deviceId = deviceId.trim(); - } - - if (needsApiKey) { - config.apiKey = apiKey; - config.pluginOptions = { - console: { evalEnabled: true }, - }; - } - - if (appIdInput.trim()) { - config.appId = appIdInput.trim(); - } else { - config.__DANGEROUS_INSECURE_CONNECTION__ = true; - } - - onComplete({ - mode, - source: installSource, - branch: branch.trim() || "main", - installDir: installDir.trim() || "feather", - installPlugins, - config, - exclude: pluginPromptsEnabled ? [...exclude] : [], - }); - exit(); - }; - - const move = (delta: number, count: number, setter: (value: number | ((value: number) => number)) => void) => { - if (count <= 0) return; - setter((value: number) => (value + count + delta) % count); - }; - - const editText = (input: string, key: Parameters[0]>[1], value: string, setter: (value: string) => void) => { - if (key.backspace || key.delete) { - setter(value.slice(0, -1)); - return true; - } - if (input && !key.ctrl && !key.meta && !key.return) { - setter(value + input); - return true; - } - return false; - }; - - const advanceTextPhase = () => { - const order: Phase[] = [ - "host", - "port", - "modeConfig", - "baseDir", - "sampleRate", - "updateInterval", - "maxTempLogs", - "outputDir", - "retryInterval", - "connectTimeout", - "errorWait", - "binaryTextThreshold", - "deviceId", - "capabilities", - "toggles", - ]; - const index = order.indexOf(phase); - setPhase(order[index + 1] ?? "summary"); - }; - - useInput((input, key) => { - setError(undefined); - - if (key.escape) { - exit(); - return; - } - - if (phase === "mode") { - if (key.upArrow || input === "k") move(-1, modes.length, setModeIndex); - else if (key.downArrow || input === "j") move(1, modes.length, setModeIndex); - else if (Number(input) >= 1 && Number(input) <= modes.length) setModeIndex(Number(input) - 1); - else if (key.return) setPhase(modes[modeIndex].value === "cli" ? "sessionName" : "installDir"); - return; - } - - if (phase === "installDir") { - if (key.return) setPhase("source"); - else editText(input, key, installDir, setInstallDir); - return; - } - - if (phase === "source") { - if (key.upArrow || input === "k") move(-1, installSources.length, setSourceIndex); - else if (key.downArrow || input === "j") move(1, installSources.length, setSourceIndex); - else if (Number(input) >= 1 && Number(input) <= installSources.length) setSourceIndex(Number(input) - 1); - else if (key.return) setPhase(installSources[sourceIndex].value === "remote" ? "branch" : "sessionName"); - return; - } - - if (phase === "branch") { - if (key.return) setPhase("sessionName"); - else editText(input, key, branch, setBranch); - return; - } - - if (phase === "sessionName") { - if (key.return) setPhase(installsFiles ? "installPlugins" : "include"); - else editText(input, key, sessionName, setSessionName); - return; - } - - if (phase === "installPlugins") { - if (input === "y" || key.leftArrow) setInstallPlugins(true); - else if (input === "n" || key.rightArrow) { - setInstallPlugins(false); - setInclude(new Set()); - setExclude(new Set()); - } else if (key.return) nextAfterPluginChoice(); - return; - } - - if (phase === "include") { - if (key.upArrow || input === "k") move(-1, optionalPlugins.length, setIncludeCursor); - else if (key.downArrow || input === "j") move(1, optionalPlugins.length, setIncludeCursor); - else if (input === " " && optionalPlugins[includeCursor]) { - setInclude((current) => { - const next = new Set(current); - const value = optionalPlugins[includeCursor].value; - if (next.has(value)) { - next.delete(value); - } else { - next.add(value); - setExclude((currentExclude) => { - const nextExclude = new Set(currentExclude); - nextExclude.delete(value); - return nextExclude; - }); - } - return next; - }); - } else if (key.return) setPhase("exclude"); - return; - } - - if (phase === "exclude") { - if (key.upArrow || input === "k") move(-1, skipPluginOptions.length, setExcludeCursor); - else if (key.downArrow || input === "j") move(1, skipPluginOptions.length, setExcludeCursor); - else if (input === " " && skipPluginOptions[excludeCursor]) { - setExclude((current) => { - const next = new Set(current); - const value = skipPluginOptions[excludeCursor].value; - if (next.has(value)) next.delete(value); - else { - next.add(value); - setInclude((currentInclude) => { - const nextInclude = new Set(currentInclude); - nextInclude.delete(value); - return nextInclude; - }); - } - return next; - }); - } else if (key.return) setPhase("advanced"); - return; - } - - if (phase === "advanced") { - if (input === "y" || key.leftArrow) setAdvanced(true); - else if (input === "n" || key.rightArrow) setAdvanced(false); - else if (key.return) nextAfterAdvanced(); - return; - } - - if (phase === "modeConfig") { - if (key.upArrow || input === "k") move(-1, 2, setSocketModeIndex); - else if (key.downArrow || input === "j") move(1, 2, setSocketModeIndex); - else if (key.return) advanceTextPhase(); - return; - } - - if (phase === "toggles") { - if (key.upArrow || input === "k") move(-1, configToggles.length, setToggleCursor); - else if (key.downArrow || input === "j") move(1, configToggles.length, setToggleCursor); - else if (input === " ") { - setToggles((current) => { - const next = new Set(current); - const value = configToggles[toggleCursor].value; - if (next.has(value)) next.delete(value); - else next.add(value); - return next; - }); - } else if (key.return) nextAfterToggles(); - return; - } - - if (phase === "apiKey") { - if (key.return) { - if (!isStrongApiKey(apiKey)) { - setError("Use at least 16 chars and mix upper/lowercase, numbers, or symbols."); - return; - } - setApiKeyCopied(copyToClipboard(apiKey)); - setPhase("appId"); - } else { - editText(input, key, apiKey, setApiKey); - } - return; - } - - if (phase === "appId") { - if (key.return) { - setPhase("summary"); - } else { - editText(input, key, appIdInput, setAppIdInput); - } - return; - } - - if (textValues[phase]) { - const [value, setter] = textValues[phase]; - if (key.return) advanceTextPhase(); - else if (!numberPhases.has(phase) || /^[\d.]*$/.test(input) || key.backspace || key.delete) { - editText(input, key, value, setter); - } - return; - } - - if (key.return) finish(); - }); - - const summary = useMemo(() => { - const includeList = [...include]; - const excludeList = [...exclude]; - const rows: SummaryRow[] = [ - { id: "mode", label: "Mode", value: modes[modeIndex].label }, - { - id: "install-dir", - label: "Install dir", - value: installsFiles ? `${installDir || "feather"}/` : "bundled CLI runtime", - }, - installsFiles ? { id: "source", label: "Source", value: installSources[sourceIndex].label } : undefined, - installsFiles && installSource === "remote" ? { id: "branch", label: "Branch", value: branch || "main" } : undefined, - installsFiles ? { id: "plugins", label: "Install plugins", value: installPlugins ? "yes" : "no" } : undefined, - { id: "session", label: "Session", value: sessionName || "(default)" }, - pluginPromptsEnabled - ? { id: "include", label: "Include", value: } - : undefined, - pluginPromptsEnabled - ? { id: "exclude", label: "Exclude", value: } - : undefined, - { id: "advanced", label: "Advanced config", value: advanced ? "yes" : "no", tone: advanced ? "warning" : undefined }, - ].filter(Boolean) as SummaryRow[]; - - if (needsApiKey) { - rows.push({ - id: "api-key", - label: "Console API key", - value: - apiKeyCopied === true - ? "set and copied to clipboard" - : apiKeyCopied === false - ? "set (clipboard copy unavailable)" - : "set", - tone: "success", - }); - } - rows.push( - appIdInput.trim() - ? { id: "app-id", label: "App ID", value: appIdInput.trim(), tone: "success" } - : { - id: "app-id", - label: "App ID", - value: ( - <> - not set → - {dangerousInsecureConnection} - = true - - ), - tone: "danger", - }, - ); - return rows; - }, [ - advanced, - apiKeyCopied, - appIdInput, - branch, - exclude, - include, - installDir, - installPlugins, - installsFiles, - installSource, - modeIndex, - needsApiKey, - pluginPromptsEnabled, - sessionName, - sourceIndex, - ]); - - if (phase === "mode") return ; - if (phase === "source") return ; - if (phase === "installPlugins") return ; - if (phase === "include") { - return ( - pluginTone(option.value)} - /> - ); - } - if (phase === "exclude") { - return ( - pluginTone(option.value)} - /> - ); - } - if (phase === "advanced") return ; - if (phase === "modeConfig") { - return ( - - ); - } - if (phase === "toggles") { - return ( - toggleTone(option.value)} - /> - ); - } - if (phase === "apiKey") { - return ; - } - if (phase === "appId") { - return ( - - - - Feather desktop app → Settings → Security → Desktop App ID. - - - - Writes {dangerousInsecureConnection} - = true to feather.config.lua. - - Use only for trusted local development. Any Feather desktop can connect to the game. - - - ); - } - if (textValues[phase]) { - const [value] = textValues[phase]; - return ; - } - - const enabledAdvancedOptions = advanced ? configToggles.filter((option) => toggles.has(option.value)).map((option) => option.value) : []; - const writesRuntimeFiles = mode !== "cli"; - const patchesMainLua = mode === "auto"; - - return ( - - - Ready to initialize Feather - - - - {writesRuntimeFiles ? ( - <> - - Runtime files will be written under {installDir || "feather"}/. - - {patchesMainLua ? ( - main.lua will be patched with a USE_DEBUGGER-guarded require. - ) : ( - feather.debugger.lua will be created for manual loading. - )} - - ) : ( - No runtime files will be copied; the CLI runtime is used at launch. - )} - - - {appIdInput.trim() ? ( - Desktop connections are limited to the configured App ID. - ) : ( - <> - - {dangerousInsecureConnection} - is enabled. - - Any Feather desktop can send commands to this game while it is running. - - )} - - {advanced ? ( - - - Enabled: - - - ) : null} - Press Enter to continue. - - ); -} - -export async function chooseInitMode( - defaultMode: InitMode = "auto", - defaultName = "My Game", - defaultBranch = "main", - defaultInstallDir = "feather", -): Promise { - return new Promise((resolve) => { - render( - , - ); - }); -} +export type { InitMode, InitSetup } from './init-mode-workflow.js'; +export { chooseInitMode } from './init-mode-workflow.js'; diff --git a/cli/src/ui/package-add-helpers.ts b/cli/src/ui/package-add-helpers.ts new file mode 100644 index 00000000..cec8b1ed --- /dev/null +++ b/cli/src/ui/package-add-helpers.ts @@ -0,0 +1,27 @@ +import { GH_HEADERS } from '../lib/github.js'; + +export const REPO_TOTAL = 7; +export const URL_TOTAL = 4; + +export async function fetchRepoMeta(repo: string): Promise<{ values: string[]; labels: string[] }> { + const [tagsRes, repoRes, branchesRes] = await Promise.all([ + fetch(`https://api.github.com/repos/${repo}/tags?per_page=20`, { headers: GH_HEADERS }), + fetch(`https://api.github.com/repos/${repo}`, { headers: GH_HEADERS }), + fetch(`https://api.github.com/repos/${repo}/branches?per_page=30`, { headers: GH_HEADERS }), + ]); + if (!tagsRes.ok) throw new Error(`GitHub API ${tagsRes.status} fetching tags for ${repo}`); + if (!repoRes.ok) throw new Error(`GitHub API ${repoRes.status} fetching repo info for ${repo}`); + if (!branchesRes.ok) throw new Error(`GitHub API ${branchesRes.status} fetching branches for ${repo}`); + const [tagsData, repoData, branchesData] = await Promise.all([ + tagsRes.json() as Promise>, + repoRes.json() as Promise<{ default_branch?: string }>, + branchesRes.json() as Promise>, + ]); + const tags = tagsData.map((tag) => tag.name); + const defaultBranch = repoData.default_branch ?? 'main'; + const branches = branchesData.map((branch) => branch.name); + const orderedBranches = [defaultBranch, ...branches.filter((branch) => branch !== defaultBranch)]; + const values = [...tags, ...orderedBranches]; + const labels = [...tags, ...orderedBranches.map((branch) => `⎇ ${branch}`)]; + return { values, labels }; +} diff --git a/cli/src/ui/package-add-steps.tsx b/cli/src/ui/package-add-steps.tsx new file mode 100644 index 00000000..ff019cc7 --- /dev/null +++ b/cli/src/ui/package-add-steps.tsx @@ -0,0 +1,246 @@ +import { Box, Text, useInput } from 'ink'; +import { useEffect, useState } from 'react'; +import { installCustomRepoPackage, installCustomUrlPackage } from '../lib/package/custom-add.js'; +import type { Lockfile } from '../lib/package/lockfile.js'; +import type { UrlFile } from './components.js'; +import { REPO_TOTAL, URL_TOTAL } from './package-add-helpers.js'; + +export function RepoConfirmStep({ + id, + repoName, + tag, + selectedFiles, + targetMap, + onConfirm, + onAbort, +}: { + id: string; + repoName: string; + tag: string; + selectedFiles: string[]; + targetMap: Record; + onConfirm: () => void; + onAbort: () => void; +}) { + useInput((input, key) => { + if (input === 'y' || input === 'Y' || key.return) onConfirm(); + else if (input === 'n' || input === 'N' || key.escape) onAbort(); + }); + return ( + + + + {' '}feather package add + + {` Step ${REPO_TOTAL} of ${REPO_TOTAL}`} + + {' '}Review before installing + + + {' '}Package: {id} + + + {' '}Source: github.com/{repoName} + + + {' '}Version: {tag} (commit SHA pinned) + + + {' '}Trust: experimental ⚠ + + {' '}Not reviewed by the Feather team. + + + {selectedFiles.map((file) => ( + + {' '} + {file} → {targetMap[file]} + + ))} + + + {' y/Enter = install · n/Esc = abort'} + + + ); +} +export function UrlConfirmStep({ + id, + urlFiles, + onConfirm, + onAbort, +}: { + id: string; + urlFiles: UrlFile[]; + onConfirm: () => void; + onAbort: () => void; +}) { + useInput((input, key) => { + if (input === 'y' || input === 'Y' || key.return) onConfirm(); + else if (input === 'n' || input === 'N' || key.escape) onAbort(); + }); + return ( + + + + {' '}feather package add + + {` Step ${URL_TOTAL} of ${URL_TOTAL}`} + + {' '}Review before installing + + + {' '}Package: {id} + + + {' '}Trust: experimental ⚠ + + {' '}Not reviewed by the Feather team. + + + {urlFiles.map((file) => ( + + + {' '} + {file.name} → {file.target} + + + {' sha256: '} + {file.sha256.slice(0, 24)}… + + + ))} + + + {' y/Enter = install · n/Esc = abort'} + + + ); +} + +export function InstallStep({ + id, + repoName, + tag, + baseUrl, + selectedFiles, + targetMap, + projectDir, + lockfile, + onDone, + onError, +}: { + id: string; + repoName: string; + tag: string; + baseUrl: string; + selectedFiles: string[]; + targetMap: Record; + projectDir: string; + lockfile: Lockfile; + onDone: () => void; + onError: (msg: string) => void; +}) { + const [current, setCurrent] = useState(''); + useEffect(() => { + const run = async () => { + const result = await installCustomRepoPackage({ + id, + repoName, + tag, + baseUrl, + selectedFiles, + targetMap, + projectDir, + lockfile, + onFileStart: setCurrent, + }); + if (!result.ok) throw new Error(result.error ?? 'Install failed'); + onDone(); + }; + run().catch((err: Error) => onError(err.message)); + }, []); + return ( + + {current ? `Installing ${current}…` : 'Installing…'} + + ); +} + +export function WriteStep({ + id, + urlFiles, + projectDir, + lockfile, + onDone, + onError, +}: { + id: string; + urlFiles: UrlFile[]; + projectDir: string; + lockfile: Lockfile; + onDone: () => void; + onError: (msg: string) => void; +}) { + useEffect(() => { + const run = async () => { + const result = await installCustomUrlPackage({ + id, + urlFiles, + projectDir, + lockfile, + }); + if (!result.ok) throw new Error(result.error ?? 'Install failed'); + onDone(); + }; + run().catch((err: Error) => onError(err.message)); + }, []); + return ( + + Installing… + + ); +} + +export function DoneStep({ + id, + files, + requirePath, + onExit, +}: { + id: string; + files: Array<{ name: string; target: string }>; + requirePath: string; + onExit: () => void; +}) { + useInput((_, key) => { + if (key.return || key.escape) onExit(); + }); + return ( + + + ✔ Installed + + {files.map((file) => ( + + {' '} + {file.name} → {file.target} + + ))} + + + Usage:{' '} + + local {id.replace(/[.-]/g, '_')} = require('{requirePath}') + + + + + Trust: experimental ⚠ — not reviewed by the Feather team + + + Press Enter to exit + + + ); +} diff --git a/cli/src/ui/package-add.tsx b/cli/src/ui/package-add.tsx index 38143076..e7d028af 100644 --- a/cli/src/ui/package-add.tsx +++ b/cli/src/ui/package-add.tsx @@ -1,7 +1,6 @@ -import { render, Text, Box, useInput, useApp } from 'ink'; -import { useState, useEffect } from 'react'; +import { render, Text, Box, useApp } from 'ink'; +import { useState } from 'react'; import type { Lockfile } from '../lib/package/lockfile.js'; -import { installCustomRepoPackage, installCustomUrlPackage } from '../lib/package/custom-add.js'; import { type UrlFile, TextInputStep, @@ -12,8 +11,10 @@ import { FileFetchStep, FileMoreStep, } from './components.js'; -import { GH_HEADERS, fetchCommitSha, fetchLuaFiles } from '../lib/github.js'; +import { fetchCommitSha, fetchLuaFiles } from '../lib/github.js'; import { fileNameFromUrl } from '../lib/url.js'; +import { fetchRepoMeta, REPO_TOTAL, URL_TOTAL } from './package-add-helpers.js'; +import { DoneStep, InstallStep, RepoConfirmStep, UrlConfirmStep, WriteStep } from './package-add-steps.js'; type Step = | 'choose' @@ -36,274 +37,6 @@ type Step = | 'done' | 'error'; -const REPO_TOTAL = 7; -const URL_TOTAL = 4; - -async function fetchRepoMeta(repo: string): Promise<{ values: string[]; labels: string[] }> { - const [tagsRes, repoRes, branchesRes] = await Promise.all([ - fetch(`https://api.github.com/repos/${repo}/tags?per_page=20`, { headers: GH_HEADERS }), - fetch(`https://api.github.com/repos/${repo}`, { headers: GH_HEADERS }), - fetch(`https://api.github.com/repos/${repo}/branches?per_page=30`, { headers: GH_HEADERS }), - ]); - if (!tagsRes.ok) throw new Error(`GitHub API ${tagsRes.status} fetching tags for ${repo}`); - if (!repoRes.ok) throw new Error(`GitHub API ${repoRes.status} fetching repo info for ${repo}`); - if (!branchesRes.ok) throw new Error(`GitHub API ${branchesRes.status} fetching branches for ${repo}`); - const [tagsData, repoData, branchesData] = await Promise.all([ - tagsRes.json() as Promise>, - repoRes.json() as Promise<{ default_branch?: string }>, - branchesRes.json() as Promise>, - ]); - const tags = tagsData.map((t) => t.name); - const defaultBranch = repoData.default_branch ?? 'main'; - const branches = branchesData.map((b) => b.name); - const orderedBranches = [defaultBranch, ...branches.filter((b) => b !== defaultBranch)]; - const values = [...tags, ...orderedBranches]; - const labels = [...tags, ...orderedBranches.map((b) => `⎇ ${b}`)]; - return { values, labels }; -} - - -function RepoConfirmStep({ - id, - repoName, - tag, - selectedFiles, - targetMap, - onConfirm, - onAbort, -}: { - id: string; - repoName: string; - tag: string; - selectedFiles: string[]; - targetMap: Record; - onConfirm: () => void; - onAbort: () => void; -}) { - useInput((input, key) => { - if (input === 'y' || input === 'Y' || key.return) onConfirm(); - else if (input === 'n' || input === 'N' || key.escape) onAbort(); - }); - return ( - - - - {' '}feather package add - - {` Step ${REPO_TOTAL} of ${REPO_TOTAL}`} - - {' '}Review before installing - - - {' '}Package: {id} - - - {' '}Source: github.com/{repoName} - - - {' '}Version: {tag} (commit SHA pinned) - - - {' '}Trust: experimental ⚠ - - {' '}Not reviewed by the Feather team. - - - {selectedFiles.map((f) => ( - - {' '} - {f} → {targetMap[f]} - - ))} - - - {' y/Enter = install · n/Esc = abort'} - - - ); -} - -function UrlConfirmStep({ - id, - urlFiles, - onConfirm, - onAbort, -}: { - id: string; - urlFiles: UrlFile[]; - onConfirm: () => void; - onAbort: () => void; -}) { - useInput((input, key) => { - if (input === 'y' || input === 'Y' || key.return) onConfirm(); - else if (input === 'n' || input === 'N' || key.escape) onAbort(); - }); - return ( - - - - {' '}feather package add - - {` Step ${URL_TOTAL} of ${URL_TOTAL}`} - - {' '}Review before installing - - - {' '}Package: {id} - - - {' '}Trust: experimental ⚠ - - {' '}Not reviewed by the Feather team. - - - {urlFiles.map((f) => ( - - - {' '} - {f.name} → {f.target} - - - {' sha256: '} - {f.sha256.slice(0, 24)}… - - - ))} - - - {' y/Enter = install · n/Esc = abort'} - - - ); -} - -function InstallStep({ - id, - repoName, - tag, - baseUrl, - selectedFiles, - targetMap, - projectDir, - lockfile, - onDone, - onError, -}: { - id: string; - repoName: string; - tag: string; - baseUrl: string; - selectedFiles: string[]; - targetMap: Record; - projectDir: string; - lockfile: Lockfile; - onDone: () => void; - onError: (msg: string) => void; -}) { - const [current, setCurrent] = useState(''); - useEffect(() => { - const run = async () => { - const result = await installCustomRepoPackage({ - id, - repoName, - tag, - baseUrl, - selectedFiles, - targetMap, - projectDir, - lockfile, - onFileStart: setCurrent, - }); - if (!result.ok) throw new Error(result.error ?? 'Install failed'); - onDone(); - }; - run().catch((err: Error) => onError(err.message)); - }, []); - return ( - - {current ? `Installing ${current}…` : 'Installing…'} - - ); -} - -function WriteStep({ - id, - urlFiles, - projectDir, - lockfile, - onDone, - onError, -}: { - id: string; - urlFiles: UrlFile[]; - projectDir: string; - lockfile: Lockfile; - onDone: () => void; - onError: (msg: string) => void; -}) { - useEffect(() => { - const run = async () => { - const result = await installCustomUrlPackage({ - id, - urlFiles, - projectDir, - lockfile, - }); - if (!result.ok) throw new Error(result.error ?? 'Install failed'); - onDone(); - }; - run().catch((err: Error) => onError(err.message)); - }, []); - return ( - - Installing… - - ); -} - -function DoneStep({ - id, - files, - requirePath, - onExit, -}: { - id: string; - files: Array<{ name: string; target: string }>; - requirePath: string; - onExit: () => void; -}) { - useInput((_, key) => { - if (key.return || key.escape) onExit(); - }); - return ( - - - ✔ Installed - - {files.map((f) => ( - - {' '} - {f.name} → {f.target} - - ))} - - - Usage:{' '} - - local {id.replace(/[.-]/g, '_')} = require('{requirePath}') - - - - - Trust: experimental ⚠ — not reviewed by the Feather team - - - Press Enter to exit - - - ); -} - function Wizard({ projectDir, lockfile }: { projectDir: string; lockfile: Lockfile }) { const { exit } = useApp(); const [step, setStep] = useState('choose'); diff --git a/cli/test/package.test.mjs b/cli/test/package.test.mjs index 1282421c..0a7179c3 100644 --- a/cli/test/package.test.mjs +++ b/cli/test/package.test.mjs @@ -17,6 +17,8 @@ import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; const CLI = fileURLToPath(new URL('../dist/index.js', import.meta.url)); +const ROOT = fileURLToPath(new URL('../..', import.meta.url)); +const LOCAL_SRC = join(ROOT, 'src-lua'); const sha256 = (s) => createHash('sha256').update(s).digest('hex'); /** Run the CLI and return { stdout, stderr, exitCode }. Never throws. */ @@ -37,6 +39,10 @@ function makeTmp() { return mkdtempSync(join(tmpdir(), 'feather-e2e-')); } +function outputOf(result) { + return `${result.stdout}\n${result.stderr}`; +} + /** * Write a minimal feather.lock.json to dir. * @param {string} dir @@ -46,6 +52,18 @@ function writeLock(dir, packages) { writeFileSync(join(dir, 'feather.lock.json'), JSON.stringify({ lockfileVersion: 1, packages }, null, 2)); } +function writeGame(dir) { + writeFileSync( + join(dir, 'main.lua'), + `function love.update(dt) +end + +function love.draw() +end +`, + ); +} + test('search: no query lists all registry packages', () => { const { stdout, exitCode } = run(['package', 'search', '--offline']); assert.equal(exitCode, 0); @@ -108,9 +126,9 @@ test('info: shows package details for known package', () => { test('info: unknown package exits 1 with message', () => { const dir = makeTmp(); - const { stdout, exitCode } = run(['package', 'info', 'zzz_no_such_pkg', '--offline', '--dir', dir]); - assert.equal(exitCode, 1); - assert.ok(stdout.includes('not found')); + const result = run(['package', 'info', 'zzz_no_such_pkg', '--offline', '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('not found')); }); test('install: no names + no lockfile prints empty hint and exits 0', () => { @@ -179,9 +197,9 @@ test('install: already-installed package is skipped', () => { test('install: version override without --allow-untrusted exits 1', () => { const dir = makeTmp(); - const { stdout, exitCode } = run(['package', 'install', 'anim8@v2.2.0', '--offline', '--dir', dir]); - assert.equal(exitCode, 1); - assert.ok(stdout.includes('allow-untrusted')); + const result = run(['package', 'install', 'anim8@v2.2.0', '--offline', '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('allow-untrusted')); }); test('install --from-url: missing --allow-untrusted exits 1 with warning', () => { @@ -227,7 +245,7 @@ test('install --from-url: --yes alone does not bypass untrusted source', () => { test('install --from-url: missing --target exits 1', () => { const dir = makeTmp(); - const { stdout, exitCode } = run([ + const result = run([ 'package', 'install', '--from-url', @@ -236,8 +254,8 @@ test('install --from-url: missing --target exits 1', () => { '--dir', dir, ]); - assert.equal(exitCode, 1); - assert.ok(stdout.includes('--target')); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('--target')); }); test('audit: no packages installed prints hint', () => { @@ -321,9 +339,9 @@ test('audit --json: outputs valid JSON', () => { test('remove: not installed exits 1 with message', () => { const dir = makeTmp(); - const { stdout, exitCode } = run(['package', 'remove', 'anim8', '--dir', dir]); - assert.equal(exitCode, 1); - assert.ok(stdout.includes('not installed')); + const result = run(['package', 'remove', 'anim8', '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('not installed')); }); test('remove: installed package requires --yes in non-interactive mode', () => { @@ -338,9 +356,9 @@ test('remove: installed package requires --yes in non-interactive mode', () => { files: [{ name: 'anim8.lua', target: 'lib/anim8.lua', sha256: 'any' }], }, }); - const { stdout, exitCode } = run(['package', 'remove', 'anim8', '--dir', dir]); - assert.equal(exitCode, 1); - assert.ok(stdout.includes('--yes')); + const result = run(['package', 'remove', 'anim8', '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('--yes')); assert.ok(existsSync(join(dir, 'lib', 'anim8.lua')), 'file should remain'); }); @@ -406,9 +424,9 @@ test('update: specific name not in lockfile exits 1', () => { files: [], }, }); - const { stdout, exitCode } = run(['package', 'update', 'anim8', '--offline', '--dir', dir]); - assert.equal(exitCode, 1); - assert.ok(stdout.includes('not installed')); + const result = run(['package', 'update', 'anim8', '--offline', '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('not installed')); }); test('update: already up-to-date package is reported', () => { @@ -517,7 +535,8 @@ test('audit: multi-file url package all verified', () => { ]); const { stdout, exitCode } = run(['package', 'audit', '--dir', dir]); assert.equal(exitCode, 0, `unexpected failure:\n${stdout}`); - assert.equal((stdout.match(/✔/g) ?? []).length, 2, 'both files should be verified'); + assert.match(stdout, /lib\/mypkg\/a\.lua\s+verified/); + assert.match(stdout, /lib\/mypkg\/b\.lua\s+verified/); }); test('install (no args): url package already on disk with correct hash is skipped', () => { @@ -658,6 +677,143 @@ test('audit --json: url package included in output', () => { assert.equal(entry.status, 'verified'); }); +test('command errors: central handler writes compact stderr and exit code', () => { + const dir = makeTmp(); + const result = run(['package', 'info', 'zzz_no_such_pkg', '--offline', '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(result.stderr.includes('not found')); + assert.equal(result.stderr.includes('Error:'), false); +}); + +test('registry validation rejects targets outside the project', async () => { + const { validateRegistry } = await import('../dist/lib/package/registry.js'); + assert.throws( + () => + validateRegistry({ + version: 1, + updatedAt: '2026-05-16', + packages: { + escape: { + type: 'love2d-library', + trust: 'verified', + description: 'bad target', + tags: [], + source: { + repo: 'owner/repo', + tag: 'main', + baseUrl: 'https://raw.githubusercontent.com/owner/repo/0123456789abcdef0123456789abcdef01234567/', + commitSha: '0123456789abcdef0123456789abcdef01234567', + }, + install: { + files: [{ name: 'escape.lua', target: '../escape.lua', sha256: 'a'.repeat(64) }], + }, + require: 'escape', + }, + }, + }), + /escapes project root/, + ); +}); + +test('init: --yes --mode auto patches main.lua with guarded markers', () => { + const dir = makeTmp(); + writeGame(dir); + const { exitCode } = run([ + 'init', + dir, + '--mode', + 'auto', + '--local-src', + LOCAL_SRC, + '--install-dir', + 'feather', + '--no-plugins', + '--yes', + ]); + assert.equal(exitCode, 0); + const main = readFileSync(join(dir, 'main.lua'), 'utf8'); + assert.ok(main.includes('FEATHER-INIT-BEGIN require')); + assert.ok(main.includes('USE_DEBUGGER')); +}); + +test('remove: non-interactive destructive remove requires --yes', () => { + const dir = makeTmp(); + writeGame(dir); + run([ + 'init', + dir, + '--mode', + 'auto', + '--local-src', + LOCAL_SRC, + '--install-dir', + 'feather', + '--no-plugins', + '--yes', + ]); + const result = run(['remove', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('--yes')); + assert.ok(existsSync(join(dir, 'feather.config.lua'))); +}); + +test('remove: dry-run does not delete files or edit main.lua', () => { + const dir = makeTmp(); + writeGame(dir); + run([ + 'init', + dir, + '--mode', + 'auto', + '--local-src', + LOCAL_SRC, + '--install-dir', + 'feather', + '--no-plugins', + '--yes', + ]); + const beforeMain = readFileSync(join(dir, 'main.lua'), 'utf8'); + const { exitCode } = run(['remove', dir, '--dry-run']); + assert.equal(exitCode, 0); + assert.equal(readFileSync(join(dir, 'main.lua'), 'utf8'), beforeMain); + assert.ok(existsSync(join(dir, 'feather'))); + assert.ok(existsSync(join(dir, 'feather.config.lua'))); +}); + +test('doctor --json reports package audit problems and unsafe config flags', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + `return { + include = { "console" }, + apiKey = "dev", + __DANGEROUS_INSECURE_CONNECTION__ = true, + debugger = true, + captureScreenshot = true, + writeToDisk = true, +} +`, + ); + writeLock(dir, { + helper: { + version: 'url', + trust: 'experimental', + source: { url: 'https://example.com/helper.lua' }, + files: [{ name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: sha256('expected') }], + }, + }); + const result = run(['doctor', dir, '--json']); + const parsed = JSON.parse(result.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('Package file integrity').severity, 'warn'); + assert.equal(labels.get('__DANGEROUS_INSECURE_CONNECTION__').severity, 'warn'); + assert.equal(labels.get('Console API key').severity, 'warn'); + assert.equal(labels.get('Step debugger').severity, 'warn'); + assert.equal(labels.get('captureScreenshot').severity, 'warn'); + assert.equal(labels.get('Disk logging').severity, 'warn'); +}); + async function withFetchMock(mock, runTest) { const originalFetch = globalThis.fetch; globalThis.fetch = mock; From de027d37446edde6da90ad50c1adb77a5ebb7804 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 00:48:01 -0400 Subject: [PATCH 27/68] cli: Deeper UI split --- cli/src/commands/package/add.ts | 36 +- cli/src/lib/package/add-plan.ts | 94 +++++ cli/src/ui/init-mode-config.ts | 93 +++++ cli/src/ui/init-mode-model.ts | 192 ++++++++++ cli/src/ui/init-mode-prompts.tsx | 188 ++++++++++ cli/src/ui/init-mode-summary.tsx | 83 +++++ cli/src/ui/init-mode-workflow.tsx | 557 ++++-------------------------- cli/src/ui/init-mode.tsx | 2 +- cli/src/ui/package-add-steps.tsx | 87 ----- cli/src/ui/package-add.tsx | 88 ++--- cli/test/package.test.mjs | 152 ++++++++ 11 files changed, 946 insertions(+), 626 deletions(-) create mode 100644 cli/src/lib/package/add-plan.ts create mode 100644 cli/src/ui/init-mode-config.ts create mode 100644 cli/src/ui/init-mode-model.ts create mode 100644 cli/src/ui/init-mode-prompts.tsx create mode 100644 cli/src/ui/init-mode-summary.tsx diff --git a/cli/src/commands/package/add.ts b/cli/src/commands/package/add.ts index 19cda6a7..05179829 100644 --- a/cli/src/commands/package/add.ts +++ b/cli/src/commands/package/add.ts @@ -1,4 +1,7 @@ +import { fail } from '../../lib/command.js'; +import { installPackageAddPlan, packageAddPlanFiles } from '../../lib/package/add-plan.js'; import { readLockfile } from '../../lib/package/lockfile.js'; +import { createSpinner, keyValueRows, style } from '../../lib/output.js'; import { showAddWizard } from '../../ui/package-add.js'; import { ensurePackageAddInteractive, resolvePackageProjectDir } from './shared.js'; @@ -11,6 +14,35 @@ export async function packageAddCommand(opts: PackageAddOptions = {}): Promise { + spinner.text = `Installing ${name}…`; + }, + }); + if (!result.ok) { + spinner.fail(result.error ?? 'Install failed'); + fail(result.error ?? 'Install failed', { silent: true }); + } + + spinner.succeed(`Installed ${plan.id}`); + console.log(); + for (const row of keyValueRows([ + ['Package', plan.id], + ['Files', packageAddPlanFiles(plan).map((file) => `${file.name} -> ${file.target}`).join(', ')], + ['Usage', `local ${plan.id.replace(/[.-]/g, '_')} = require('${plan.requirePath}')`], + ['Trust', style.warning('experimental; not reviewed by the Feather team')], + ])) { + console.log(row); + } +} diff --git a/cli/src/lib/package/add-plan.ts b/cli/src/lib/package/add-plan.ts new file mode 100644 index 00000000..19dc48bf --- /dev/null +++ b/cli/src/lib/package/add-plan.ts @@ -0,0 +1,94 @@ +import { + installCustomRepoPackage, + installCustomUrlPackage, + type CustomPackageInstallResult, + type CustomRepoPackageInput, + type CustomUrlFileInput, + type CustomUrlPackageInput, +} from './custom-add.js'; +import type { Lockfile } from './lockfile.js'; + +export type PackageAddRepoPlan = { + kind: 'repo'; + id: string; + requirePath: string; + repoName: string; + tag: string; + baseUrl: string; + selectedFiles: string[]; + targetMap: Record; +}; + +export type PackageAddUrlPlan = { + kind: 'url'; + id: string; + requirePath: string; + urlFiles: CustomUrlFileInput[]; +}; + +export type PackageAddPlan = PackageAddRepoPlan | PackageAddUrlPlan; + +export function packageAddPlanFiles(plan: PackageAddPlan): Array<{ name: string; target: string }> { + if (plan.kind === 'repo') { + return plan.selectedFiles.map((name) => ({ name, target: plan.targetMap[name] ?? name })); + } + return plan.urlFiles.map((file) => ({ name: file.name, target: file.target })); +} + +export function toCustomRepoPackageInput(input: { + plan: PackageAddRepoPlan; + projectDir: string; + lockfile: Lockfile; + onFileStart?: (name: string) => void; +}): CustomRepoPackageInput { + return { + id: input.plan.id, + repoName: input.plan.repoName, + tag: input.plan.tag, + baseUrl: input.plan.baseUrl, + selectedFiles: input.plan.selectedFiles, + targetMap: input.plan.targetMap, + projectDir: input.projectDir, + lockfile: input.lockfile, + onFileStart: input.onFileStart, + }; +} + +export function toCustomUrlPackageInput(input: { + plan: PackageAddUrlPlan; + projectDir: string; + lockfile: Lockfile; +}): CustomUrlPackageInput { + return { + id: input.plan.id, + urlFiles: input.plan.urlFiles, + projectDir: input.projectDir, + lockfile: input.lockfile, + }; +} + +export async function installPackageAddPlan(input: { + plan: PackageAddPlan; + projectDir: string; + lockfile: Lockfile; + onFileStart?: (name: string) => void; +}): Promise { + if (input.plan.kind === 'repo') { + return installCustomRepoPackage( + toCustomRepoPackageInput({ + plan: input.plan, + projectDir: input.projectDir, + lockfile: input.lockfile, + onFileStart: input.onFileStart, + }), + ); + } + + return installCustomUrlPackage( + toCustomUrlPackageInput({ + plan: input.plan, + projectDir: input.projectDir, + lockfile: input.lockfile, + }), + ); +} diff --git a/cli/src/ui/init-mode-config.ts b/cli/src/ui/init-mode-config.ts new file mode 100644 index 00000000..be19b0a6 --- /dev/null +++ b/cli/src/ui/init-mode-config.ts @@ -0,0 +1,93 @@ +import { + dangerousInsecureConnection, + numericValue, + parseCapabilities, + type InitMode, + type InitSetup, +} from "./init-mode-model.js"; + +export type InitSetupState = { + mode: InitMode; + installSource: "local" | "remote"; + branch: string; + installDir: string; + installPlugins: boolean; + pluginPromptsEnabled: boolean; + include: Set; + exclude: Set; + advanced: boolean; + sessionName: string; + host: string; + port: string; + socketModeIndex: number; + baseDir: string; + sampleRate: string; + updateInterval: string; + maxTempLogs: string; + outputDir: string; + retryInterval: string; + connectTimeout: string; + errorWait: string; + binaryTextThreshold: string; + deviceId: string; + capabilities: string; + toggles: Set; + needsApiKey: boolean; + apiKey: string; + appIdInput: string; +}; + +export function buildInitSetup(input: InitSetupState): InitSetup { + const config: Record = {}; + if (input.sessionName.trim()) config.sessionName = input.sessionName.trim(); + if (input.pluginPromptsEnabled && input.include.size > 0) config.include = [...input.include]; + if (input.pluginPromptsEnabled && input.exclude.size > 0) config.exclude = [...input.exclude]; + + if (input.advanced) { + config.debug = input.toggles.has("debug"); + config.host = input.host.trim() || "127.0.0.1"; + config.port = numericValue(input.port, 4004); + config.mode = input.socketModeIndex === 0 ? "socket" : "disk"; + if (input.baseDir.trim()) config.baseDir = input.baseDir.trim(); + config.wrapPrint = input.toggles.has("wrapPrint"); + config.maxTempLogs = numericValue(input.maxTempLogs, 200); + config.sampleRate = numericValue(input.sampleRate, 1); + config.updateInterval = numericValue(input.updateInterval, 0.1); + config.defaultObservers = input.toggles.has("defaultObservers"); + config.errorWait = numericValue(input.errorWait, 3); + config.autoRegisterErrorHandler = input.toggles.has("autoRegisterErrorHandler"); + config.captureScreenshot = input.toggles.has("captureScreenshot"); + config.writeToDisk = input.toggles.has("writeToDisk"); + config.outputDir = input.outputDir.trim() || "logs"; + config.capabilities = parseCapabilities(input.capabilities); + config.retryInterval = numericValue(input.retryInterval, 5); + config.connectTimeout = numericValue(input.connectTimeout, 2); + config.debugger = input.toggles.has("debugger"); + config.assetPreview = input.toggles.has("assetPreview"); + config.binaryTextThreshold = numericValue(input.binaryTextThreshold, 4096); + if (input.deviceId.trim()) config.deviceId = input.deviceId.trim(); + } + + if (input.needsApiKey) { + config.apiKey = input.apiKey; + config.pluginOptions = { + console: { evalEnabled: true }, + }; + } + + if (input.appIdInput.trim()) { + config.appId = input.appIdInput.trim(); + } else { + config[dangerousInsecureConnection] = true; + } + + return { + mode: input.mode, + source: input.installSource, + branch: input.branch.trim() || "main", + installDir: input.installDir.trim() || "feather", + installPlugins: input.installPlugins, + config, + exclude: input.pluginPromptsEnabled ? [...input.exclude] : [], + }; +} diff --git a/cli/src/ui/init-mode-model.ts b/cli/src/ui/init-mode-model.ts new file mode 100644 index 00000000..acea084a --- /dev/null +++ b/cli/src/ui/init-mode-model.ts @@ -0,0 +1,192 @@ +import type { ReactNode } from "react"; +import { pluginCatalog } from "../generated/plugin-catalog.js"; + +export type InitMode = "cli" | "auto" | "manual"; + +export type InitSetup = { + mode: InitMode; + source: "local" | "remote"; + branch: string; + installDir: string; + installPlugins: boolean; + config: Record; + exclude: string[]; +}; + +export type Phase = + | "mode" + | "installDir" + | "source" + | "branch" + | "sessionName" + | "installPlugins" + | "include" + | "exclude" + | "advanced" + | "host" + | "port" + | "modeConfig" + | "baseDir" + | "sampleRate" + | "updateInterval" + | "maxTempLogs" + | "outputDir" + | "retryInterval" + | "connectTimeout" + | "errorWait" + | "binaryTextThreshold" + | "deviceId" + | "capabilities" + | "toggles" + | "apiKey" + | "appId" + | "summary"; + +export type Option = { + value: T; + label: string; + description?: string; +}; + +export type Tone = "info" | "success" | "warning" | "danger"; + +export type SummaryRow = { + id: string; + label: string; + value: ReactNode; + tone?: Tone; +}; + +export const dangerousInsecureConnection = "__DANGEROUS_INSECURE_CONNECTION__"; +export const dangerousPluginIds = new Set(["console", "hot-reload"]); +export const dangerousToggleIds = new Set(["debugger", "captureScreenshot"]); +export const warningToggleIds = new Set(["autoRegisterErrorHandler", "writeToDisk"]); + +export const toneColor = (tone?: Tone) => { + if (tone === "danger") return "red"; + if (tone === "warning") return "yellow"; + if (tone === "success") return "green"; + if (tone === "info") return "cyan"; + return undefined; +}; + +export const pluginTone = (value: string): Tone | undefined => (dangerousPluginIds.has(value) ? "danger" : undefined); +export const toggleTone = (value: string): Tone | undefined => { + if (dangerousToggleIds.has(value)) return "danger"; + if (warningToggleIds.has(value)) return "warning"; + return undefined; +}; + +export const modes: Option[] = [ + { + value: "cli", + label: "CLI injection", + description: "Create feather.config.lua only. Run with `feather run .`.", + }, + { + value: "auto", + label: "Auto require", + description: 'Patch main.lua with a USE_DEBUGGER-guarded require("feather.auto").', + }, + { + value: "manual", + label: "Manual setup", + description: "Create feather.debugger.lua and load it when USE_DEBUGGER is set.", + }, +]; + +export const installSources: Option<"local" | "remote">[] = [ + { + value: "local", + label: "Bundled/local copy", + description: "Copy the CLI-bundled Lua runtime, or src-lua when running from the repo.", + }, + { + value: "remote", + label: "GitHub download", + description: "Fetch files from GitHub using the selected branch or tag.", + }, +]; + +const pluginToOption = (plugin: (typeof pluginCatalog)[number]): Option => ({ + value: plugin.id, + label: plugin.name, + description: plugin.description, +}); + +export const optionalPlugins: Option[] = pluginCatalog.filter((plugin) => plugin.optIn).map(pluginToOption); +export const skipPluginOptions: Option[] = pluginCatalog.map(pluginToOption); +export const defaultSkippedPlugins = new Set(["console", "hot-reload", "hump.signal", "lua-state-machine"]); + +export const configToggles: Option[] = [ + { value: "debug", label: "Enable Feather", description: "Set debug = true." }, + { value: "wrapPrint", label: "Wrap print()", description: "Send print output to Logs." }, + { value: "defaultObservers", label: "Default observers", description: "Capture built-in runtime observers." }, + { + value: "autoRegisterErrorHandler", + label: "Error handler", + description: "Capture Lua errors before LÖVE shows its handler.", + }, + { value: "writeToDisk", label: "Write logs to disk", description: "Persist .featherlog files." }, + { value: "debugger", label: "Step debugger", description: "Enable debugger commands by default." }, + { value: "captureScreenshot", label: "Error screenshots", description: "Capture screenshots on errors." }, + { value: "assetPreview", label: "Asset previews", description: "Track loaded assets for the Assets tab." }, +]; + +export const numberPhases = new Set([ + "port", + "sampleRate", + "updateInterval", + "maxTempLogs", + "retryInterval", + "connectTimeout", + "errorWait", + "binaryTextThreshold", +]); + +export const textPromptTitles: Partial> = { + installDir: "Install directory", + branch: "GitHub branch or tag", + sessionName: "Session name shown in Feather", + host: "Desktop host", + port: "Desktop WebSocket port", + baseDir: "Base directory for file links", + sampleRate: "Sample rate in seconds", + updateInterval: "Update interval in seconds", + maxTempLogs: "Max temporary logs", + outputDir: "Output directory for logs", + retryInterval: "Reconnect interval in seconds", + connectTimeout: "Connect timeout in seconds", + errorWait: "Error delivery wait in seconds", + binaryTextThreshold: "Binary text threshold in bytes", + deviceId: "Device ID override", + capabilities: 'Capabilities ("all" or comma-separated)', +}; + +export const isStrongApiKey = (value: string) => { + if (value.length < 16) return false; + if (/^(password|secret|changeme|dev|test|console|apikey)$/i.test(value)) return false; + + const classes = [ + /[a-z]/.test(value), + /[A-Z]/.test(value), + /\d/.test(value), + /[^A-Za-z0-9]/.test(value), + ].filter(Boolean).length; + + return classes >= 3 || value.length >= 24; +}; + +export const numericValue = (value: string, fallback: number) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +}; + +export const parseCapabilities = (value: string): string[] | "all" => { + const trimmed = value.trim(); + if (!trimmed || trimmed.toLowerCase() === "all") return "all"; + return trimmed + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +}; diff --git a/cli/src/ui/init-mode-prompts.tsx b/cli/src/ui/init-mode-prompts.tsx new file mode 100644 index 00000000..1dc40363 --- /dev/null +++ b/cli/src/ui/init-mode-prompts.tsx @@ -0,0 +1,188 @@ +import React, { type ReactNode } from "react"; +import { Box, Text } from "ink"; +import { toneColor, type Option, type SummaryRow, type Tone } from "./init-mode-model.js"; + +export function DangerousName({ children }: { children: string }) { + return ( + + {children} + + ); +} + +export function NameList({ + values, + getTone, +}: { + values: string[]; + getTone?: (value: string) => Tone | undefined; +}) { + if (values.length === 0) return (none); + + return ( + <> + {values.map((value, index) => ( + + {index > 0 ? , : null} + {value} + + ))} + + ); +} + +function SummaryValue({ value, tone }: { value: ReactNode; tone?: Tone }) { + if (typeof value === "string") return {value}; + return <>{value}; +} + +export function SummaryRows({ rows }: { rows: SummaryRow[] }) { + return ( + + {rows.map((row) => ( + + {row.label.padEnd(17)} + + + ))} + + ); +} + +export function InfoPanel({ + title, + tone = "info", + children, +}: { + title: string; + tone?: Tone; + children: ReactNode; +}) { + return ( + + + {title} + + + {children} + + + ); +} + +function cursorLine(active: boolean, text: string, description?: string, tone?: Tone) { + return ( + + + {active ? "❯" : " "}{" "} + {text} + + {description ? {description} : null} + + ); +} + +export function TextInputPrompt({ + title, + value, + placeholder, + secure, + error, +}: { + title: string; + value: string; + placeholder?: string; + secure?: boolean; + error?: string; +}) { + const shown = secure && value ? "•".repeat(value.length) : value; + return ( + + {title} + + {shown || placeholder || " "} + + {error ? {error} : ←→ move · Backspace delete · Enter confirm} + + ); +} + +export function SingleSelect({ + title, + options, + selected, + getTone, +}: { + title: string; + options: Option[]; + selected: number; + getTone?: (option: Option) => Tone | undefined; +}) { + return ( + + {title} + + {options.map((option, index) => ( + + {cursorLine(index === selected, `${index + 1}. ${option.label}`, option.description, getTone?.(option))} + + ))} + + ↑↓ or j/k navigate · 1-{options.length} jump · Enter select + + ); +} + +export function MultiSelect({ + title, + options, + selected, + cursor, + getTone, + hint = "↑↓ or j/k navigate · Space toggle · a select all · Enter confirm", +}: { + title: string; + options: Option[]; + selected: Set; + cursor: number; + getTone?: (option: Option) => Tone | undefined; + hint?: string; +}) { + return ( + + {title} + + {options.length === 0 ? No options available. : null} + {options.map((option, index) => { + const tone = getTone?.(option); + return ( + + + + {index === cursor ? "❯" : " "} {selected.has(option.value) ? "◉" : "○"} + {" "} + {option.label} + + {option.description ? {option.description} : null} + + ); + })} + + {hint} + + ); +} + +export function ConfirmPrompt({ title, value }: { title: string; value: boolean }) { + return ( + + {title} + + Yes + / + No + + y/← = yes · n/→ = no · Enter confirm + + ); +} diff --git a/cli/src/ui/init-mode-summary.tsx b/cli/src/ui/init-mode-summary.tsx new file mode 100644 index 00000000..d9637f1d --- /dev/null +++ b/cli/src/ui/init-mode-summary.tsx @@ -0,0 +1,83 @@ +import { Text } from "ink"; +import { + dangerousInsecureConnection, + installSources, + modes, + pluginTone, + type SummaryRow, +} from "./init-mode-model.js"; +import { DangerousName, NameList } from "./init-mode-prompts.js"; + +export type InitSummaryInput = { + advanced: boolean; + apiKeyCopied: boolean | null; + appIdInput: string; + branch: string; + exclude: Set; + include: Set; + installDir: string; + installPlugins: boolean; + installsFiles: boolean; + installSource: "local" | "remote"; + modeIndex: number; + needsApiKey: boolean; + pluginPromptsEnabled: boolean; + sessionName: string; + sourceIndex: number; +}; + +export function buildInitSummaryRows(input: InitSummaryInput): SummaryRow[] { + const includeList = [...input.include]; + const excludeList = [...input.exclude]; + const rows: SummaryRow[] = [ + { id: "mode", label: "Mode", value: modes[input.modeIndex].label }, + { + id: "install-dir", + label: "Install dir", + value: input.installsFiles ? `${input.installDir || "feather"}/` : "bundled CLI runtime", + }, + input.installsFiles ? { id: "source", label: "Source", value: installSources[input.sourceIndex].label } : undefined, + input.installsFiles && input.installSource === "remote" ? { id: "branch", label: "Branch", value: input.branch || "main" } : undefined, + input.installsFiles ? { id: "plugins", label: "Install plugins", value: input.installPlugins ? "yes" : "no" } : undefined, + { id: "session", label: "Session", value: input.sessionName || "(default)" }, + input.pluginPromptsEnabled + ? { id: "include", label: "Include", value: } + : undefined, + input.pluginPromptsEnabled + ? { id: "exclude", label: "Exclude", value: } + : undefined, + { id: "advanced", label: "Advanced config", value: input.advanced ? "yes" : "no", tone: input.advanced ? "warning" : undefined }, + ].filter(Boolean) as SummaryRow[]; + + if (input.needsApiKey) { + rows.push({ + id: "api-key", + label: "Console API key", + value: + input.apiKeyCopied === true + ? "set and copied to clipboard" + : input.apiKeyCopied === false + ? "set (clipboard copy unavailable)" + : "set", + tone: "success", + }); + } + + rows.push( + input.appIdInput.trim() + ? { id: "app-id", label: "App ID", value: input.appIdInput.trim(), tone: "success" } + : { + id: "app-id", + label: "App ID", + value: ( + <> + not set → + {dangerousInsecureConnection} + = true + + ), + tone: "danger", + }, + ); + return rows; +} diff --git a/cli/src/ui/init-mode-workflow.tsx b/cli/src/ui/init-mode-workflow.tsx index 5bfeb05a..1f9d8996 100644 --- a/cli/src/ui/init-mode-workflow.tsx +++ b/cli/src/ui/init-mode-workflow.tsx @@ -1,382 +1,36 @@ -import React, { type ReactNode, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { Box, Text, render, useApp, useInput } from "ink"; import { copyToClipboard } from "../lib/clipboard.js"; -import { pluginCatalog } from "../generated/plugin-catalog.js"; - -export type InitMode = "cli" | "auto" | "manual"; - -export type InitSetup = { - mode: InitMode; - source: "local" | "remote"; - branch: string; - installDir: string; - installPlugins: boolean; - config: Record; - exclude: string[]; -}; - -type Phase = - | "mode" - | "installDir" - | "source" - | "branch" - | "sessionName" - | "installPlugins" - | "include" - | "exclude" - | "advanced" - | "host" - | "port" - | "modeConfig" - | "baseDir" - | "sampleRate" - | "updateInterval" - | "maxTempLogs" - | "outputDir" - | "retryInterval" - | "connectTimeout" - | "errorWait" - | "binaryTextThreshold" - | "deviceId" - | "capabilities" - | "toggles" - | "apiKey" - | "appId" - | "summary"; - -type Option = { - value: T; - label: string; - description?: string; -}; - -type Tone = "info" | "success" | "warning" | "danger"; - -type SummaryRow = { - id: string; - label: string; - value: ReactNode; - tone?: Tone; -}; - -const dangerousInsecureConnection = "__DANGEROUS_INSECURE_CONNECTION__"; -const dangerousPluginIds = new Set(["console", "hot-reload"]); -const dangerousToggleIds = new Set(["debugger", "captureScreenshot"]); -const warningToggleIds = new Set(["autoRegisterErrorHandler", "writeToDisk"]); - -const toneColor = (tone?: Tone) => { - if (tone === "danger") return "red"; - if (tone === "warning") return "yellow"; - if (tone === "success") return "green"; - if (tone === "info") return "cyan"; - return undefined; -}; - -const pluginTone = (value: string): Tone | undefined => (dangerousPluginIds.has(value) ? "danger" : undefined); -const toggleTone = (value: string): Tone | undefined => { - if (dangerousToggleIds.has(value)) return "danger"; - if (warningToggleIds.has(value)) return "warning"; - return undefined; -}; - -const modes: Option[] = [ - { - value: "cli", - label: "CLI injection", - description: "Create feather.config.lua only. Run with `feather run .`.", - }, - { - value: "auto", - label: "Auto require", - description: 'Patch main.lua with a USE_DEBUGGER-guarded require("feather.auto").', - }, - { - value: "manual", - label: "Manual setup", - description: "Create feather.debugger.lua and load it when USE_DEBUGGER is set.", - }, -]; - -const installSources: Option<"local" | "remote">[] = [ - { - value: "local", - label: "Bundled/local copy", - description: "Copy the CLI-bundled Lua runtime, or src-lua when running from the repo.", - }, - { - value: "remote", - label: "GitHub download", - description: "Fetch files from GitHub using the selected branch or tag.", - }, -]; - -const pluginToOption = (plugin: (typeof pluginCatalog)[number]): Option => ({ - value: plugin.id, - label: plugin.name, - description: plugin.description, -}); - -const optionalPlugins: Option[] = pluginCatalog.filter((plugin) => plugin.optIn).map(pluginToOption); -const skipPluginOptions: Option[] = pluginCatalog.map(pluginToOption); -const defaultSkippedPlugins = new Set(["console", "hot-reload", "hump.signal", "lua-state-machine"]); - -const configToggles: Option[] = [ - { value: "debug", label: "Enable Feather", description: "Set debug = true." }, - { value: "wrapPrint", label: "Wrap print()", description: "Send print output to Logs." }, - { value: "defaultObservers", label: "Default observers", description: "Capture built-in runtime observers." }, - { - value: "autoRegisterErrorHandler", - label: "Error handler", - description: "Capture Lua errors before LÖVE shows its handler.", - }, - { value: "writeToDisk", label: "Write logs to disk", description: "Persist .featherlog files." }, - { value: "debugger", label: "Step debugger", description: "Enable debugger commands by default." }, - { value: "captureScreenshot", label: "Error screenshots", description: "Capture screenshots on errors." }, - { value: "assetPreview", label: "Asset previews", description: "Track loaded assets for the Assets tab." }, -]; - -const numberPhases = new Set([ - "port", - "sampleRate", - "updateInterval", - "maxTempLogs", - "retryInterval", - "connectTimeout", - "errorWait", - "binaryTextThreshold", -]); - -const textPromptTitles: Partial> = { - installDir: "Install directory", - branch: "GitHub branch or tag", - sessionName: "Session name shown in Feather", - host: "Desktop host", - port: "Desktop WebSocket port", - baseDir: "Base directory for file links", - sampleRate: "Sample rate in seconds", - updateInterval: "Update interval in seconds", - maxTempLogs: "Max temporary logs", - outputDir: "Output directory for logs", - retryInterval: "Reconnect interval in seconds", - connectTimeout: "Connect timeout in seconds", - errorWait: "Error delivery wait in seconds", - binaryTextThreshold: "Binary text threshold in bytes", - deviceId: "Device ID override", - capabilities: 'Capabilities ("all" or comma-separated)', -}; - -const isStrongApiKey = (value: string) => { - if (value.length < 16) return false; - if (/^(password|secret|changeme|dev|test|console|apikey)$/i.test(value)) return false; - - const classes = [ - /[a-z]/.test(value), - /[A-Z]/.test(value), - /\d/.test(value), - /[^A-Za-z0-9]/.test(value), - ].filter(Boolean).length; - - return classes >= 3 || value.length >= 24; -}; - -const numericValue = (value: string, fallback: number) => { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : fallback; -}; - -const parseCapabilities = (value: string): string[] | "all" => { - const trimmed = value.trim(); - if (!trimmed || trimmed.toLowerCase() === "all") return "all"; - return trimmed - .split(",") - .map((item) => item.trim()) - .filter(Boolean); -}; - -function DangerousName({ children }: { children: string }) { - return ( - - {children} - - ); -} - -function NameList({ - values, - getTone, -}: { - values: string[]; - getTone?: (value: string) => Tone | undefined; -}) { - if (values.length === 0) return (none); - - return ( - <> - {values.map((value, index) => ( - - {index > 0 ? , : null} - {value} - - ))} - - ); -} - -function SummaryValue({ value, tone }: { value: ReactNode; tone?: Tone }) { - if (typeof value === "string") return {value}; - return <>{value}; -} - -function SummaryRows({ rows }: { rows: SummaryRow[] }) { - return ( - - {rows.map((row) => ( - - {row.label.padEnd(17)} - - - ))} - - ); -} - -function InfoPanel({ - title, - tone = "info", - children, -}: { - title: string; - tone?: Tone; - children: ReactNode; -}) { - return ( - - - {title} - - - {children} - - - ); -} - -function cursorLine(active: boolean, text: string, description?: string, tone?: Tone) { - return ( - - - {active ? "❯" : " "}{" "} - {text} - - {description ? {description} : null} - - ); -} - -function TextInputPrompt({ - title, - value, - placeholder, - secure, - error, -}: { - title: string; - value: string; - placeholder?: string; - secure?: boolean; - error?: string; -}) { - const shown = secure && value ? "•".repeat(value.length) : value; - return ( - - {title} - - {shown || placeholder || " "} - - {error ? {error} : ←→ move · Backspace delete · Enter confirm} - - ); -} - -function SingleSelect({ - title, - options, - selected, - getTone, -}: { - title: string; - options: Option[]; - selected: number; - getTone?: (option: Option) => Tone | undefined; -}) { - return ( - - {title} - - {options.map((option, index) => ( - - {cursorLine(index === selected, `${index + 1}. ${option.label}`, option.description, getTone?.(option))} - - ))} - - ↑↓ or j/k navigate · 1-{options.length} jump · Enter select - - ); -} - -function MultiSelect({ - title, - options, - selected, - cursor, - getTone, - hint = "↑↓ or j/k navigate · Space toggle · a select all · Enter confirm", -}: { - title: string; - options: Option[]; - selected: Set; - cursor: number; - getTone?: (option: Option) => Tone | undefined; - hint?: string; -}) { - return ( - - {title} - - {options.length === 0 ? No options available. : null} - {options.map((option, index) => { - const tone = getTone?.(option); - return ( - - - - {index === cursor ? "❯" : " "} {selected.has(option.value) ? "◉" : "○"} - {" "} - {option.label} - - {option.description ? {option.description} : null} - - ); - })} - - {hint} - - ); -} - -function ConfirmPrompt({ title, value }: { title: string; value: boolean }) { - return ( - - {title} - - Yes - / - No - - y/← = yes · n/→ = no · Enter confirm - - ); -} +import { buildInitSetup } from "./init-mode-config.js"; +import { + configToggles, + dangerousInsecureConnection, + defaultSkippedPlugins, + installSources, + isStrongApiKey, + modes, + numberPhases, + optionalPlugins, + pluginTone, + skipPluginOptions, + textPromptTitles, + toggleTone, + type InitMode, + type InitSetup, + type Phase, + type SummaryRow, +} from "./init-mode-model.js"; +import { + ConfirmPrompt, + DangerousName, + InfoPanel, + MultiSelect, + NameList, + SingleSelect, + SummaryRows, + TextInputPrompt, +} from "./init-mode-prompts.js"; +import { buildInitSummaryRows } from "./init-mode-summary.js"; function InitSetupPrompt({ defaultMode, @@ -457,58 +111,36 @@ function InitSetupPrompt({ const nextAfterToggles = () => setPhase(needsApiKey ? "apiKey" : "appId"); const finish = () => { - const config: Record = {}; - if (sessionName.trim()) config.sessionName = sessionName.trim(); - if (pluginPromptsEnabled && include.size > 0) config.include = [...include]; - if (pluginPromptsEnabled && exclude.size > 0) config.exclude = [...exclude]; - - if (advanced) { - config.debug = toggles.has("debug"); - config.host = host.trim() || "127.0.0.1"; - config.port = numericValue(port, 4004); - config.mode = socketModeIndex === 0 ? "socket" : "disk"; - if (baseDir.trim()) config.baseDir = baseDir.trim(); - config.wrapPrint = toggles.has("wrapPrint"); - config.maxTempLogs = numericValue(maxTempLogs, 200); - config.sampleRate = numericValue(sampleRate, 1); - config.updateInterval = numericValue(updateInterval, 0.1); - config.defaultObservers = toggles.has("defaultObservers"); - config.errorWait = numericValue(errorWait, 3); - config.autoRegisterErrorHandler = toggles.has("autoRegisterErrorHandler"); - config.captureScreenshot = toggles.has("captureScreenshot"); - config.writeToDisk = toggles.has("writeToDisk"); - config.outputDir = outputDir.trim() || "logs"; - config.capabilities = parseCapabilities(capabilities); - config.retryInterval = numericValue(retryInterval, 5); - config.connectTimeout = numericValue(connectTimeout, 2); - config.debugger = toggles.has("debugger"); - config.assetPreview = toggles.has("assetPreview"); - config.binaryTextThreshold = numericValue(binaryTextThreshold, 4096); - if (deviceId.trim()) config.deviceId = deviceId.trim(); - } - - if (needsApiKey) { - config.apiKey = apiKey; - config.pluginOptions = { - console: { evalEnabled: true }, - }; - } - - if (appIdInput.trim()) { - config.appId = appIdInput.trim(); - } else { - config.__DANGEROUS_INSECURE_CONNECTION__ = true; - } - - onComplete({ + onComplete(buildInitSetup({ mode, - source: installSource, - branch: branch.trim() || "main", - installDir: installDir.trim() || "feather", + installSource, + branch, + installDir, installPlugins, - config, - exclude: pluginPromptsEnabled ? [...exclude] : [], - }); + pluginPromptsEnabled, + include, + exclude, + advanced, + sessionName, + host, + port, + socketModeIndex, + baseDir, + sampleRate, + updateInterval, + maxTempLogs, + outputDir, + retryInterval, + connectTimeout, + errorWait, + binaryTextThreshold, + deviceId, + capabilities, + toggles, + needsApiKey, + apiKey, + appIdInput, + })); exit(); }; @@ -712,60 +344,23 @@ function InitSetupPrompt({ if (key.return) finish(); }); - const summary = useMemo(() => { - const includeList = [...include]; - const excludeList = [...exclude]; - const rows: SummaryRow[] = [ - { id: "mode", label: "Mode", value: modes[modeIndex].label }, - { - id: "install-dir", - label: "Install dir", - value: installsFiles ? `${installDir || "feather"}/` : "bundled CLI runtime", - }, - installsFiles ? { id: "source", label: "Source", value: installSources[sourceIndex].label } : undefined, - installsFiles && installSource === "remote" ? { id: "branch", label: "Branch", value: branch || "main" } : undefined, - installsFiles ? { id: "plugins", label: "Install plugins", value: installPlugins ? "yes" : "no" } : undefined, - { id: "session", label: "Session", value: sessionName || "(default)" }, - pluginPromptsEnabled - ? { id: "include", label: "Include", value: } - : undefined, - pluginPromptsEnabled - ? { id: "exclude", label: "Exclude", value: } - : undefined, - { id: "advanced", label: "Advanced config", value: advanced ? "yes" : "no", tone: advanced ? "warning" : undefined }, - ].filter(Boolean) as SummaryRow[]; - - if (needsApiKey) { - rows.push({ - id: "api-key", - label: "Console API key", - value: - apiKeyCopied === true - ? "set and copied to clipboard" - : apiKeyCopied === false - ? "set (clipboard copy unavailable)" - : "set", - tone: "success", - }); - } - rows.push( - appIdInput.trim() - ? { id: "app-id", label: "App ID", value: appIdInput.trim(), tone: "success" } - : { - id: "app-id", - label: "App ID", - value: ( - <> - not set → - {dangerousInsecureConnection} - = true - - ), - tone: "danger", - }, - ); - return rows; - }, [ + const summary = useMemo(() => buildInitSummaryRows({ + advanced, + apiKeyCopied, + appIdInput, + branch, + exclude, + include, + installDir, + installPlugins, + installsFiles, + installSource, + modeIndex, + needsApiKey, + pluginPromptsEnabled, + sessionName, + sourceIndex, + }), [ advanced, apiKeyCopied, appIdInput, diff --git a/cli/src/ui/init-mode.tsx b/cli/src/ui/init-mode.tsx index c9491db9..c114d17c 100644 --- a/cli/src/ui/init-mode.tsx +++ b/cli/src/ui/init-mode.tsx @@ -1,2 +1,2 @@ -export type { InitMode, InitSetup } from './init-mode-workflow.js'; +export type { InitMode, InitSetup } from './init-mode-model.js'; export { chooseInitMode } from './init-mode-workflow.js'; diff --git a/cli/src/ui/package-add-steps.tsx b/cli/src/ui/package-add-steps.tsx index ff019cc7..86c287fa 100644 --- a/cli/src/ui/package-add-steps.tsx +++ b/cli/src/ui/package-add-steps.tsx @@ -1,7 +1,4 @@ import { Box, Text, useInput } from 'ink'; -import { useEffect, useState } from 'react'; -import { installCustomRepoPackage, installCustomUrlPackage } from '../lib/package/custom-add.js'; -import type { Lockfile } from '../lib/package/lockfile.js'; import type { UrlFile } from './components.js'; import { REPO_TOTAL, URL_TOTAL } from './package-add-helpers.js'; @@ -118,90 +115,6 @@ export function UrlConfirmStep({ ); } -export function InstallStep({ - id, - repoName, - tag, - baseUrl, - selectedFiles, - targetMap, - projectDir, - lockfile, - onDone, - onError, -}: { - id: string; - repoName: string; - tag: string; - baseUrl: string; - selectedFiles: string[]; - targetMap: Record; - projectDir: string; - lockfile: Lockfile; - onDone: () => void; - onError: (msg: string) => void; -}) { - const [current, setCurrent] = useState(''); - useEffect(() => { - const run = async () => { - const result = await installCustomRepoPackage({ - id, - repoName, - tag, - baseUrl, - selectedFiles, - targetMap, - projectDir, - lockfile, - onFileStart: setCurrent, - }); - if (!result.ok) throw new Error(result.error ?? 'Install failed'); - onDone(); - }; - run().catch((err: Error) => onError(err.message)); - }, []); - return ( - - {current ? `Installing ${current}…` : 'Installing…'} - - ); -} - -export function WriteStep({ - id, - urlFiles, - projectDir, - lockfile, - onDone, - onError, -}: { - id: string; - urlFiles: UrlFile[]; - projectDir: string; - lockfile: Lockfile; - onDone: () => void; - onError: (msg: string) => void; -}) { - useEffect(() => { - const run = async () => { - const result = await installCustomUrlPackage({ - id, - urlFiles, - projectDir, - lockfile, - }); - if (!result.ok) throw new Error(result.error ?? 'Install failed'); - onDone(); - }; - run().catch((err: Error) => onError(err.message)); - }, []); - return ( - - Installing… - - ); -} - export function DoneStep({ id, files, diff --git a/cli/src/ui/package-add.tsx b/cli/src/ui/package-add.tsx index e7d028af..d4f1a720 100644 --- a/cli/src/ui/package-add.tsx +++ b/cli/src/ui/package-add.tsx @@ -1,5 +1,6 @@ import { render, Text, Box, useApp } from 'ink'; import { useState } from 'react'; +import type { PackageAddPlan } from '../lib/package/add-plan.js'; import type { Lockfile } from '../lib/package/lockfile.js'; import { type UrlFile, @@ -14,7 +15,7 @@ import { import { fetchCommitSha, fetchLuaFiles } from '../lib/github.js'; import { fileNameFromUrl } from '../lib/url.js'; import { fetchRepoMeta, REPO_TOTAL, URL_TOTAL } from './package-add-helpers.js'; -import { DoneStep, InstallStep, RepoConfirmStep, UrlConfirmStep, WriteStep } from './package-add-steps.js'; +import { RepoConfirmStep, UrlConfirmStep } from './package-add-steps.js'; type Step = | 'choose' @@ -32,12 +33,9 @@ type Step = | 'file-more' | 'require' | 'confirm' - | 'install' - | 'write' - | 'done' | 'error'; -function Wizard({ projectDir, lockfile }: { projectDir: string; lockfile: Lockfile }) { +function Wizard({ lockfile, onPlan }: { lockfile: Lockfile; onPlan: (plan: PackageAddPlan | null) => void }) { const { exit } = useApp(); const [step, setStep] = useState('choose'); const [mode, setMode] = useState<'repo' | 'url' | null>(null); @@ -70,10 +68,10 @@ function Wizard({ projectDir, lockfile }: { projectDir: string; lockfile: Lockfi setStep('error'); }; - const doneFiles = - mode === 'repo' - ? selectedFiles.map((f) => ({ name: f, target: targetMap[f] ?? f })) - : urlFiles.map((f) => ({ name: f.name, target: f.target })); + const finishWithPlan = (plan: PackageAddPlan | null) => { + onPlan(plan); + exit(); + }; if (step === 'choose') { return ( @@ -259,11 +257,19 @@ function Wizard({ projectDir, lockfile }: { projectDir: string; lockfile: Lockfi tag={tag} selectedFiles={selectedFiles} targetMap={targetMap} - onConfirm={() => setStep('install')} - onAbort={() => { - setErrorMsg('Aborted.'); - setStep('error'); - }} + onConfirm={() => + finishWithPlan({ + kind: 'repo', + id, + requirePath, + repoName, + tag, + baseUrl, + selectedFiles, + targetMap, + }) + } + onAbort={() => finishWithPlan(null)} /> ); } @@ -271,28 +277,15 @@ function Wizard({ projectDir, lockfile }: { projectDir: string; lockfile: Lockfi setStep('write')} - onAbort={() => { - setErrorMsg('Aborted.'); - setStep('error'); - }} - /> - ); - } - - if (step === 'install') { - return ( - setStep('done')} - onError={handleError} + onConfirm={() => + finishWithPlan({ + kind: 'url', + id, + requirePath, + urlFiles, + }) + } + onAbort={() => finishWithPlan(null)} /> ); } @@ -373,23 +366,6 @@ function Wizard({ projectDir, lockfile }: { projectDir: string; lockfile: Lockfi ); } - if (step === 'write') { - return ( - setStep('done')} - onError={handleError} - /> - ); - } - - if (step === 'done') { - return ; - } - return ( @@ -400,9 +376,11 @@ function Wizard({ projectDir, lockfile }: { projectDir: string; lockfile: Lockfi ); } -export async function showAddWizard(opts: { projectDir: string; lockfile: Lockfile }): Promise { - const { waitUntilExit } = render(, { +export async function showAddWizard(opts: { projectDir: string; lockfile: Lockfile }): Promise { + let plan: PackageAddPlan | null = null; + const { waitUntilExit } = render( (plan = nextPlan)} />, { alternateScreen: true, }); await waitUntilExit(); + return plan; } diff --git a/cli/test/package.test.mjs b/cli/test/package.test.mjs index 0a7179c3..83272910 100644 --- a/cli/test/package.test.mjs +++ b/cli/test/package.test.mjs @@ -828,6 +828,158 @@ function emptyLockfile() { return { lockfileVersion: 1, generatedAt: new Date(0).toISOString(), packages: {} }; } +function initSetupState(overrides = {}) { + return { + mode: 'auto', + installSource: 'local', + branch: 'main', + installDir: 'feather', + installPlugins: true, + pluginPromptsEnabled: true, + include: new Set(), + exclude: new Set(['console']), + advanced: false, + sessionName: 'My Game', + host: '127.0.0.1', + port: '4004', + socketModeIndex: 0, + baseDir: '', + sampleRate: '1', + updateInterval: '0.1', + maxTempLogs: '200', + outputDir: 'logs', + retryInterval: '5', + connectTimeout: '2', + errorWait: '3', + binaryTextThreshold: '4096', + deviceId: '', + capabilities: 'all', + toggles: new Set(['debug', 'wrapPrint', 'defaultObservers']), + needsApiKey: false, + apiKey: '', + appIdInput: 'feather-app-test', + ...overrides, + }; +} + +test('package add: repo plan converts to custom install input', async () => { + const { packageAddPlanFiles, toCustomRepoPackageInput } = await import('../dist/lib/package/add-plan.js'); + const lockfile = emptyLockfile(); + const plan = { + kind: 'repo', + id: 'my-pkg', + requirePath: 'lib.my-pkg.init', + repoName: 'me/pkg', + tag: 'v1.0.0', + baseUrl: 'https://raw.githubusercontent.com/me/pkg/abc123/', + selectedFiles: ['init.lua', 'util.lua'], + targetMap: { 'init.lua': 'lib/my-pkg/init.lua', 'util.lua': 'lib/my-pkg/util.lua' }, + }; + + assert.deepEqual(packageAddPlanFiles(plan), [ + { name: 'init.lua', target: 'lib/my-pkg/init.lua' }, + { name: 'util.lua', target: 'lib/my-pkg/util.lua' }, + ]); + assert.deepEqual(toCustomRepoPackageInput({ plan, projectDir: '/tmp/game', lockfile }), { + id: 'my-pkg', + repoName: 'me/pkg', + tag: 'v1.0.0', + baseUrl: 'https://raw.githubusercontent.com/me/pkg/abc123/', + selectedFiles: ['init.lua', 'util.lua'], + targetMap: { 'init.lua': 'lib/my-pkg/init.lua', 'util.lua': 'lib/my-pkg/util.lua' }, + projectDir: '/tmp/game', + lockfile, + onFileStart: undefined, + }); +}); + +test('package add: url plan converts to custom install input', async () => { + const { packageAddPlanFiles, toCustomUrlPackageInput } = await import('../dist/lib/package/add-plan.js'); + const lockfile = emptyLockfile(); + const urlFiles = [ + { + name: 'helper.lua', + url: 'https://example.com/helper.lua', + sha256: sha256('return {}'), + target: 'lib/helper.lua', + buffer: Buffer.from('return {}'), + }, + ]; + const plan = { kind: 'url', id: 'helper', requirePath: 'lib.helper', urlFiles }; + + assert.deepEqual(packageAddPlanFiles(plan), [{ name: 'helper.lua', target: 'lib/helper.lua' }]); + assert.deepEqual(toCustomUrlPackageInput({ plan, projectDir: '/tmp/game', lockfile }), { + id: 'helper', + urlFiles, + projectDir: '/tmp/game', + lockfile, + }); +}); + +test('package add: failed plan install does not write lockfile', async () => { + const dir = makeTmp(); + const { installPackageAddPlan } = await import('../dist/lib/package/add-plan.js'); + + await withFetchMock( + async () => new Response('missing', { status: 404 }), + async () => { + const result = await installPackageAddPlan({ + projectDir: dir, + lockfile: emptyLockfile(), + plan: { + kind: 'repo', + id: 'my-pkg', + requirePath: 'lib.my-pkg.init', + repoName: 'me/pkg', + tag: 'v1.0.0', + baseUrl: 'https://raw.githubusercontent.com/me/pkg/abc123/', + selectedFiles: ['init.lua'], + targetMap: { 'init.lua': 'lib/my-pkg/init.lua' }, + }, + }); + + assert.equal(result.ok, false); + assert.equal(existsSync(join(dir, 'feather.lock.json')), false); + }, + ); +}); + +test('init mode: config builder preserves cli and advanced setup values', async () => { + const { buildInitSetup } = await import('../dist/ui/init-mode-config.js'); + const setup = buildInitSetup( + initSetupState({ + mode: 'cli', + installSource: 'remote', + branch: 'dev', + installDir: '', + installPlugins: true, + include: new Set(['console']), + exclude: new Set(), + advanced: true, + port: '5000', + socketModeIndex: 1, + capabilities: 'logs, assets', + toggles: new Set(['debug', 'captureScreenshot']), + needsApiKey: true, + apiKey: 'StrongSecret123!', + appIdInput: '', + }), + ); + + assert.equal(setup.mode, 'cli'); + assert.equal(setup.source, 'remote'); + assert.equal(setup.branch, 'dev'); + assert.equal(setup.installDir, 'feather'); + assert.deepEqual(setup.config.include, ['console']); + assert.equal(setup.config.port, 5000); + assert.equal(setup.config.mode, 'disk'); + assert.deepEqual(setup.config.capabilities, ['logs', 'assets']); + assert.equal(setup.config.captureScreenshot, true); + assert.equal(setup.config.apiKey, 'StrongSecret123!'); + assert.deepEqual(setup.config.pluginOptions, { console: { evalEnabled: true } }); + assert.equal(setup.config.__DANGEROUS_INSECURE_CONNECTION__, true); +}); + test('custom add: repo install writes selected files and lockfile metadata', async () => { const dir = makeTmp(); const { installCustomRepoPackage } = await import('../dist/lib/package/custom-add.js'); From 6ff72907c94ea09411d44608cbd4e6768b80ac0f Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 00:54:41 -0400 Subject: [PATCH 28/68] cli: Output standardization --- cli/src/commands/doctor/report.ts | 22 ++++---- cli/src/commands/init.ts | 34 ++++++------- cli/src/commands/package/add.ts | 12 ++--- cli/src/commands/package/audit.ts | 31 ++++++++---- cli/src/commands/package/info.ts | 38 +++++++------- cli/src/commands/package/install.ts | 78 ++++++++++++++++------------- cli/src/commands/package/list.ts | 14 +++--- cli/src/commands/package/remove.ts | 8 +-- cli/src/commands/package/search.ts | 10 ++-- cli/src/commands/package/shared.ts | 10 ++-- cli/src/commands/package/update.ts | 12 ++--- cli/src/commands/plugin/list.ts | 16 +++--- cli/src/commands/plugin/remove.ts | 6 +-- cli/src/commands/plugin/update.ts | 6 +-- cli/src/commands/plugin/workflow.ts | 4 +- cli/src/commands/remove.ts | 10 ++-- cli/src/commands/run.ts | 12 ++--- cli/src/commands/update.ts | 8 +-- cli/src/lib/output.ts | 52 +++++++++++++++++++ cli/test/package.test.mjs | 39 ++++++++++++++- 20 files changed, 258 insertions(+), 164 deletions(-) diff --git a/cli/src/commands/doctor/report.ts b/cli/src/commands/doctor/report.ts index 32f55d53..b5dc4fc8 100644 --- a/cli/src/commands/doctor/report.ts +++ b/cli/src/commands/doctor/report.ts @@ -1,4 +1,4 @@ -import { icon as statusIcon, style } from '../../lib/output.js'; +import { icon as statusIcon, printBlank, printDanger, printHeading, printLine, printMuted, printSuccess, printWarning, style } from '../../lib/output.js'; import { type DoctorCheck, type Severity, severityOrder } from './checks.js'; function icon(severity: Severity): string { @@ -15,17 +15,17 @@ function colorLabel(severity: Severity, label: string): string { } export function renderReport(checks: DoctorCheck[], projectDir: string): void { - console.log(style.heading('\nFeather doctor\n')); - console.log(style.muted(`Project: ${projectDir}\n`)); + printHeading('\nFeather doctor\n'); + printMuted(`Project: ${projectDir}\n`); const groups = [...new Set(checks.map((check) => check.group))]; for (const group of groups) { - console.log(style.heading(group)); + printHeading(group); for (const check of checks.filter((item) => item.group === group).sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity])) { - console.log(` ${icon(check.severity)} ${colorLabel(check.severity, check.label)}${check.detail ? style.muted(` ${check.detail}`) : ''}`); - if (check.fix) console.log(style.muted(` → ${check.fix}`)); + printLine(` ${icon(check.severity)} ${colorLabel(check.severity, check.label)}${check.detail ? style.muted(` ${check.detail}`) : ''}`); + if (check.fix) printMuted(` → ${check.fix}`); } - console.log(); + printBlank(); } const failures = checks.filter((check) => check.severity === 'fail'); @@ -33,11 +33,11 @@ export function renderReport(checks: DoctorCheck[], projectDir: string): void { const passed = checks.filter((check) => check.severity === 'pass'); if (failures.length > 0) { - console.log(style.danger(`Doctor found ${failures.length} blocker${failures.length === 1 ? '' : 's'}.`)); + printDanger(`Doctor found ${failures.length} blocker${failures.length === 1 ? '' : 's'}.`); } else if (warnings.length > 0) { - console.log(style.warning(`Doctor passed with ${warnings.length} warning${warnings.length === 1 ? '' : 's'}.`)); + printWarning(`Doctor passed with ${warnings.length} warning${warnings.length === 1 ? '' : 's'}.`); } else { - console.log(style.success('Doctor found no problems.')); + printSuccess('Doctor found no problems.'); } - console.log(style.muted(`${passed.length} passed, ${warnings.length} warnings, ${failures.length} failures.\n`)); + printMuted(`${passed.length} passed, ${warnings.length} warnings, ${failures.length} failures.\n`); } diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index ad81c6be..1b5d72df 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -15,7 +15,7 @@ import { chooseInitMode, type InitMode, type InitSetup } from '../ui/init-mode.j import { pluginCatalog } from '../generated/plugin-catalog.js'; import { resolveLocalLuaRoot } from '../lib/paths.js'; import { fail } from '../lib/command.js'; -import { createSpinner, icon, style } from '../lib/output.js'; +import { createSpinner, icon, printLine, printMuted, printWarning, style } from '../lib/output.js'; export interface InitOptions { branch?: string; @@ -105,15 +105,15 @@ export async function initCommand(dir: string, opts: InitOptions): Promise installDir, source: useRemote ? `github:${setup.branch || opts.branch || 'main'}` : 'local', }); - console.log(`\n${style.heading('Done!')} Run this project through Feather CLI.\n`); - console.log(style.muted(` feather run ${dir}`)); - console.log(style.muted(' Use `--config ` if feather.config.lua lives elsewhere.\n')); + printLine(`\n${style.heading('Done!')} Run this project through Feather CLI.\n`); + printMuted(` feather run ${dir}`); + printMuted(' Use `--config ` if feather.config.lua lives elsewhere.\n'); return; } if (alreadyInstalled) { - console.log(style.warning('Feather is already installed in this project.')); - console.log(style.muted('Run `feather update` to update to the latest version.')); + printWarning('Feather is already installed in this project.'); + printMuted('Run `feather update` to update to the latest version.'); } const branch = setup.branch || opts.branch || 'main'; @@ -196,9 +196,9 @@ export async function initCommand(dir: string, opts: InitOptions): Promise if (mode === 'auto') { const patched = patchMainLua(mainPath, installDir); if (patched) { - console.log(`${icon.success} Patched main.lua with feather.auto require`); + printLine(`${icon.success} Patched main.lua with feather.auto require`); } else { - console.log(style.muted(' main.lua already references feather — skipped patch')); + printMuted(' main.lua already references feather — skipped patch'); } } else if (mode === 'manual') { const pluginIds = @@ -211,15 +211,15 @@ export async function initCommand(dir: string, opts: InitOptions): Promise const patched = patchMainLuaForManual(mainPath); if (created) { - console.log(`${icon.success} Created feather.debugger.lua`); + printLine(`${icon.success} Created feather.debugger.lua`); } else { - console.log(style.muted(' feather.debugger.lua already exists — skipped')); + printMuted(' feather.debugger.lua already exists — skipped'); } if (patched) { - console.log(`${icon.success} Patched main.lua with feather.debugger.lua loader`); + printLine(`${icon.success} Patched main.lua with feather.debugger.lua loader`); } else { - console.log(style.muted(' main.lua already references Feather — skipped patch')); + printMuted(' main.lua already references Feather — skipped patch'); } } @@ -230,12 +230,12 @@ export async function initCommand(dir: string, opts: InitOptions): Promise manualEntrypoint: mode === 'manual' ? 'feather.debugger.lua' : undefined, }); - console.log(`\n${style.heading('Done!')} Start the Feather desktop app, then run your game.\n`); + printLine(`\n${style.heading('Done!')} Start the Feather desktop app, then run your game.\n`); if (mode === 'manual') { - console.log(style.muted(' Manual setup lives in feather.debugger.lua and is loaded from main.lua.\n')); + printMuted(' Manual setup lives in feather.debugger.lua and is loaded from main.lua.\n'); } else { - console.log(style.muted(' Tip: use `feather run .` to inject without touching game code.\n')); + printMuted(' Tip: use `feather run .` to inject without touching game code.\n'); } } @@ -247,9 +247,9 @@ function writeConfig( const configPath = join(target, 'feather.config.lua'); if (!existsSync(configPath)) { writeFileSync(configPath, configTemplate(config, context)); - console.log(`${icon.success} Created feather.config.lua`); + printLine(`${icon.success} Created feather.config.lua`); } else { - console.log(style.muted(' feather.config.lua already exists — skipped')); + printMuted(' feather.config.lua already exists — skipped'); } } diff --git a/cli/src/commands/package/add.ts b/cli/src/commands/package/add.ts index 05179829..962a8916 100644 --- a/cli/src/commands/package/add.ts +++ b/cli/src/commands/package/add.ts @@ -1,7 +1,7 @@ import { fail } from '../../lib/command.js'; import { installPackageAddPlan, packageAddPlanFiles } from '../../lib/package/add-plan.js'; import { readLockfile } from '../../lib/package/lockfile.js'; -import { createSpinner, keyValueRows, style } from '../../lib/output.js'; +import { createSpinner, printBlank, printKeyValues, printMuted, style } from '../../lib/output.js'; import { showAddWizard } from '../../ui/package-add.js'; import { ensurePackageAddInteractive, resolvePackageProjectDir } from './shared.js'; @@ -16,7 +16,7 @@ export async function packageAddCommand(opts: PackageAddOptions = {}): Promise `${file.name} -> ${file.target}`).join(', ')], ['Usage', `local ${plan.id.replace(/[.-]/g, '_')} = require('${plan.requirePath}')`], ['Trust', style.warning('experimental; not reviewed by the Feather team')], - ])) { - console.log(row); - } + ]); } diff --git a/cli/src/commands/package/audit.ts b/cli/src/commands/package/audit.ts index e5bcf887..5eebaf53 100644 --- a/cli/src/commands/package/audit.ts +++ b/cli/src/commands/package/audit.ts @@ -1,7 +1,18 @@ import { auditLockfile } from '../../lib/package/audit.js'; import { readLockfile } from '../../lib/package/lockfile.js'; import { fail } from '../../lib/command.js'; -import { createSpinner, icon, printJson, statusLine, style, table } from '../../lib/output.js'; +import { + createSpinner, + icon, + printBlank, + printDanger, + printHeading, + printJson, + printMuted, + printStatus, + printTable, + style, +} from '../../lib/output.js'; import { resolvePackageProjectDir } from './shared.js'; export type PackageAuditOptions = { @@ -15,7 +26,7 @@ export async function packageAuditCommand(opts: PackageAuditOptions = {}): Promi const entries = Object.values(lockfile.packages); if (entries.length === 0) { - console.log(style.muted('No packages installed.')); + printMuted('No packages installed.'); return; } @@ -29,9 +40,9 @@ export async function packageAuditCommand(opts: PackageAuditOptions = {}): Promi return; } - console.log(style.heading(`\nAuditing ${entries.length} installed package(s)…\n`)); + printHeading(`\nAuditing ${entries.length} installed package(s)…\n`); - for (const line of table({ + printTable({ columns: [ { key: 'status', @@ -54,17 +65,15 @@ export async function packageAuditCommand(opts: PackageAuditOptions = {}): Promi target: result.target, message: result.status === 'modified' ? 'MODIFIED ← SHA-256 mismatch' : result.status, })), - })) { - console.log(line); - } + }); const bad = results.filter((r) => r.status !== 'verified'); - console.log(); + printBlank(); if (bad.length === 0) { - console.log(statusLine('success', 'All packages verified.')); + printStatus('success', 'All packages verified.'); } else { - console.log(style.danger(`${bad.length} issue(s) found. Re-install affected packages with \`feather package install \`.`)); + printDanger(`${bad.length} issue(s) found. Re-install affected packages with \`feather package install \`.`); fail('', { silent: true }); } - console.log(); + printBlank(); } diff --git a/cli/src/commands/package/info.ts b/cli/src/commands/package/info.ts index bfa990e6..01cf326b 100644 --- a/cli/src/commands/package/info.ts +++ b/cli/src/commands/package/info.ts @@ -1,6 +1,6 @@ import { readLockfile } from '../../lib/package/lockfile.js'; import { fail } from '../../lib/command.js'; -import { style } from '../../lib/output.js'; +import { printBlank, printLine, printMuted, style } from '../../lib/output.js'; import { trustBadge } from '../../lib/trust.js'; import { loadRegistryOrExit, resolvePackageProjectDir } from './shared.js'; @@ -23,28 +23,28 @@ export async function packageInfoCommand(name: string, opts: PackageInfoOptions } const installed = lockfile.packages[name]; - console.log(); - console.log(`${style.heading(name)} ${trustBadge(entry.trust)}`); - console.log(style.muted(entry.description)); - console.log(); - console.log(` Source: ${style.info(`github.com/${entry.source.repo}`)}`); - console.log(` Version: ${entry.source.tag}`); - console.log(` Tags: ${entry.tags.join(', ') || '—'}`); - if (entry.license) console.log(` License: ${entry.license}`); - if (entry.homepage) console.log(` Docs: ${style.info(entry.homepage)}`); + printBlank(); + printLine(`${style.heading(name)} ${trustBadge(entry.trust)}`); + printMuted(entry.description); + printBlank(); + printLine(` Source: ${style.info(`github.com/${entry.source.repo}`)}`); + printLine(` Version: ${entry.source.tag}`); + printLine(` Tags: ${entry.tags.join(', ') || '—'}`); + if (entry.license) printLine(` License: ${entry.license}`); + if (entry.homepage) printLine(` Docs: ${style.info(entry.homepage)}`); if (installed) { - console.log(` Status: ${style.success('installed')} @ ${installed.version}`); + printLine(` Status: ${style.success('installed')} @ ${installed.version}`); } if (entry.subpackages?.length) { - console.log(` Modules: ${entry.subpackages.join(', ')}`); + printLine(` Modules: ${entry.subpackages.join(', ')}`); } - console.log(); - console.log(' Files to install:'); + printBlank(); + printLine(' Files to install:'); for (const f of entry.install.files) { - console.log(` ${style.muted(f.name)} → ${f.target}`); + printLine(` ${style.muted(f.name)} → ${f.target}`); } - console.log(); - console.log(' Usage:'); - console.log(` ${style.info(entry.example ?? `local lib = require("${entry.require}")`)}`); - console.log(); + printBlank(); + printLine(' Usage:'); + printLine(` ${style.info(entry.example ?? `local lib = require("${entry.require}")`)}`); + printBlank(); } diff --git a/cli/src/commands/package/install.ts b/cli/src/commands/package/install.ts index 530ee8e0..4c4e5bea 100644 --- a/cli/src/commands/package/install.ts +++ b/cli/src/commands/package/install.ts @@ -3,7 +3,19 @@ import { installFromUrl, restorePackage } from '../../lib/package/install.js'; import { readLockfile, writeLockfile } from '../../lib/package/lockfile.js'; import { resolveMany } from '../../lib/package/resolve.js'; import { fail } from '../../lib/command.js'; -import { createSpinner, icon, keyValueRows, statusLine, style } from '../../lib/output.js'; +import { + createSpinner, + icon, + printBlank, + printDanger, + printKeyValues, + printLine, + printMuted, + printStatus, + printSuccess, + printWarning, + style, +} from '../../lib/output.js'; import { trustBadge } from '../../lib/trust.js'; import { confirmAction } from '../../ui/confirm.js'; import { showInstallProgress } from '../../ui/package-progress.js'; @@ -25,25 +37,23 @@ export async function packageInstallCommand(names: string[], opts: PackageInstal if (opts.fromUrl) { if (!opts.target) { - console.log(); - console.log(statusLine('error', '--target is required with --from-url')); + printBlank(); + printStatus('error', '--target is required with --from-url'); fail('', { silent: true }); } if (!opts.allowUntrusted) { - console.log(); - console.log(style.warning('Installing from untrusted URL')); - for (const row of keyValueRows([ + printBlank(); + printWarning('Installing from untrusted URL'); + printKeyValues([ ['URL', opts.fromUrl], ['Target', opts.target], ['Trust', 'experimental; not reviewed by the Feather team'], - ])) { - console.log(row); - } + ]); if (!process.stdin.isTTY || !process.stdout.isTTY) { - console.log(); - console.log(style.danger('Use --allow-untrusted to confirm this source in non-interactive mode.')); + printBlank(); + printDanger('Use --allow-untrusted to confirm this source in non-interactive mode.'); fail('', { silent: true }); } @@ -55,7 +65,7 @@ export async function packageInstallCommand(names: string[], opts: PackageInstal rows: [`URL: ${opts.fromUrl}`, `Target: ${opts.target}`], }); if (!confirmed) { - console.log(style.muted('Install cancelled.')); + printMuted('Install cancelled.'); return; } } @@ -76,29 +86,25 @@ export async function packageInstallCommand(names: string[], opts: PackageInstal if (opts.dryRun) { spinner.stop(); - console.log(); - console.log(style.warning('Dry run: no files written')); - for (const row of keyValueRows([ + printBlank(); + printWarning('Dry run: no files written'); + printKeyValues([ ['URL', opts.fromUrl], ['SHA-256', result.sha256], ['Size', `${result.size} bytes`], ['Target', result.target], ['Trust', 'experimental; not reviewed'], - ])) { - console.log(row); - } + ]); return; } spinner.succeed('Installed from URL (experimental)'); - console.log(); - for (const row of keyValueRows([ + printBlank(); + printKeyValues([ ['SHA-256', result.sha256], ['Target', result.target], ['Trust', style.warning('experimental; not reviewed by the Feather team')], - ])) { - console.log(row); - } + ]); writeLockfile(projectDir, lockfile); return; } @@ -108,23 +114,23 @@ export async function packageInstallCommand(names: string[], opts: PackageInstal const entries = Object.entries(lockfile.packages).filter(([, e]) => !e.parent); if (entries.length === 0) { - console.log(style.muted('feather.lock.json is empty. Run `feather package install ` to add packages.')); + printMuted('feather.lock.json is empty. Run `feather package install ` to add packages.'); return; } - console.log(); + printBlank(); const auditResults = await auditLockfile(projectDir, lockfile); const broken = new Set(auditResults.filter((r) => r.status !== 'verified').map((r) => r.id)); if (broken.size === 0) { - console.log(style.success(`✔ All ${entries.length} package(s) already up to date.`)); + printSuccess(`✔ All ${entries.length} package(s) already up to date.`); return; } let failed = false; for (const [id, entry] of entries) { if (!broken.has(id)) { - console.log(style.muted(` ${id} @ ${entry.version} — up to date`)); + printMuted(` ${id} @ ${entry.version} — up to date`); continue; } const spinner = createSpinner(` ${id} @ ${entry.version}`).start(); @@ -137,7 +143,7 @@ export async function packageInstallCommand(names: string[], opts: PackageInstal } } - console.log(); + printBlank(); if (failed) fail('', { silent: true }); return; } @@ -149,7 +155,7 @@ export async function packageInstallCommand(names: string[], opts: PackageInstal const { resolved, errors } = resolveMany(names, registry); if (errors.length) { - for (const e of errors) console.log(` ${icon.error} ${style.danger(e)}`); + for (const e of errors) printLine(` ${icon.error} ${style.danger(e)}`); fail('', { silent: true }); } @@ -157,7 +163,7 @@ export async function packageInstallCommand(names: string[], opts: PackageInstal if (pkg.versionOverride) return true; const existing = lockfile.packages[pkg.id]; if (existing && existing.version === pkg.entry.source.tag) { - console.log(style.muted(` ${pkg.id} is already installed at ${existing.version}`)); + printMuted(` ${pkg.id} is already installed at ${existing.version}`); return false; } return true; @@ -175,17 +181,17 @@ export async function packageInstallCommand(names: string[], opts: PackageInstal } if (opts.dryRun) { - console.log(); + printBlank(); for (const pkg of toInstall) { const displayVersion = pkg.versionOverride ?? pkg.entry.source.tag; - console.log(` ${style.heading(pkg.id)} ${trustBadge(pkg.versionOverride ? 'experimental' : pkg.entry.trust)}`); - console.log(` Source: github.com/${pkg.entry.source.repo} Version: ${displayVersion}`); + printLine(` ${style.heading(pkg.id)} ${trustBadge(pkg.versionOverride ? 'experimental' : pkg.entry.trust)}`); + printLine(` Source: github.com/${pkg.entry.source.repo} Version: ${displayVersion}`); for (const f of pkg.files) { - console.log(` ${style.muted(f.name)} → ${f.target}`); + printLine(` ${style.muted(f.name)} → ${f.target}`); } - console.log(); + printBlank(); } - console.log(style.warning('Dry run — no files written.')); + printWarning('Dry run — no files written.'); return; } diff --git a/cli/src/commands/package/list.ts b/cli/src/commands/package/list.ts index 9422d0c8..5fb3c76e 100644 --- a/cli/src/commands/package/list.ts +++ b/cli/src/commands/package/list.ts @@ -1,7 +1,7 @@ import { readLockfile, writeLockfile } from '../../lib/package/lockfile.js'; import { resolveMany } from '../../lib/package/resolve.js'; import { fail } from '../../lib/command.js'; -import { icon, style } from '../../lib/output.js'; +import { icon, printLine, printMuted, style } from '../../lib/output.js'; import { trustBadge } from '../../lib/trust.js'; import { showPackageBrowser } from '../../ui/package-workflow.js'; import { showInstallProgress } from '../../ui/package-progress.js'; @@ -23,13 +23,13 @@ export async function packageListCommand(opts: PackageListOptions = {}): Promise const lockfile = readLockfile(projectDir); const entries = Object.entries(lockfile.packages); if (entries.length === 0) { - console.log(style.muted('No packages installed. Run `feather package install `.')); + printMuted('No packages installed. Run `feather package install `.'); return; } for (const [id, entry] of entries) { - console.log(` ${trustBadge(entry.trust)} ${style.heading(id)} @ ${entry.version}`); + printLine(` ${trustBadge(entry.trust)} ${style.heading(id)} @ ${entry.version}`); } - console.log(style.muted(`\n${entries.length} package(s) installed.`)); + printMuted(`\n${entries.length} package(s) installed.`); return; } @@ -47,9 +47,9 @@ export async function packageListCommand(opts: PackageListOptions = {}): Promise for (const [id, entry] of entries.sort(([a], [b]) => a.localeCompare(b))) { const installed = lockfile.packages[id]; const installedLabel = installed ? style.info(` (installed ${installed.version})`) : ''; - console.log(` ${trustBadge(entry.trust)} ${style.heading(id)}${installedLabel} ${style.muted(entry.description)}`); + printLine(` ${trustBadge(entry.trust)} ${style.heading(id)}${installedLabel} ${style.muted(entry.description)}`); } - console.log(style.muted(`\n${entries.length} available.`)); + printMuted(`\n${entries.length} available.`); return; } @@ -58,7 +58,7 @@ export async function packageListCommand(opts: PackageListOptions = {}): Promise const { resolved, errors } = resolveMany([result.id], registry); if (errors.length) { - for (const e of errors) console.log(` ${icon.error} ${style.danger(e)}`); + for (const e of errors) printLine(` ${icon.error} ${style.danger(e)}`); fail('', { silent: true }); } diff --git a/cli/src/commands/package/remove.ts b/cli/src/commands/package/remove.ts index 2603f0c8..eb091956 100644 --- a/cli/src/commands/package/remove.ts +++ b/cli/src/commands/package/remove.ts @@ -2,7 +2,7 @@ import { existsSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { readLockfile, removeFromLockfile, writeLockfile } from '../../lib/package/lockfile.js'; import { fail } from '../../lib/command.js'; -import { icon, style } from '../../lib/output.js'; +import { icon, printLine, printMuted, style } from '../../lib/output.js'; import { confirmAction } from '../../ui/confirm.js'; import { resolvePackageProjectDir } from './shared.js'; @@ -37,7 +37,7 @@ export async function packageRemoveCommand(name: string, opts: PackageRemoveOpti ], }); if (!confirmed) { - console.log(style.muted('Package remove cancelled.')); + printMuted('Package remove cancelled.'); return; } } @@ -46,11 +46,11 @@ export async function packageRemoveCommand(name: string, opts: PackageRemoveOpti const abs = join(projectDir, file.target); if (existsSync(abs)) { rmSync(abs); - console.log(style.muted(` removed ${file.target}`)); + printMuted(` removed ${file.target}`); } } removeFromLockfile(lockfile, name); writeLockfile(projectDir, lockfile); - console.log(` ${icon.success} ${style.heading(name)} removed.`); + printLine(` ${icon.success} ${style.heading(name)} removed.`); } diff --git a/cli/src/commands/package/search.ts b/cli/src/commands/package/search.ts index a6adf8aa..1f3ff1c2 100644 --- a/cli/src/commands/package/search.ts +++ b/cli/src/commands/package/search.ts @@ -1,4 +1,4 @@ -import { style } from '../../lib/output.js'; +import { printLine, printMuted, style } from '../../lib/output.js'; import { trustBadge } from '../../lib/trust.js'; import { loadRegistryOrExit } from './shared.js'; @@ -22,17 +22,17 @@ export async function packageSearchCommand(query: string | undefined, opts: Pack : entries; if (matches.length === 0) { - console.log(style.muted(`No packages found${q ? ` matching "${query}"` : ''}.`)); + printMuted(`No packages found${q ? ` matching "${query}"` : ''}.`); return; } const maxId = Math.max(...matches.map(([id]) => id.length)); for (const [id, entry] of matches.sort(([a], [b]) => a.localeCompare(b))) { const badge = trustBadge(entry.trust); - console.log(` ${style.heading(id.padEnd(maxId + 2))} ${badge} ${style.muted(entry.description)}`); + printLine(` ${style.heading(id.padEnd(maxId + 2))} ${badge} ${style.muted(entry.description)}`); if (entry.subpackages?.length) { - console.log(style.muted(` ${''.padEnd(maxId + 2)} subpackages: ${entry.subpackages.join(', ')}`)); + printMuted(` ${''.padEnd(maxId + 2)} subpackages: ${entry.subpackages.join(', ')}`); } } - console.log(style.muted(`\n${matches.length} package(s). Run \`feather package info \` for details.`)); + printMuted(`\n${matches.length} package(s). Run \`feather package info \` for details.`); } diff --git a/cli/src/commands/package/shared.ts b/cli/src/commands/package/shared.ts index 1ae10f54..1b29cd20 100644 --- a/cli/src/commands/package/shared.ts +++ b/cli/src/commands/package/shared.ts @@ -2,7 +2,7 @@ import { resolve } from 'node:path'; import { findProjectDir } from '../../lib/paths.js'; import { loadRegistry, type Registry, type RegistryLoadOptions } from '../../lib/package/registry.js'; import { fail } from '../../lib/command.js'; -import { createSpinner, statusLine, style } from '../../lib/output.js'; +import { createSpinner, printMuted, printStatus } from '../../lib/output.js'; export function resolvePackageProjectDir(dir?: string): string { return dir ? resolve(dir) : findProjectDir(); @@ -29,12 +29,8 @@ export function ensurePackageAddInteractive(): boolean { return true; } - console.log(statusLine('error', '`feather package add` requires an interactive terminal.')); - console.log( - style.muted( - 'Run it from a real TTY, or use `feather package install --from-url --target --allow-untrusted` for scripts.', - ), - ); + printStatus('error', '`feather package add` requires an interactive terminal.'); + printMuted('Run it from a real TTY, or use `feather package install --from-url --target --allow-untrusted` for scripts.'); fail('', { silent: true }); return false; } diff --git a/cli/src/commands/package/update.ts b/cli/src/commands/package/update.ts index d41be601..04a2ec0d 100644 --- a/cli/src/commands/package/update.ts +++ b/cli/src/commands/package/update.ts @@ -1,7 +1,7 @@ import { readLockfile, writeLockfile } from '../../lib/package/lockfile.js'; import type { ResolvedPackage } from '../../lib/package/resolve.js'; import { fail } from '../../lib/command.js'; -import { style } from '../../lib/output.js'; +import { printLine, printMuted, printWarning, style } from '../../lib/output.js'; import { showInstallProgress } from '../../ui/package-progress.js'; import { loadRegistryOrExit, resolvePackageProjectDir } from './shared.js'; @@ -18,7 +18,7 @@ export async function packageUpdateCommand(name: string | undefined, opts: Packa const installed = Object.entries(lockfile.packages); if (installed.length === 0) { - console.log(style.muted('No packages installed.')); + printMuted('No packages installed.'); return; } @@ -34,19 +34,19 @@ export async function packageUpdateCommand(name: string | undefined, opts: Packa const toUpdate: ResolvedPackage[] = []; for (const [id, current] of targets) { if (current.trust === 'experimental') { - console.log(style.muted(` Skipping "${id}" (experimental — re-install with --from-url to update)`)); + printMuted(` Skipping "${id}" (experimental — re-install with --from-url to update)`); continue; } const entry = registry.packages[id]; if (!entry) { - console.log(style.warning(` "${id}" not found in registry — skipping`)); + printWarning(` "${id}" not found in registry — skipping`); continue; } if (entry.source.tag === current.version) { - console.log(style.muted(` ${id} is already up to date (${current.version})`)); + printMuted(` ${id} is already up to date (${current.version})`); continue; } - console.log(` ${style.heading(id)}: ${current.version} → ${entry.source.tag}`); + printLine(` ${style.heading(id)}: ${current.version} → ${entry.source.tag}`); toUpdate.push({ id, entry, files: entry.install.files }); } diff --git a/cli/src/commands/plugin/list.ts b/cli/src/commands/plugin/list.ts index c00c431a..6ab98123 100644 --- a/cli/src/commands/plugin/list.ts +++ b/cli/src/commands/plugin/list.ts @@ -1,6 +1,6 @@ import { existsSync } from 'node:fs'; import { findInstalledPluginDirs, readPluginManifest } from '../../lib/plugin-utils.js'; -import { style, table } from '../../lib/output.js'; +import { printBlank, printHeading, printMuted, printTable, style } from '../../lib/output.js'; import { pluginsDir, resolvePluginProjectDir } from './shared.js'; export async function pluginListCommand(dir?: string, installDir = 'feather'): Promise { @@ -8,18 +8,18 @@ export async function pluginListCommand(dir?: string, installDir = 'feather'): P const dirPath = pluginsDir(projectDir, installDir); if (!existsSync(dirPath)) { - console.log(style.muted('No plugins directory found. Run `feather init` first.')); + printMuted('No plugins directory found. Run `feather init` first.'); return; } const dirs = findInstalledPluginDirs(dirPath); if (dirs.length === 0) { - console.log(style.muted('No plugins installed.')); + printMuted('No plugins installed.'); return; } - console.log(style.heading(`\nInstalled plugins (${dirs.length})\n`)); + printHeading(`\nInstalled plugins (${dirs.length})\n`); const rows = dirs.map((dir) => { const meta = readPluginManifest(dir); return { @@ -28,15 +28,13 @@ export async function pluginListCommand(dir?: string, installDir = 'feather'): P name: meta?.name ?? '', }; }); - for (const line of table({ + 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, - })) { - console.log(line); - } - console.log(); + }); + printBlank(); } diff --git a/cli/src/commands/plugin/remove.ts b/cli/src/commands/plugin/remove.ts index 508b72a3..31b135dc 100644 --- a/cli/src/commands/plugin/remove.ts +++ b/cli/src/commands/plugin/remove.ts @@ -1,7 +1,7 @@ import { existsSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { fail } from '../../lib/command.js'; -import { icon, style } from '../../lib/output.js'; +import { icon, printLine, printMuted } from '../../lib/output.js'; import { confirmAction } from '../../ui/confirm.js'; import { pluginsDir, resolvePluginProjectDir } from './shared.js'; @@ -29,11 +29,11 @@ export async function pluginRemoveCommand( rows: [pluginDir], }); if (!confirmed) { - console.log(style.muted('Plugin remove cancelled.')); + printMuted('Plugin remove cancelled.'); return; } } rmSync(pluginDir, { recursive: true, force: true }); - console.log(`${icon.success} Removed ${pluginId}`); + printLine(`${icon.success} Removed ${pluginId}`); } diff --git a/cli/src/commands/plugin/update.ts b/cli/src/commands/plugin/update.ts index e26c2072..323622f8 100644 --- a/cli/src/commands/plugin/update.ts +++ b/cli/src/commands/plugin/update.ts @@ -8,7 +8,7 @@ import { installPluginsFromLocal, } from '../../lib/install.js'; import { fail } from '../../lib/command.js'; -import { createSpinner, style } from '../../lib/output.js'; +import { createSpinner, printMuted } from '../../lib/output.js'; import { resolveLocalLuaRoot } from '../../lib/paths.js'; import { choosePluginUpdateWorkflow } from '../../ui/plugin-workflow.js'; import { getInstalledPluginIds, pluginsDir, resolvePluginProjectDir } from './shared.js'; @@ -26,7 +26,7 @@ export async function pluginUpdateCommand( if (!pluginId && process.stdin.isTTY && !hasExplicitSource) { const installedIds = getInstalledPluginIds(projectDir, installDir); if (installedIds.length === 0) { - console.log(style.muted('No plugins installed.')); + printMuted('No plugins installed.'); return; } @@ -37,7 +37,7 @@ export async function pluginUpdateCommand( if (result.action === 'cancel') return; if (result.action !== 'update' || result.pluginIds.length === 0) { - console.log(style.muted('No plugins selected.')); + printMuted('No plugins selected.'); return; } diff --git a/cli/src/commands/plugin/workflow.ts b/cli/src/commands/plugin/workflow.ts index fec6b3c1..e375673b 100644 --- a/cli/src/commands/plugin/workflow.ts +++ b/cli/src/commands/plugin/workflow.ts @@ -1,4 +1,4 @@ -import { style } from '../../lib/output.js'; +import { printMuted } from '../../lib/output.js'; import { choosePluginWorkflow } from '../../ui/plugin-workflow.js'; import { pluginInstallCommand } from './install.js'; import { pluginListCommand } from './list.js'; @@ -29,7 +29,7 @@ export async function pluginWorkflowCommand(opts: { } if (result.pluginIds.length === 0) { - console.log(style.muted('No plugins selected.')); + printMuted('No plugins selected.'); return; } diff --git a/cli/src/commands/remove.ts b/cli/src/commands/remove.ts index d7e6587f..27347477 100644 --- a/cli/src/commands/remove.ts +++ b/cli/src/commands/remove.ts @@ -4,7 +4,7 @@ import { normalizeInstallDir } from "../lib/install.js"; import { chooseRemoveWorkflow, type RemoveTarget } from "../ui/remove-workflow.js"; import { parseManagedValue } from "../lib/plugin-utils.js"; import { fail } from "../lib/command.js"; -import { icon, style } from "../lib/output.js"; +import { icon, printLine, printMuted, style } from "../lib/output.js"; export interface RemoveOptions { yes?: boolean; @@ -145,7 +145,7 @@ export async function removeCommand(dir: string, opts: RemoveOptions): Promise | undefined, }); - console.log(style.info("Feather run")); - for (const row of keyValueRows([ + printInfo("Feather run"); + printKeyValues([ ["Game", absGame], ["Shim", shim.dir], ["Args", opts.gameArgs?.join(" ")], - ])) { - console.log(row); - } + ]); const env = shimEnv(absGame, sessionName); diff --git a/cli/src/commands/update.ts b/cli/src/commands/update.ts index fa328530..d866393a 100644 --- a/cli/src/commands/update.ts +++ b/cli/src/commands/update.ts @@ -4,7 +4,7 @@ import { fetchManifest, installCore, installCoreFromLocal, normalizeInstallDir } import { chooseCoreUpdateWorkflow } from "../ui/update-workflow.js"; import { resolveLocalLuaRoot } from "../lib/paths.js"; import { fail } from "../lib/command.js"; -import { createSpinner, style } from "../lib/output.js"; +import { createSpinner, printLine, printMuted, style } from "../lib/output.js"; export async function updateCommand( dir: string, @@ -24,7 +24,7 @@ export async function updateCommand( if (!hasExplicitSource && process.stdin.isTTY) { const result = await chooseCoreUpdateWorkflow(branch); if (result.cancelled) { - console.log(style.muted("Update cancelled.")); + printMuted("Update cancelled."); return; } useRemote = result.source === "remote"; @@ -44,7 +44,7 @@ export async function updateCommand( fail((err as Error).message, { cause: err, silent: true }); } - console.log(`\n${style.heading("Done!")} Feather core is up to date.\n`); + printLine(`\n${style.heading("Done!")} Feather core is up to date.\n`); return; } @@ -70,5 +70,5 @@ export async function updateCommand( fail((err as Error).message, { cause: err, silent: true }); } - console.log(`\n${style.heading("Done!")} Feather core is up to date.\n`); + printLine(`\n${style.heading("Done!")} Feather core is up to date.\n`); } diff --git a/cli/src/lib/output.ts b/cli/src/lib/output.ts index 16233145..8baab376 100644 --- a/cli/src/lib/output.ts +++ b/cli/src/lib/output.ts @@ -30,6 +30,46 @@ export function printJson(value: unknown): void { console.log(JSON.stringify(value, null, 2)); } +export function printLine(message = ''): void { + console.log(message); +} + +export function printBlank(): void { + printLine(); +} + +export function printLines(lines: string[]): void { + for (const line of lines) printLine(line); +} + +export function printHeading(message: string): void { + printLine(heading(message)); +} + +export function printStatus(kind: keyof typeof icon, message: string): void { + printLine(statusLine(kind, message)); +} + +export function printMuted(message: string): void { + printLine(style.muted(message)); +} + +export function printWarning(message: string): void { + printLine(style.warning(message)); +} + +export function printDanger(message: string): void { + printLine(style.danger(message)); +} + +export function printSuccess(message: string): void { + printLine(style.success(message)); +} + +export function printInfo(message: string): void { + printLine(style.info(message)); +} + export function section(title: string, lines: string[] = []): string[] { return ['', heading(title), ...lines, '']; } @@ -60,6 +100,10 @@ export function keyValueRows(rows: Array<[string, string | number | undefined | return visible.map(([key, value]) => ` ${style.muted(key.padEnd(width))} ${value}`); } +export function printKeyValues(rows: Array<[string, string | number | undefined | null]>): void { + printLines(keyValueRows(rows)); +} + export function table>(input: { columns: Array<{ key: keyof Row; label: string; color?: (value: string, row: Row) => string }>; rows: Row[]; @@ -91,3 +135,11 @@ export function table>(i return lines; } + +export function printTable>(input: { + columns: Array<{ key: keyof Row; label: string; color?: (value: string, row: Row) => string }>; + rows: Row[]; + indent?: string; +}): void { + printLines(table(input)); +} diff --git a/cli/test/package.test.mjs b/cli/test/package.test.mjs index 83272910..4f0bb5dd 100644 --- a/cli/test/package.test.mjs +++ b/cli/test/package.test.mjs @@ -11,7 +11,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; @@ -64,6 +64,14 @@ end ); } +function sourceFiles(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return sourceFiles(path); + return /\.(ts|tsx)$/.test(entry.name) ? [path] : []; + }); +} + test('search: no query lists all registry packages', () => { const { stdout, exitCode } = run(['package', 'search', '--offline']); assert.equal(exitCode, 0); @@ -607,6 +615,24 @@ test('output: NO_COLOR keeps package search readable without ANSI escapes', () = assert.equal(/\x1B\[[0-?]*[ -/]*[@-~]/.test(stdout), false); }); +test('output: command and lib sources route terminal writes through output helpers', () => { + const allowed = new Set([ + join(ROOT, 'cli', 'src', 'lib', 'output.ts'), + join(ROOT, 'cli', 'src', 'lib', 'command.ts'), + ]); + const files = [ + ...sourceFiles(join(ROOT, 'cli', 'src', 'commands')), + ...sourceFiles(join(ROOT, 'cli', 'src', 'lib')), + ].filter((file) => !allowed.has(file)); + + const offenders = files.flatMap((file) => { + const source = readFileSync(file, 'utf8'); + return source.match(/console\.(?:log|error)\s*\(/) ? [file.replace(`${ROOT}/`, '')] : []; + }); + + assert.deepEqual(offenders, []); +}); + test('update: url package is skipped with experimental message', () => { const dir = makeTmp(); writeUrlLock(dir, 'my-helper', [ @@ -814,6 +840,17 @@ test('doctor --json reports package audit problems and unsafe config flags', () assert.equal(labels.get('Disk logging').severity, 'warn'); }); +test('doctor: human report includes grouped checks and summary', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test" }\n'); + const result = run(['doctor', dir]); + assert.match(result.stdout, /Feather doctor/); + assert.match(result.stdout, /Environment/); + assert.match(result.stdout, /Project/); + assert.match(result.stdout, /passed, .* warnings, .* failures/); +}); + async function withFetchMock(mock, runTest) { const originalFetch = globalThis.fetch; globalThis.fetch = mock; From 985c4b4721acc84158e25b482dde48eca1eac1ba Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 01:03:07 -0400 Subject: [PATCH 29/68] cli: improve lockfile --- cli/src/lib/package/add-plan.ts | 2 + cli/src/lib/package/custom-add.ts | 19 ++- cli/src/lib/package/lockfile.ts | 47 +++++++- cli/src/ui/package-add.tsx | 1 + cli/test/package.test.mjs | 184 ++++++++++++++++++++++++++++-- 5 files changed, 240 insertions(+), 13 deletions(-) diff --git a/cli/src/lib/package/add-plan.ts b/cli/src/lib/package/add-plan.ts index 19dc48bf..7065b8e9 100644 --- a/cli/src/lib/package/add-plan.ts +++ b/cli/src/lib/package/add-plan.ts @@ -14,6 +14,7 @@ export type PackageAddRepoPlan = { requirePath: string; repoName: string; tag: string; + commitSha: string; baseUrl: string; selectedFiles: string[]; targetMap: Record; @@ -45,6 +46,7 @@ export function toCustomRepoPackageInput(input: { id: input.plan.id, repoName: input.plan.repoName, tag: input.plan.tag, + commitSha: input.plan.commitSha, baseUrl: input.plan.baseUrl, selectedFiles: input.plan.selectedFiles, targetMap: input.plan.targetMap, diff --git a/cli/src/lib/package/custom-add.ts b/cli/src/lib/package/custom-add.ts index 369ebb2b..bc773162 100644 --- a/cli/src/lib/package/custom-add.ts +++ b/cli/src/lib/package/custom-add.ts @@ -21,6 +21,7 @@ export type CustomRepoPackageInput = { id: string; repoName: string; tag: string; + commitSha?: string; baseUrl: string; selectedFiles: string[]; targetMap: Record; @@ -29,6 +30,8 @@ export type CustomRepoPackageInput = { onFileStart?: (name: string) => void; }; +const COMMIT_SHA_RE = /^[a-f0-9]{40}$/i; + export type CustomUrlFileInput = { name: string; url: string; @@ -55,6 +58,10 @@ function validateTargets(projectDir: string, files: Array<{ target: string }>): } export async function installCustomRepoPackage(input: CustomRepoPackageInput): Promise { + if (input.commitSha !== undefined && !COMMIT_SHA_RE.test(input.commitSha)) { + return { ok: false, files: [], error: "commitSha must be a 40-character SHA" }; + } + const plannedFiles = input.selectedFiles.map((name) => ({ name, target: input.targetMap[name] ?? name, @@ -87,7 +94,11 @@ export async function installCustomRepoPackage(input: CustomRepoPackageInput): P addToLockfile(input.lockfile, input.id, { version: input.tag, trust: "experimental", - source: { repo: input.repoName, tag: input.tag }, + source: { + repo: input.repoName, + tag: input.tag, + ...(input.commitSha ? { resolvedRef: input.commitSha, commitSha: input.commitSha } : {}), + }, files: lockedFiles, }); writeLockfile(input.projectDir, input.lockfile); @@ -120,7 +131,11 @@ export async function installCustomUrlPackage(input: CustomUrlPackageInput): Pro addToLockfile(input.lockfile, input.id, { version: "url", trust: "experimental", - source: { url: input.urlFiles[0]!.url }, + source: { + kind: "url", + url: input.urlFiles[0]!.url, + urls: input.urlFiles.map((file) => file.url), + }, files: lockedFiles, }); writeLockfile(input.projectDir, input.lockfile); diff --git a/cli/src/lib/package/lockfile.ts b/cli/src/lib/package/lockfile.ts index b6ce0c3d..dd62e7f7 100644 --- a/cli/src/lib/package/lockfile.ts +++ b/cli/src/lib/package/lockfile.ts @@ -1,13 +1,24 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +export type LockfileRepoSource = { + repo: string; + tag: string; + commitSha?: string; + resolvedRef?: string; +}; + +export type LockfileUrlSource = { + kind?: "url"; + url: string; + urls?: string[]; +}; + export type LockfileEntry = { parent?: string; version: string; trust: "verified" | "known" | "experimental"; - source: - | { repo: string; tag: string } - | { url: string }; + source: LockfileRepoSource | LockfileUrlSource; files: { name: string; url?: string; target: string; sha256: string }[]; installedAt: string; }; @@ -19,6 +30,35 @@ export type Lockfile = { }; const LOCKFILE_NAME = "feather.lock.json"; +const COMMIT_SHA_RE = /^[a-f0-9]{40}$/i; + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function validateLockfileSource(source: unknown): void { + if (!isObject(source)) throw new Error("Lockfile source must be an object"); + + if ("repo" in source) { + if (typeof source.repo !== "string" || !source.repo) throw new Error("Lockfile source.repo must be a string"); + if (typeof source.tag !== "string" || !source.tag) throw new Error("Lockfile source.tag must be a string"); + if (source.commitSha !== undefined && (typeof source.commitSha !== "string" || !COMMIT_SHA_RE.test(source.commitSha))) { + throw new Error("Lockfile source.commitSha must be a 40-character SHA"); + } + if (source.resolvedRef !== undefined && typeof source.resolvedRef !== "string") { + throw new Error("Lockfile source.resolvedRef must be a string"); + } + return; + } + + if (typeof source.url !== "string" || !source.url) throw new Error("Lockfile source.url must be a string"); + if (source.kind !== undefined && source.kind !== "url") throw new Error('Lockfile source.kind must be "url"'); + if (source.urls !== undefined) { + if (!Array.isArray(source.urls) || source.urls.length === 0 || source.urls.some((url) => typeof url !== "string" || !url)) { + throw new Error("Lockfile source.urls must be non-empty strings"); + } + } +} export function lockfilePath(projectDir: string): string { return join(projectDir, LOCKFILE_NAME); @@ -46,6 +86,7 @@ export function addToLockfile( id: string, entry: Omit ): void { + validateLockfileSource(entry.source); lockfile.packages[id] = { ...entry, installedAt: new Date().toISOString() }; } diff --git a/cli/src/ui/package-add.tsx b/cli/src/ui/package-add.tsx index d4f1a720..f89c02b8 100644 --- a/cli/src/ui/package-add.tsx +++ b/cli/src/ui/package-add.tsx @@ -264,6 +264,7 @@ function Wizard({ lockfile, onPlan }: { lockfile: Lockfile; onPlan: (plan: Packa requirePath, repoName, tag, + commitSha, baseUrl, selectedFiles, targetMap, diff --git a/cli/test/package.test.mjs b/cli/test/package.test.mjs index 4f0bb5dd..71b38002 100644 --- a/cli/test/package.test.mjs +++ b/cli/test/package.test.mjs @@ -902,13 +902,15 @@ function initSetupState(overrides = {}) { test('package add: repo plan converts to custom install input', async () => { const { packageAddPlanFiles, toCustomRepoPackageInput } = await import('../dist/lib/package/add-plan.js'); const lockfile = emptyLockfile(); + const commitSha = '0123456789abcdef0123456789abcdef01234567'; const plan = { kind: 'repo', id: 'my-pkg', requirePath: 'lib.my-pkg.init', repoName: 'me/pkg', tag: 'v1.0.0', - baseUrl: 'https://raw.githubusercontent.com/me/pkg/abc123/', + commitSha, + baseUrl: `https://raw.githubusercontent.com/me/pkg/${commitSha}/`, selectedFiles: ['init.lua', 'util.lua'], targetMap: { 'init.lua': 'lib/my-pkg/init.lua', 'util.lua': 'lib/my-pkg/util.lua' }, }; @@ -921,7 +923,8 @@ test('package add: repo plan converts to custom install input', async () => { id: 'my-pkg', repoName: 'me/pkg', tag: 'v1.0.0', - baseUrl: 'https://raw.githubusercontent.com/me/pkg/abc123/', + commitSha, + baseUrl: `https://raw.githubusercontent.com/me/pkg/${commitSha}/`, selectedFiles: ['init.lua', 'util.lua'], targetMap: { 'init.lua': 'lib/my-pkg/init.lua', 'util.lua': 'lib/my-pkg/util.lua' }, projectDir: '/tmp/game', @@ -969,6 +972,7 @@ test('package add: failed plan install does not write lockfile', async () => { requirePath: 'lib.my-pkg.init', repoName: 'me/pkg', tag: 'v1.0.0', + commitSha: '0123456789abcdef0123456789abcdef01234567', baseUrl: 'https://raw.githubusercontent.com/me/pkg/abc123/', selectedFiles: ['init.lua'], targetMap: { 'init.lua': 'lib/my-pkg/init.lua' }, @@ -1020,9 +1024,10 @@ test('init mode: config builder preserves cli and advanced setup values', async test('custom add: repo install writes selected files and lockfile metadata', async () => { const dir = makeTmp(); const { installCustomRepoPackage } = await import('../dist/lib/package/custom-add.js'); + const commitSha = '0123456789abcdef0123456789abcdef01234567'; const files = new Map([ - ['https://raw.githubusercontent.com/me/pkg/abc123/init.lua', 'return "init"'], - ['https://raw.githubusercontent.com/me/pkg/abc123/util.lua', 'return "util"'], + [`https://raw.githubusercontent.com/me/pkg/${commitSha}/init.lua`, 'return "init"'], + [`https://raw.githubusercontent.com/me/pkg/${commitSha}/util.lua`, 'return "util"'], ]); await withFetchMock( @@ -1035,7 +1040,8 @@ test('custom add: repo install writes selected files and lockfile metadata', asy id: 'my-pkg', repoName: 'me/pkg', tag: 'v1.0.0', - baseUrl: 'https://raw.githubusercontent.com/me/pkg/abc123/', + commitSha, + baseUrl: `https://raw.githubusercontent.com/me/pkg/${commitSha}/`, selectedFiles: ['init.lua', 'util.lua'], targetMap: { 'init.lua': 'lib/my-pkg/init.lua', 'util.lua': 'lib/my-pkg/util.lua' }, projectDir: dir, @@ -1049,9 +1055,9 @@ test('custom add: repo install writes selected files and lockfile metadata', asy const lock = JSON.parse(readFileSync(join(dir, 'feather.lock.json'), 'utf8')); assert.equal(lock.packages['my-pkg'].version, 'v1.0.0'); assert.equal(lock.packages['my-pkg'].trust, 'experimental'); - assert.deepEqual(lock.packages['my-pkg'].source, { repo: 'me/pkg', tag: 'v1.0.0' }); + assert.deepEqual(lock.packages['my-pkg'].source, { repo: 'me/pkg', tag: 'v1.0.0', resolvedRef: commitSha, commitSha }); assert.equal(lock.packages['my-pkg'].files.length, 2); - assert.equal(lock.packages['my-pkg'].files[0].url, 'https://raw.githubusercontent.com/me/pkg/abc123/init.lua'); + assert.equal(lock.packages['my-pkg'].files[0].url, `https://raw.githubusercontent.com/me/pkg/${commitSha}/init.lua`); }, ); }); @@ -1060,6 +1066,7 @@ test('custom add: URL install writes buffered files and lockfile metadata', asyn const dir = makeTmp(); const { installCustomUrlPackage } = await import('../dist/lib/package/custom-add.js'); const buffer = Buffer.from('return "helper"'); + const otherBuffer = Buffer.from('return "other"'); const result = await installCustomUrlPackage({ id: 'my-helper', urlFiles: [ @@ -1070,6 +1077,13 @@ test('custom add: URL install writes buffered files and lockfile metadata', asyn target: 'lib/helper.lua', buffer, }, + { + name: 'other.lua', + url: 'https://example.com/other.lua', + sha256: 'stale-sha-is-recomputed', + target: 'lib/other.lua', + buffer: otherBuffer, + }, ], projectDir: dir, lockfile: emptyLockfile(), @@ -1080,8 +1094,162 @@ test('custom add: URL install writes buffered files and lockfile metadata', asyn const lock = JSON.parse(readFileSync(join(dir, 'feather.lock.json'), 'utf8')); assert.equal(lock.packages['my-helper'].version, 'url'); assert.equal(lock.packages['my-helper'].trust, 'experimental'); - assert.deepEqual(lock.packages['my-helper'].source, { url: 'https://example.com/helper.lua' }); + assert.deepEqual(lock.packages['my-helper'].source, { + kind: 'url', + url: 'https://example.com/helper.lua', + urls: ['https://example.com/helper.lua', 'https://example.com/other.lua'], + }); assert.equal(lock.packages['my-helper'].files[0].sha256, sha256(buffer)); + assert.equal(lock.packages['my-helper'].files[1].url, 'https://example.com/other.lua'); +}); + +test('custom add: lockfile source validation rejects malformed optional provenance', async () => { + const { validateLockfileSource } = await import('../dist/lib/package/lockfile.js'); + + assert.throws( + () => validateLockfileSource({ repo: 'me/pkg', tag: 'main', commitSha: 'abc123' }), + /commitSha/, + ); + assert.throws( + () => validateLockfileSource({ kind: 'url', url: 'https://example.com/helper.lua', urls: [] }), + /source\.urls/, + ); + assert.throws( + () => validateLockfileSource({ kind: 'url', url: 'https://example.com/helper.lua', urls: [''] }), + /source\.urls/, + ); +}); + +test('custom add: invalid repo commit provenance is rejected before fetch or write', async () => { + const dir = makeTmp(); + const { installCustomRepoPackage } = await import('../dist/lib/package/custom-add.js'); + let fetchCalled = false; + + await withFetchMock( + async () => { + fetchCalled = true; + return new Response('return {}'); + }, + async () => { + const result = await installCustomRepoPackage({ + id: 'bad-sha', + repoName: 'me/pkg', + tag: 'main', + commitSha: 'abc123', + baseUrl: 'https://raw.githubusercontent.com/me/pkg/abc123/', + selectedFiles: ['init.lua'], + targetMap: { 'init.lua': 'lib/init.lua' }, + projectDir: dir, + lockfile: emptyLockfile(), + }); + + assert.equal(result.ok, false); + assert.equal(fetchCalled, false); + assert.match(result.error, /commitSha/); + assert.equal(existsSync(join(dir, 'feather.lock.json')), false); + }, + ); +}); + +test('restore: old url source-only lockfiles remain compatible', async () => { + const dir = makeTmp(); + const { restorePackage } = await import('../dist/lib/package/install.js'); + const content = 'return "old url"'; + + await withFetchMock( + async (url) => { + assert.equal(String(url), 'https://example.com/helper.lua'); + return new Response(content); + }, + async () => { + const result = await restorePackage( + 'my-helper', + { + version: 'url', + trust: 'experimental', + source: { url: 'https://example.com/helper.lua' }, + files: [{ name: 'helper.lua', target: 'lib/helper.lua', sha256: sha256(content) }], + installedAt: new Date(0).toISOString(), + }, + { projectDir: dir }, + ); + + assert.equal(result.ok, true); + assert.equal(readFileSync(join(dir, 'lib', 'helper.lua'), 'utf8'), content); + }, + ); +}); + +test('restore: enriched url lockfiles still prefer per-file URLs', async () => { + const dir = makeTmp(); + const { restorePackage } = await import('../dist/lib/package/install.js'); + const files = new Map([ + ['https://example.com/a.lua', 'return "a"'], + ['https://example.com/b.lua', 'return "b"'], + ]); + const fetched = []; + + await withFetchMock( + async (url) => { + fetched.push(String(url)); + const body = files.get(String(url)); + return body === undefined ? new Response('missing', { status: 404 }) : new Response(body); + }, + async () => { + const result = await restorePackage( + 'mypkg', + { + version: 'url', + trust: 'experimental', + source: { + kind: 'url', + url: 'https://example.com/primary.lua', + urls: ['https://example.com/a.lua', 'https://example.com/b.lua'], + }, + files: [ + { name: 'a.lua', url: 'https://example.com/a.lua', target: 'lib/mypkg/a.lua', sha256: sha256('return "a"') }, + { name: 'b.lua', url: 'https://example.com/b.lua', target: 'lib/mypkg/b.lua', sha256: sha256('return "b"') }, + ], + installedAt: new Date(0).toISOString(), + }, + { projectDir: dir }, + ); + + assert.equal(result.ok, true); + assert.deepEqual(fetched, ['https://example.com/a.lua', 'https://example.com/b.lua']); + assert.equal(readFileSync(join(dir, 'lib', 'mypkg', 'a.lua'), 'utf8'), 'return "a"'); + assert.equal(readFileSync(join(dir, 'lib', 'mypkg', 'b.lua'), 'utf8'), 'return "b"'); + }, + ); +}); + +test('restore: old repo lockfiles without commitSha remain compatible', async () => { + const dir = makeTmp(); + const { restorePackage } = await import('../dist/lib/package/install.js'); + const content = 'return "repo"'; + + await withFetchMock( + async (url) => { + assert.equal(String(url), 'https://raw.githubusercontent.com/me/pkg/main/init.lua'); + return new Response(content); + }, + async () => { + const result = await restorePackage( + 'my-pkg', + { + version: 'main', + trust: 'experimental', + source: { repo: 'me/pkg', tag: 'main' }, + files: [{ name: 'init.lua', target: 'lib/init.lua', sha256: sha256(content) }], + installedAt: new Date(0).toISOString(), + }, + { projectDir: dir }, + ); + + assert.equal(result.ok, true); + assert.equal(readFileSync(join(dir, 'lib', 'init.lua'), 'utf8'), content); + }, + ); }); test('custom add: escaping target is rejected before fetch or write', async () => { From 9dec2f17b021a0a7dff1d0b112be01bcd16a8548 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 01:08:52 -0400 Subject: [PATCH 30/68] cli: ui refactor --- cli/src/commands/init.ts | 2 +- cli/src/commands/package/add.ts | 2 +- cli/src/commands/package/install.ts | 2 +- cli/src/commands/package/list.ts | 3 +-- cli/src/commands/package/update.ts | 2 +- cli/src/index.ts | 2 +- cli/src/ui/init-mode.tsx | 2 -- cli/src/ui/{init-mode-config.ts => init/config.ts} | 2 +- cli/src/ui/init/index.ts | 2 ++ cli/src/ui/{init-mode-model.ts => init/model.ts} | 2 +- .../ui/{init-mode-prompts.tsx => init/prompts.tsx} | 2 +- .../ui/{init-mode-summary.tsx => init/summary.tsx} | 4 ++-- .../{init-mode-workflow.tsx => init/workflow.tsx} | 10 +++++----- .../add-helpers.ts} | 2 +- .../add-steps.tsx} | 4 ++-- cli/src/ui/{package-add.tsx => package/add.tsx} | 14 +++++++------- cli/src/ui/package/index.ts | 3 +++ .../{package-progress.tsx => package/progress.tsx} | 12 ++++++------ .../{package-workflow.tsx => package/workflow.tsx} | 6 +++--- cli/test/package.test.mjs | 2 +- 20 files changed, 41 insertions(+), 39 deletions(-) delete mode 100644 cli/src/ui/init-mode.tsx rename cli/src/ui/{init-mode-config.ts => init/config.ts} (99%) create mode 100644 cli/src/ui/init/index.ts rename cli/src/ui/{init-mode-model.ts => init/model.ts} (98%) rename cli/src/ui/{init-mode-prompts.tsx => init/prompts.tsx} (99%) rename cli/src/ui/{init-mode-summary.tsx => init/summary.tsx} (96%) rename cli/src/ui/{init-mode-workflow.tsx => init/workflow.tsx} (98%) rename cli/src/ui/{package-add-helpers.ts => package/add-helpers.ts} (96%) rename cli/src/ui/{package-add-steps.tsx => package/add-steps.tsx} (97%) rename cli/src/ui/{package-add.tsx => package/add.tsx} (95%) create mode 100644 cli/src/ui/package/index.ts rename cli/src/ui/{package-progress.tsx => package/progress.tsx} (94%) rename cli/src/ui/{package-workflow.tsx => package/workflow.tsx} (97%) diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index 1b5d72df..be8cf6ed 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -11,7 +11,7 @@ import { normalizeInstallDir, } from '../lib/install.js'; import { configTemplate, luaKey, luaValue } from '../lib/config.js'; -import { chooseInitMode, type InitMode, type InitSetup } from '../ui/init-mode.js'; +import { chooseInitMode, type InitMode, type InitSetup } from '../ui/init/index.js'; import { pluginCatalog } from '../generated/plugin-catalog.js'; import { resolveLocalLuaRoot } from '../lib/paths.js'; import { fail } from '../lib/command.js'; diff --git a/cli/src/commands/package/add.ts b/cli/src/commands/package/add.ts index 962a8916..a3e3c783 100644 --- a/cli/src/commands/package/add.ts +++ b/cli/src/commands/package/add.ts @@ -2,7 +2,7 @@ import { fail } from '../../lib/command.js'; import { installPackageAddPlan, packageAddPlanFiles } from '../../lib/package/add-plan.js'; import { readLockfile } from '../../lib/package/lockfile.js'; import { createSpinner, printBlank, printKeyValues, printMuted, style } from '../../lib/output.js'; -import { showAddWizard } from '../../ui/package-add.js'; +import { showAddWizard } from '../../ui/package/index.js'; import { ensurePackageAddInteractive, resolvePackageProjectDir } from './shared.js'; export type PackageAddOptions = { diff --git a/cli/src/commands/package/install.ts b/cli/src/commands/package/install.ts index 4c4e5bea..5140d864 100644 --- a/cli/src/commands/package/install.ts +++ b/cli/src/commands/package/install.ts @@ -18,7 +18,7 @@ import { } from '../../lib/output.js'; import { trustBadge } from '../../lib/trust.js'; import { confirmAction } from '../../ui/confirm.js'; -import { showInstallProgress } from '../../ui/package-progress.js'; +import { showInstallProgress } from '../../ui/package/index.js'; import { loadRegistryOrExit, resolvePackageProjectDir } from './shared.js'; export type PackageInstallOptions = { diff --git a/cli/src/commands/package/list.ts b/cli/src/commands/package/list.ts index 5fb3c76e..9fc0b966 100644 --- a/cli/src/commands/package/list.ts +++ b/cli/src/commands/package/list.ts @@ -3,8 +3,7 @@ import { resolveMany } from '../../lib/package/resolve.js'; import { fail } from '../../lib/command.js'; import { icon, printLine, printMuted, style } from '../../lib/output.js'; import { trustBadge } from '../../lib/trust.js'; -import { showPackageBrowser } from '../../ui/package-workflow.js'; -import { showInstallProgress } from '../../ui/package-progress.js'; +import { showInstallProgress, showPackageBrowser } from '../../ui/package/index.js'; import { packageRemoveCommand } from './remove.js'; import { loadRegistryOrExit, resolvePackageProjectDir } from './shared.js'; diff --git a/cli/src/commands/package/update.ts b/cli/src/commands/package/update.ts index 04a2ec0d..d02d37e3 100644 --- a/cli/src/commands/package/update.ts +++ b/cli/src/commands/package/update.ts @@ -2,7 +2,7 @@ import { readLockfile, writeLockfile } from '../../lib/package/lockfile.js'; import type { ResolvedPackage } from '../../lib/package/resolve.js'; import { fail } from '../../lib/command.js'; import { printLine, printMuted, printWarning, style } from '../../lib/output.js'; -import { showInstallProgress } from '../../ui/package-progress.js'; +import { showInstallProgress } from '../../ui/package/index.js'; import { loadRegistryOrExit, resolvePackageProjectDir } from './shared.js'; export type PackageUpdateOptions = { diff --git a/cli/src/index.ts b/cli/src/index.ts index 0ba24d09..b7ae0ce6 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -23,7 +23,7 @@ import { packageAuditCommand, packageAddCommand, } from './commands/package.js'; -import type { InitMode } from './ui/init-mode.js'; +import type { InitMode } from './ui/init/index.js'; const program = new Command(); const initModes = new Set(['cli', 'auto', 'manual']); diff --git a/cli/src/ui/init-mode.tsx b/cli/src/ui/init-mode.tsx deleted file mode 100644 index c114d17c..00000000 --- a/cli/src/ui/init-mode.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export type { InitMode, InitSetup } from './init-mode-model.js'; -export { chooseInitMode } from './init-mode-workflow.js'; diff --git a/cli/src/ui/init-mode-config.ts b/cli/src/ui/init/config.ts similarity index 99% rename from cli/src/ui/init-mode-config.ts rename to cli/src/ui/init/config.ts index be19b0a6..6df1cadd 100644 --- a/cli/src/ui/init-mode-config.ts +++ b/cli/src/ui/init/config.ts @@ -4,7 +4,7 @@ import { parseCapabilities, type InitMode, type InitSetup, -} from "./init-mode-model.js"; +} from "./model.js"; export type InitSetupState = { mode: InitMode; diff --git a/cli/src/ui/init/index.ts b/cli/src/ui/init/index.ts new file mode 100644 index 00000000..19ac23fe --- /dev/null +++ b/cli/src/ui/init/index.ts @@ -0,0 +1,2 @@ +export type { InitMode, InitSetup } from './model.js'; +export { chooseInitMode } from './workflow.js'; diff --git a/cli/src/ui/init-mode-model.ts b/cli/src/ui/init/model.ts similarity index 98% rename from cli/src/ui/init-mode-model.ts rename to cli/src/ui/init/model.ts index acea084a..607932de 100644 --- a/cli/src/ui/init-mode-model.ts +++ b/cli/src/ui/init/model.ts @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { pluginCatalog } from "../generated/plugin-catalog.js"; +import { pluginCatalog } from "../../generated/plugin-catalog.js"; export type InitMode = "cli" | "auto" | "manual"; diff --git a/cli/src/ui/init-mode-prompts.tsx b/cli/src/ui/init/prompts.tsx similarity index 99% rename from cli/src/ui/init-mode-prompts.tsx rename to cli/src/ui/init/prompts.tsx index 1dc40363..bf325688 100644 --- a/cli/src/ui/init-mode-prompts.tsx +++ b/cli/src/ui/init/prompts.tsx @@ -1,6 +1,6 @@ import React, { type ReactNode } from "react"; import { Box, Text } from "ink"; -import { toneColor, type Option, type SummaryRow, type Tone } from "./init-mode-model.js"; +import { toneColor, type Option, type SummaryRow, type Tone } from "./model.js"; export function DangerousName({ children }: { children: string }) { return ( diff --git a/cli/src/ui/init-mode-summary.tsx b/cli/src/ui/init/summary.tsx similarity index 96% rename from cli/src/ui/init-mode-summary.tsx rename to cli/src/ui/init/summary.tsx index d9637f1d..26afd482 100644 --- a/cli/src/ui/init-mode-summary.tsx +++ b/cli/src/ui/init/summary.tsx @@ -5,8 +5,8 @@ import { modes, pluginTone, type SummaryRow, -} from "./init-mode-model.js"; -import { DangerousName, NameList } from "./init-mode-prompts.js"; +} from "./model.js"; +import { DangerousName, NameList } from "./prompts.js"; export type InitSummaryInput = { advanced: boolean; diff --git a/cli/src/ui/init-mode-workflow.tsx b/cli/src/ui/init/workflow.tsx similarity index 98% rename from cli/src/ui/init-mode-workflow.tsx rename to cli/src/ui/init/workflow.tsx index 1f9d8996..15b914b5 100644 --- a/cli/src/ui/init-mode-workflow.tsx +++ b/cli/src/ui/init/workflow.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from "react"; import { Box, Text, render, useApp, useInput } from "ink"; -import { copyToClipboard } from "../lib/clipboard.js"; -import { buildInitSetup } from "./init-mode-config.js"; +import { copyToClipboard } from "../../lib/clipboard.js"; +import { buildInitSetup } from "./config.js"; import { configToggles, dangerousInsecureConnection, @@ -19,7 +19,7 @@ import { type InitSetup, type Phase, type SummaryRow, -} from "./init-mode-model.js"; +} from "./model.js"; import { ConfirmPrompt, DangerousName, @@ -29,8 +29,8 @@ import { SingleSelect, SummaryRows, TextInputPrompt, -} from "./init-mode-prompts.js"; -import { buildInitSummaryRows } from "./init-mode-summary.js"; +} from "./prompts.js"; +import { buildInitSummaryRows } from "./summary.js"; function InitSetupPrompt({ defaultMode, diff --git a/cli/src/ui/package-add-helpers.ts b/cli/src/ui/package/add-helpers.ts similarity index 96% rename from cli/src/ui/package-add-helpers.ts rename to cli/src/ui/package/add-helpers.ts index cec8b1ed..063cd9da 100644 --- a/cli/src/ui/package-add-helpers.ts +++ b/cli/src/ui/package/add-helpers.ts @@ -1,4 +1,4 @@ -import { GH_HEADERS } from '../lib/github.js'; +import { GH_HEADERS } from '../../lib/github.js'; export const REPO_TOTAL = 7; export const URL_TOTAL = 4; diff --git a/cli/src/ui/package-add-steps.tsx b/cli/src/ui/package/add-steps.tsx similarity index 97% rename from cli/src/ui/package-add-steps.tsx rename to cli/src/ui/package/add-steps.tsx index 86c287fa..e5eadb0d 100644 --- a/cli/src/ui/package-add-steps.tsx +++ b/cli/src/ui/package/add-steps.tsx @@ -1,6 +1,6 @@ import { Box, Text, useInput } from 'ink'; -import type { UrlFile } from './components.js'; -import { REPO_TOTAL, URL_TOTAL } from './package-add-helpers.js'; +import type { UrlFile } from '../components.js'; +import { REPO_TOTAL, URL_TOTAL } from './add-helpers.js'; export function RepoConfirmStep({ id, diff --git a/cli/src/ui/package-add.tsx b/cli/src/ui/package/add.tsx similarity index 95% rename from cli/src/ui/package-add.tsx rename to cli/src/ui/package/add.tsx index f89c02b8..5e71f8a2 100644 --- a/cli/src/ui/package-add.tsx +++ b/cli/src/ui/package/add.tsx @@ -1,7 +1,7 @@ import { render, Text, Box, useApp } from 'ink'; import { useState } from 'react'; -import type { PackageAddPlan } from '../lib/package/add-plan.js'; -import type { Lockfile } from '../lib/package/lockfile.js'; +import type { PackageAddPlan } from '../../lib/package/add-plan.js'; +import type { Lockfile } from '../../lib/package/lockfile.js'; import { type UrlFile, TextInputStep, @@ -11,11 +11,11 @@ import { TargetsStep, FileFetchStep, FileMoreStep, -} from './components.js'; -import { fetchCommitSha, fetchLuaFiles } from '../lib/github.js'; -import { fileNameFromUrl } from '../lib/url.js'; -import { fetchRepoMeta, REPO_TOTAL, URL_TOTAL } from './package-add-helpers.js'; -import { RepoConfirmStep, UrlConfirmStep } from './package-add-steps.js'; +} from '../components.js'; +import { fetchCommitSha, fetchLuaFiles } from '../../lib/github.js'; +import { fileNameFromUrl } from '../../lib/url.js'; +import { fetchRepoMeta, REPO_TOTAL, URL_TOTAL } from './add-helpers.js'; +import { RepoConfirmStep, UrlConfirmStep } from './add-steps.js'; type Step = | 'choose' diff --git a/cli/src/ui/package/index.ts b/cli/src/ui/package/index.ts new file mode 100644 index 00000000..c4f954b3 --- /dev/null +++ b/cli/src/ui/package/index.ts @@ -0,0 +1,3 @@ +export { showAddWizard } from './add.js'; +export { showInstallProgress } from './progress.js'; +export { showPackageBrowser, type PackageWorkflowResult } from './workflow.js'; diff --git a/cli/src/ui/package-progress.tsx b/cli/src/ui/package/progress.tsx similarity index 94% rename from cli/src/ui/package-progress.tsx rename to cli/src/ui/package/progress.tsx index bdb6d36e..91b540bf 100644 --- a/cli/src/ui/package-progress.tsx +++ b/cli/src/ui/package/progress.tsx @@ -1,11 +1,11 @@ import React, { useState, useEffect } from "react"; import { Box, Text, render, useApp } from "ink"; -import { SPINNER_FRAMES } from "./components.js"; -import { installPackage } from "../lib/package/install.js"; -import { formatRequireHint } from "../lib/package/resolve.js"; -import type { ResolvedPackage } from "../lib/package/resolve.js"; -import type { Lockfile } from "../lib/package/lockfile.js"; -import type { InstallResult } from "../lib/package/install.js"; +import { SPINNER_FRAMES } from "../components.js"; +import { installPackage } from "../../lib/package/install.js"; +import { formatRequireHint } from "../../lib/package/resolve.js"; +import type { ResolvedPackage } from "../../lib/package/resolve.js"; +import type { Lockfile } from "../../lib/package/lockfile.js"; +import type { InstallResult } from "../../lib/package/install.js"; type FileStatus = "pending" | "downloading" | "ok" | "error"; type PkgStatus = "pending" | "installing" | "ok" | "error"; diff --git a/cli/src/ui/package-workflow.tsx b/cli/src/ui/package/workflow.tsx similarity index 97% rename from cli/src/ui/package-workflow.tsx rename to cli/src/ui/package/workflow.tsx index 4870c061..917bcfb3 100644 --- a/cli/src/ui/package-workflow.tsx +++ b/cli/src/ui/package/workflow.tsx @@ -1,8 +1,8 @@ import { useState, useMemo, useEffect } from 'react'; import { Box, Text, render, useApp, useInput } from 'ink'; -import type { Registry, RegistryEntry } from '../lib/package/registry.js'; -import type { Lockfile, LockfileEntry } from '../lib/package/lockfile.js'; -import { trustLabel, trustColor } from '../lib/trust.js'; +import type { Registry, RegistryEntry } from '../../lib/package/registry.js'; +import type { Lockfile, LockfileEntry } from '../../lib/package/lockfile.js'; +import { trustLabel, trustColor } from '../../lib/trust.js'; export type PackageWorkflowResult = { action: 'install' | 'update' | 'remove'; id: string } | { action: 'cancel' }; diff --git a/cli/test/package.test.mjs b/cli/test/package.test.mjs index 71b38002..10309d29 100644 --- a/cli/test/package.test.mjs +++ b/cli/test/package.test.mjs @@ -986,7 +986,7 @@ test('package add: failed plan install does not write lockfile', async () => { }); test('init mode: config builder preserves cli and advanced setup values', async () => { - const { buildInitSetup } = await import('../dist/ui/init-mode-config.js'); + const { buildInitSetup } = await import('../dist/ui/init/config.js'); const setup = buildInitSetup( initSetupState({ mode: 'cli', From c95bac10c785c36e2b8019263bbd0a54c4f26419 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 01:15:46 -0400 Subject: [PATCH 31/68] cli: improve doctor --- cli/src/commands/doctor/checks.ts | 23 ++++++ cli/src/commands/doctor/index.ts | 114 ++++++++++++++++++++++++++---- cli/test/package.test.mjs | 97 ++++++++++++++++++++++++- 3 files changed, 221 insertions(+), 13 deletions(-) diff --git a/cli/src/commands/doctor/checks.ts b/cli/src/commands/doctor/checks.ts index e2e82c59..6bc9aff4 100644 --- a/cli/src/commands/doctor/checks.ts +++ b/cli/src/commands/doctor/checks.ts @@ -1,6 +1,8 @@ import { existsSync, readFileSync } from 'node:fs'; import { createConnection } from 'node:net'; import { spawnSync } from 'node:child_process'; +import { basename } from 'node:path'; +import { readPluginManifest } from '../../lib/plugin-utils.js'; export type Severity = 'pass' | 'warn' | 'fail' | 'info'; @@ -83,7 +85,28 @@ export function hasConfigArrayValue(src: string, key: string, value: string): bo return match ? new RegExp(`["']${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']`).test(match[1]) : false; } +export function configArrayValues(src: string, key: string): string[] { + const match = src.match(new RegExp(`${key}\\s*=\\s*\\{([\\s\\S]*?)\\}`)); + if (!match) return []; + return [...match[1].matchAll(/["']([^"']+)["']/g)].map((item) => item[1]); +} + export function isWeakApiKey(value: unknown): boolean { return typeof value !== 'string' || value.trim().length < 24 || value === 'change-me' || value === 'dev'; } +export function shellArg(value: string): string { + if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) return value; + return `'${value.replaceAll("'", "'\\''")}'`; +} + +export function buildPluginIndex(pluginDirs: string[]): Map { + const plugins = new Map(); + for (const dir of pluginDirs) { + const manifest = readPluginManifest(dir); + if (manifest?.id) { + plugins.set(manifest.id, { dir, name: manifest.name || basename(dir), version: manifest.version || 'unknown' }); + } + } + return plugins; +} diff --git a/cli/src/commands/doctor/index.ts b/cli/src/commands/doctor/index.ts index 2a58301b..09fa453e 100644 --- a/cli/src/commands/doctor/index.ts +++ b/cli/src/commands/doctor/index.ts @@ -7,15 +7,20 @@ import { fail } from '../../lib/command.js'; import { printJson } from '../../lib/output.js'; import { auditLockfile } from '../../lib/package/audit.js'; import { readLockfile } from '../../lib/package/lockfile.js'; +import { loadRegistry } from '../../lib/package/registry.js'; import { parseManagedValue, findInstalledPluginDirs, readPluginManifest } from '../../lib/plugin-utils.js'; +import { pluginCatalog } from '../../generated/plugin-catalog.js'; import { add, + buildPluginIndex, commandVersion, + configArrayValues, hasConfigArrayValue, isWeakApiKey, luaBoolEnabled, portReachable, readIfExists, + shellArg, uncommentedLua, type DoctorCheck, type DoctorOptions, @@ -91,6 +96,10 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) const configPath = join(projectDir, 'feather.config.lua'); const configSource = readIfExists(configPath); + const activeConfigSource = configSource ? uncommentedLua(configSource) : ''; + const includedPluginIds = configSource ? configArrayValues(activeConfigSource, 'include') : []; + const excludedPluginIds = new Set(configSource ? configArrayValues(activeConfigSource, 'exclude') : []); + const activeIncludedPluginIds = [...new Set(includedPluginIds.filter((id) => !excludedPluginIds.has(id)))].sort(); let config: ReturnType = null; if (configSource) { try { @@ -146,6 +155,10 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) const sourceTreePluginRoot = join(projectDir, 'plugins'); const pluginRoot = existsSync(runtimePluginRoot) ? runtimePluginRoot : sourceTreePluginRoot; const pluginDirs = findInstalledPluginDirs(pluginRoot); + const installedPlugins = buildPluginIndex(pluginDirs); + const knownPluginIds = new Set(pluginCatalog.map((plugin) => plugin.id)); + const projectDirArg = shellArg(projectDir); + const installDirArg = shellArg(effectiveInstallDir); if (hasRuntime) { add( checks, @@ -158,12 +171,44 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) add(checks, 'Plugins', 'Installed plugins', pluginDirs.length > 0 ? 'pass' : 'info', `${pluginDirs.length}`); const malformed = pluginDirs.filter((dir) => !readPluginManifest(dir)?.id); if (malformed.length > 0) { - add(checks, 'Plugins', 'Plugin manifests', 'warn', `${malformed.length} missing id`, 'Reinstall affected plugins with `feather plugin update`.'); + add( + checks, + 'Plugins', + 'Plugin manifests', + 'warn', + `${malformed.length} missing id`, + `Run \`feather plugin update --dir ${projectDirArg} --install-dir ${installDirArg} --yes\`.`, + ); } else if (pluginDirs.length > 0) { add(checks, 'Plugins', 'Plugin manifests', 'pass', 'all installed plugins declare an id'); } } + for (const id of activeIncludedPluginIds) { + if (!knownPluginIds.has(id)) { + add( + checks, + 'Plugins', + `Plugin ${id}`, + 'warn', + 'included but unknown', + `Remove or correct "${id}" in feather.config.lua include.`, + ); + continue; + } + + if (!installedPlugins.has(id)) { + add( + checks, + 'Plugins', + `Plugin ${id}`, + 'warn', + 'included but not installed', + `Run \`feather plugin install ${id} --dir ${projectDirArg} --install-dir ${installDirArg}\`.`, + ); + } + } + if (mainSource) { const hasUseDebugger = mainSource.includes('USE_DEBUGGER'); const hasInitMarkers = mainSource.includes('FEATHER-INIT-BEGIN') && mainSource.includes('FEATHER-INIT-END'); @@ -186,7 +231,6 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) } if (configSource) { - const activeConfigSource = uncommentedLua(configSource); const insecureConnection = luaBoolEnabled(activeConfigSource, '__DANGEROUS_INSECURE_CONNECTION__'); add( checks, @@ -194,7 +238,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) '__DANGEROUS_INSECURE_CONNECTION__', insecureConnection ? 'warn' : 'pass', insecureConnection ? 'enabled' : 'disabled', - insecureConnection ? 'Set a desktop App ID in feather.config.lua before sharing or shipping this project.' : undefined, + insecureConnection ? 'Set appId in feather.config.lua and remove __DANGEROUS_INSECURE_CONNECTION__ before sharing or shipping.' : undefined, ); const captureScreenshot = luaBoolEnabled(activeConfigSource, 'captureScreenshot'); @@ -204,7 +248,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) 'captureScreenshot', captureScreenshot ? 'warn' : 'pass', captureScreenshot ? 'enabled' : 'disabled', - captureScreenshot ? 'Enable only when you need visual error context; it can affect performance.' : undefined, + captureScreenshot ? 'Set captureScreenshot = false in feather.config.lua unless you need visual error context.' : undefined, ); const hotReloadEnabled = /hotReload\s*=\s*\{[\s\S]*?enabled\s*=\s*true/.test(activeConfigSource); @@ -217,10 +261,10 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) 'Hot reload', hotReloadEnabled ? 'warn' : 'pass', hotReloadEnabled ? 'enabled' : 'disabled', - hotReloadEnabled ? 'Hot reload is development-only remote code execution; keep allowlists narrow and never ship with it on.' : undefined, + hotReloadEnabled ? 'Set debugger.hotReload.enabled = false in feather.config.lua before shipping.' : undefined, ); if (hotReloadEnabled && broadHotReload) { - add(checks, 'Safety', 'Hot reload allowlist', 'warn', 'contains wildcard', 'Prefer exact module names while editing.'); + add(checks, 'Safety', 'Hot reload allowlist', 'warn', 'contains wildcard', 'Replace wildcard hot reload allow entries with exact module names in feather.config.lua.'); } if (hotReloadEnabled && !hotReloadPluginIncluded) { add( @@ -229,11 +273,11 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) 'Hot reload plugin', 'warn', 'not included', - 'Install and include the opt-in `hot-reload` plugin, or remove debugger.hotReload.', + `Run \`feather plugin install hot-reload --dir ${projectDirArg} --install-dir ${installDirArg}\`, or remove debugger.hotReload from feather.config.lua.`, ); } if (hotReloadEnabled && persistToDisk) { - add(checks, 'Safety', 'Hot reload persistence', 'warn', 'persistToDisk=true', 'Persisted patches survive app restarts until restored or cleared.'); + add(checks, 'Safety', 'Hot reload persistence', 'warn', 'persistToDisk=true', 'Set debugger.hotReload.persistToDisk = false in feather.config.lua.'); } const consoleIncluded = hasConfigArrayValue(activeConfigSource, 'include', 'console') || pluginDirs.some((dir) => readPluginManifest(dir)?.id === 'console'); @@ -245,7 +289,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) 'Console API key', isWeakApiKey(apiKey) ? 'warn' : 'pass', isWeakApiKey(apiKey) ? 'missing or weak' : 'configured', - isWeakApiKey(apiKey) ? 'Set a strong per-session or config API key when using Console.' : undefined, + isWeakApiKey(apiKey) ? 'Set apiKey to a strong per-session secret in feather.config.lua when using Console.' : undefined, ); } @@ -256,7 +300,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) 'Step debugger', debuggerEnabled ? 'warn' : 'pass', debuggerEnabled ? 'enabled' : 'disabled', - debuggerEnabled ? 'Enable only for trusted development sessions.' : undefined, + debuggerEnabled ? 'Set debugger = false in feather.config.lua unless this is a trusted development session.' : undefined, ); const writeToDisk = luaBoolEnabled(activeConfigSource, 'writeToDisk'); @@ -266,7 +310,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) 'Disk logging', writeToDisk ? 'warn' : 'pass', writeToDisk ? 'enabled' : 'disabled', - writeToDisk ? 'Review generated log files before committing or sharing the project.' : undefined, + writeToDisk ? 'Set writeToDisk = false in feather.config.lua before committing or sharing the project.' : undefined, ); } @@ -276,6 +320,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) const lockfile = readLockfile(projectDir); const auditResults = await auditLockfile(projectDir, lockfile); const badPackages = new Set(auditResults.filter((result) => result.status !== 'verified').map((result) => result.id)); + const registry = await loadRegistry({ offline: true }); add( checks, 'Packages', @@ -290,8 +335,53 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) 'Package file integrity', badPackages.size === 0 ? 'pass' : 'warn', badPackages.size === 0 ? 'all verified' : `${badPackages.size} package(s) need attention`, - badPackages.size === 0 ? undefined : 'Run `feather package audit`, then `feather package install` to restore missing or modified files.', + badPackages.size === 0 ? undefined : `Run \`feather package install --dir ${projectDirArg}\` to restore missing or modified files.`, ); + for (const id of [...badPackages].sort()) { + const packageResults = auditResults.filter((result) => result.id === id && result.status !== 'verified'); + const missing = packageResults.filter((result) => result.status === 'missing').length; + const modified = packageResults.filter((result) => result.status === 'modified').length; + const details = [ + missing > 0 ? `${missing} missing` : '', + modified > 0 ? `${modified} modified` : '', + ].filter(Boolean).join(', '); + add( + checks, + 'Packages', + `Package ${id} files`, + 'warn', + details, + `Run \`feather package install --dir ${projectDirArg}\`.`, + ); + } + + for (const [id, entry] of Object.entries(lockfile.packages).sort(([a], [b]) => a.localeCompare(b))) { + if (entry.trust === 'experimental') continue; + + const registryEntry = registry.packages[id]; + if (!registryEntry) { + add( + checks, + 'Packages', + `Package ${id} registry`, + 'warn', + 'not found in bundled registry', + `Run \`feather package remove ${id} --dir ${projectDirArg} --yes\`, or reinstall from a trusted source.`, + ); + continue; + } + + if (entry.version !== registryEntry.source.tag) { + add( + checks, + 'Packages', + `Package ${id} version`, + 'warn', + `${entry.version} → ${registryEntry.source.tag}`, + `Run \`feather package update ${id} --dir ${projectDirArg}\`.`, + ); + } + } } catch (err) { add(checks, 'Packages', 'Package lockfile', 'fail', (err as Error).message, 'Fix or delete feather.lock.json and reinstall packages.'); } diff --git a/cli/test/package.test.mjs b/cli/test/package.test.mjs index 10309d29..3b11c3be 100644 --- a/cli/test/package.test.mjs +++ b/cli/test/package.test.mjs @@ -840,14 +840,109 @@ test('doctor --json reports package audit problems and unsafe config flags', () assert.equal(labels.get('Disk logging').severity, 'warn'); }); -test('doctor: human report includes grouped checks and summary', () => { +test('doctor --json reports included plugins missing or unknown', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { include = { "console", "missing-plugin" } }\n'); + + const result = run(['doctor', dir, '--json']); + const parsed = JSON.parse(result.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + const consoleCheck = labels.get('Plugin console'); + const unknownCheck = labels.get('Plugin missing-plugin'); + + assert.equal(consoleCheck.severity, 'warn'); + assert.equal(consoleCheck.detail, 'included but not installed'); + assert.ok(consoleCheck.fix.includes(`feather plugin install console --dir ${dir} --install-dir feather`)); + assert.equal(unknownCheck.severity, 'warn'); + assert.equal(unknownCheck.detail, 'included but unknown'); + assert.ok(unknownCheck.fix.includes('Remove or correct "missing-plugin"')); +}); + +test('doctor --json reports malformed installed plugin manifests with exact update fix', () => { + const dir = makeTmp(); + writeGame(dir); + mkdirSync(join(dir, 'feather', 'plugins', 'bad-plugin'), { recursive: true }); + writeFileSync(join(dir, 'feather', 'init.lua'), 'return {}\n'); + writeFileSync(join(dir, 'feather', 'plugins', 'bad-plugin', 'manifest.lua'), 'return { name = "Bad Plugin" }\n'); + + const result = run(['doctor', dir, '--json']); + const parsed = JSON.parse(result.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + const manifestCheck = labels.get('Plugin manifests'); + + assert.equal(manifestCheck.severity, 'warn'); + assert.equal(manifestCheck.detail, '1 missing id'); + assert.ok(manifestCheck.fix.includes(`feather plugin update --dir ${dir} --install-dir feather --yes`)); +}); + +test('doctor --json reports package file recovery and stale bundled registry versions', () => { const dir = makeTmp(); writeGame(dir); writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test" }\n'); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'anim8.lua'), 'tampered'); + writeLock(dir, { + anim8: { + version: 'v0.0.0', + trust: 'verified', + source: { repo: 'kikito/anim8', tag: 'v0.0.0' }, + files: [{ name: 'anim8.lua', target: 'lib/anim8.lua', sha256: sha256('original') }], + }, + helper: { + version: 'url', + trust: 'experimental', + source: { url: 'https://example.com/helper.lua' }, + files: [{ name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: sha256('expected') }], + }, + }); + + const result = run(['doctor', dir, '--json']); + const parsed = JSON.parse(result.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + + assert.equal(labels.get('Package file integrity').severity, 'warn'); + assert.ok(labels.get('Package file integrity').fix.includes(`feather package install --dir ${dir}`)); + assert.equal(labels.get('Package anim8 files').detail, '1 modified'); + assert.ok(labels.get('Package anim8 files').fix.includes(`feather package install --dir ${dir}`)); + assert.equal(labels.get('Package helper files').detail, '1 missing'); + assert.equal(labels.get('Package anim8 version').severity, 'warn'); + assert.ok(labels.get('Package anim8 version').fix.includes(`feather package update anim8 --dir ${dir}`)); + assert.equal(labels.has('Package helper version'), false); +}); + +test('doctor --json reports non-experimental packages missing from bundled registry', () => { + const dir = makeTmp(); + writeGame(dir); + writeLock(dir, { + 'local-legacy': { + version: 'v1.0.0', + trust: 'known', + source: { repo: 'me/local-legacy', tag: 'v1.0.0' }, + files: [], + }, + }); + + const result = run(['doctor', dir, '--json']); + const parsed = JSON.parse(result.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + const registryCheck = labels.get('Package local-legacy registry'); + + assert.equal(registryCheck.severity, 'warn'); + assert.equal(registryCheck.detail, 'not found in bundled registry'); + assert.ok(registryCheck.fix.includes(`feather package remove local-legacy --dir ${dir} --yes`)); +}); + +test('doctor: human report includes grouped checks and summary', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test", include = { "console" } }\n'); const result = run(['doctor', dir]); assert.match(result.stdout, /Feather doctor/); assert.match(result.stdout, /Environment/); assert.match(result.stdout, /Project/); + assert.match(result.stdout, /Plugin console/); + assert.match(result.stdout, /feather plugin install console/); assert.match(result.stdout, /passed, .* warnings, .* failures/); }); From 5b78a1be0a4a2b79f8cf2e57e3d0e554cc2f77c3 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 01:22:51 -0400 Subject: [PATCH 32/68] cli: add e2e --- cli/package.json | 4 +- cli/src/commands/plugin/list.ts | 3 +- cli/test/commands.test.mjs | 333 ++++++++++++++++++++++++++++++++ package.json | 2 +- 4 files changed, 338 insertions(+), 4 deletions(-) create mode 100644 cli/test/commands.test.mjs diff --git a/cli/package.json b/cli/package.json index 0ad09e6e..c3ea47b6 100644 --- a/cli/package.json +++ b/cli/package.json @@ -35,8 +35,8 @@ "bundle:lua": "bash ../scripts/bundle-lua.sh", "prepack": "npm run bundle:lua && npm run build", "typecheck": "tsc --noEmit", - "test": "node --test test/package.test.mjs", - "test:e2e": "npm run build && node --test test/package.test.mjs", + "test": "node --test test/*.test.mjs", + "test:e2e": "npm run build && node --test test/*.test.mjs", "package:add": "tsx --tsconfig scripts/tsconfig.json scripts/add-package.tsx", "package:add-url": "tsx --tsconfig scripts/tsconfig.json scripts/add-package-url.tsx", "package:update": "tsx --tsconfig scripts/tsconfig.json scripts/update-package.tsx", diff --git a/cli/src/commands/plugin/list.ts b/cli/src/commands/plugin/list.ts index 6ab98123..73f4568c 100644 --- a/cli/src/commands/plugin/list.ts +++ b/cli/src/commands/plugin/list.ts @@ -22,8 +22,9 @@ export async function pluginListCommand(dir?: string, installDir = 'feather'): P printHeading(`\nInstalled plugins (${dirs.length})\n`); const rows = dirs.map((dir) => { const meta = readPluginManifest(dir); + const fallbackId = dir.replace(dirPath, '').replace(/^[/\\]/, ''); return { - id: meta?.id ?? dir.replace(dirPath, '').replace(/^[/\\]/, ''), + id: meta?.id || fallbackId, version: meta?.version ?? '', name: meta?.name ?? '', }; diff --git a/cli/test/commands.test.mjs b/cli/test/commands.test.mjs new file mode 100644 index 00000000..24854772 --- /dev/null +++ b/cli/test/commands.test.mjs @@ -0,0 +1,333 @@ +/* eslint-disable no-undef */ +/** + * Focused compiled-CLI coverage for non-package commands. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const CLI = fileURLToPath(new URL('../dist/index.js', import.meta.url)); +const ROOT = fileURLToPath(new URL('../..', import.meta.url)); +const LOCAL_SRC = join(ROOT, 'src-lua'); +const ANSI_RE = /\x1B\[[0-?]*[ -/]*[@-~]/; +const sha256 = (value) => createHash('sha256').update(value).digest('hex'); + +function makeTmp() { + return mkdtempSync(join(tmpdir(), 'feather-command-test-')); +} + +function run(args, extra = {}) { + const result = spawnSync(process.execPath, [CLI, ...args], { + encoding: 'utf8', + env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, + ...extra, + }); + return { + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + exitCode: result.status ?? 1, + }; +} + +function outputOf(result) { + return `${result.stdout}\n${result.stderr}`; +} + +function writeGame(dir) { + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'main.lua'), + `function love.update(dt) +end + +function love.draw() +end +`, + ); +} + +function writeMinimalRuntime(dir) { + mkdirSync(join(dir, 'feather', 'lib'), { recursive: true }); + writeFileSync(join(dir, 'feather', 'init.lua'), 'FEATHER_VERSION_NAME = "test"\nreturn {}\n'); + writeFileSync(join(dir, 'feather', 'auto.lua'), 'return {}\n'); + writeFileSync(join(dir, 'feather', 'lib', 'ws.lua'), 'return {}\n'); + writeFileSync(join(dir, 'feather', 'plugin_manager.lua'), 'return {}\n'); +} + +function writeLock(dir, packages) { + writeFileSync(join(dir, 'feather.lock.json'), JSON.stringify({ lockfileVersion: 1, packages }, null, 2)); +} + +function writeFakeLove(dir, options = {}) { + const recordPath = options.recordPath ?? join(dir, 'fake-love-record.json'); + const exitCode = options.exitCode ?? 0; + const fakePath = join(dir, 'fake-love.cjs'); + writeFileSync( + fakePath, + `#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +const shimDir = process.argv[2] || ''; +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ + argv: process.argv.slice(2), + env: { + FEATHER_GAME_PATH: process.env.FEATHER_GAME_PATH, + FEATHER_SESSION_NAME: process.env.FEATHER_SESSION_NAME, + }, + shimMainExists: shimDir ? fs.existsSync(path.join(shimDir, 'main.lua')) : false, +}, null, 2)); +process.exit(${JSON.stringify(exitCode)}); +`, + ); + chmodSync(fakePath, 0o755); + return { fakePath, recordPath }; +} + +function parseDoctorJson(dir, extra = []) { + const result = run(['doctor', dir, '--json', ...extra]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + assert.equal(result.stdout.trim().startsWith('{'), true); + return JSON.parse(result.stdout); +} + +test('run: non-interactive missing game path exits with compact error', () => { + const result = run(['run']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Game path is required')); + assert.equal(outputOf(result).includes('Raw mode is not supported'), false); + assert.equal(outputOf(result).includes('Error:'), false); +}); + +test('run: missing game path and missing main.lua render compact errors', () => { + const dir = makeTmp(); + const missingPath = join(dir, 'missing-game'); + const emptyGame = join(dir, 'empty-game'); + mkdirSync(emptyGame, { recursive: true }); + + const missing = run(['run', missingPath]); + assert.equal(missing.exitCode, 1); + assert.ok(outputOf(missing).includes(`Game path not found: ${resolve(missingPath)}`)); + assert.equal(outputOf(missing).includes('Error:'), false); + + const noMain = run(['run', emptyGame]); + assert.equal(noMain.exitCode, 1); + assert.ok(outputOf(noMain).includes(`No main.lua found in: ${resolve(emptyGame)}`)); + assert.equal(outputOf(noMain).includes('Error:'), false); +}); + +test('run: fake love receives shim, args, env, and exit code is propagated', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + const { fakePath, recordPath } = writeFakeLove(dir, { exitCode: 7 }); + + const result = run([ + 'run', + '--love', + fakePath, + '--session-name', + 'Command Test', + '--no-plugins', + '--feather-path', + join(LOCAL_SRC, 'feather'), + gameDir, + '--', + '--level', + '2', + ]); + + assert.equal(result.exitCode, 7); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.equal(record.env.FEATHER_GAME_PATH, resolve(gameDir)); + assert.equal(record.env.FEATHER_SESSION_NAME, 'Command Test'); + assert.equal(record.shimMainExists, true); + assert.equal(existsSync(record.argv[0]), false, 'shim should be cleaned after love exits'); + assert.deepEqual(record.argv.slice(1), ['--level', '2']); +}); + +test('plugin list: missing plugin directory is a clean empty state', () => { + const dir = makeTmp(); + const result = run(['plugin', 'list', dir]); + assert.equal(result.exitCode, 0); + assert.ok(result.stdout.includes('No plugins directory found')); +}); + +test('plugin install: local source copies console manifest', () => { + const dir = makeTmp(); + const result = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + const manifest = readFileSync(join(dir, 'feather', 'plugins', 'console', 'manifest.lua'), 'utf8'); + assert.ok(manifest.includes('id = "console"')); + assert.ok(outputOf(result).includes('Installed console')); +}); + +test('plugin update: explicit local update refreshes damaged files', () => { + const dir = makeTmp(); + run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + const installedInit = join(dir, 'feather', 'plugins', 'console', 'init.lua'); + writeFileSync(installedInit, 'damaged'); + + const result = run(['plugin', 'update', 'console', '--local-src', LOCAL_SRC, '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(readFileSync(installedInit, 'utf8'), readFileSync(join(LOCAL_SRC, 'plugins', 'console', 'init.lua'), 'utf8')); + assert.ok(outputOf(result).includes('Updated console')); +}); + +test('plugin update: local --yes updates all installed plugins without selection', () => { + const dir = makeTmp(); + run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + run(['plugin', 'install', 'hot-reload', '--local-src', LOCAL_SRC, '--dir', dir]); + const consoleInit = join(dir, 'feather', 'plugins', 'console', 'init.lua'); + const hotReloadInit = join(dir, 'feather', 'plugins', 'hot-reload', 'init.lua'); + writeFileSync(consoleInit, 'damaged console'); + writeFileSync(hotReloadInit, 'damaged hot reload'); + + const result = run(['plugin', 'update', '--local-src', LOCAL_SRC, '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(readFileSync(consoleInit, 'utf8'), readFileSync(join(LOCAL_SRC, 'plugins', 'console', 'init.lua'), 'utf8')); + assert.equal(readFileSync(hotReloadInit, 'utf8'), readFileSync(join(LOCAL_SRC, 'plugins', 'hot-reload', 'init.lua'), 'utf8')); +}); + +test('plugin install: unknown local plugin exits 1', () => { + const dir = makeTmp(); + const result = run(['plugin', 'install', 'zzz-missing', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Unknown plugin: zzz-missing')); +}); + +test('plugin list: malformed manifests do not crash and use directory fallback id', () => { + const dir = makeTmp(); + const pluginDir = join(dir, 'feather', 'plugins', 'bad-plugin'); + mkdirSync(pluginDir, { recursive: true }); + writeFileSync(join(pluginDir, 'manifest.lua'), 'return { name = "Bad Plugin", version = "0.0.1" }\n'); + + const result = run(['plugin', 'list', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(result.stdout.includes('bad-plugin')); + assert.ok(result.stdout.includes('Bad Plugin')); +}); + +test('doctor --json remains decoration-free and reports missing plugin directory', () => { + const dir = makeTmp(); + writeGame(dir); + writeMinimalRuntime(dir); + const parsed = parseDoctorJson(dir); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('Plugin directory').severity, 'info'); + assert.equal(labels.get('Plugin directory').detail, 'not installed'); +}); + +test('doctor --json reports malformed plugin manifests with recovery text', () => { + const dir = makeTmp(); + writeGame(dir); + writeMinimalRuntime(dir); + mkdirSync(join(dir, 'feather', 'plugins', 'bad-plugin'), { recursive: true }); + writeFileSync(join(dir, 'feather', 'plugins', 'bad-plugin', 'manifest.lua'), 'return { name = "Bad Plugin" }\n'); + + const parsed = parseDoctorJson(dir); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + const manifestCheck = labels.get('Plugin manifests'); + assert.equal(manifestCheck.severity, 'warn'); + assert.ok(manifestCheck.fix.includes('feather plugin update')); +}); + +test('doctor: human output honors NO_COLOR', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test", include = { "console" } }\n'); + const result = run(['doctor', dir]); + assert.equal(result.exitCode, 0); + assert.equal(ANSI_RE.test(result.stdout), false); + assert.ok(result.stdout.includes('Plugin console')); + assert.ok(result.stdout.includes('feather plugin install console')); +}); + +test('command runtime: unexpected errors render compact stderr and exit 1', async () => { + const { runCliAction } = await import('../dist/lib/command.js'); + const originalError = console.error; + const lines = []; + const previousExitCode = process.exitCode; + const previousDebug = process.env.FEATHER_DEBUG; + delete process.env.FEATHER_DEBUG; + process.exitCode = undefined; + console.error = (line = '') => lines.push(String(line)); + try { + await runCliAction(async () => { + throw new Error('surprise failure'); + }); + assert.equal(process.exitCode, 1); + assert.ok(lines.join('\n').includes('surprise failure')); + assert.equal(lines.join('\n').includes('Error: surprise failure'), false); + } finally { + console.error = originalError; + process.exitCode = previousExitCode; + if (previousDebug === undefined) delete process.env.FEATHER_DEBUG; + else process.env.FEATHER_DEBUG = previousDebug; + } +}); + +test('command runtime: FEATHER_DEBUG includes stack for unexpected errors', async () => { + const { runCliAction } = await import('../dist/lib/command.js'); + const originalError = console.error; + const lines = []; + const previousExitCode = process.exitCode; + const previousDebug = process.env.FEATHER_DEBUG; + process.env.FEATHER_DEBUG = '1'; + process.exitCode = undefined; + console.error = (line = '') => lines.push(String(line)); + try { + await runCliAction(async () => { + throw new Error('debuggable failure'); + }); + assert.equal(process.exitCode, 1); + assert.ok(lines.join('\n').includes('debuggable failure')); + assert.ok(lines.join('\n').includes('Error: debuggable failure')); + } finally { + console.error = originalError; + process.exitCode = previousExitCode; + if (previousDebug === undefined) delete process.env.FEATHER_DEBUG; + else process.env.FEATHER_DEBUG = previousDebug; + } +}); + +test('json commands used by scripts stay parseable and decoration-free', () => { + const dir = makeTmp(); + writeGame(dir); + const content = 'return {}'; + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'helper.lua'), content); + writeLock(dir, { + helper: { + version: 'url', + trust: 'experimental', + source: { url: 'https://example.com/helper.lua' }, + files: [{ name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: sha256(content) }], + }, + }); + + const audit = run(['package', 'audit', '--json', '--dir', dir]); + assert.equal(audit.exitCode, 0); + assert.equal(ANSI_RE.test(audit.stdout), false); + const auditParsed = JSON.parse(audit.stdout); + assert.equal(auditParsed[0].status, 'verified'); + + const doctor = run(['doctor', dir, '--json']); + assert.equal(doctor.exitCode, 0); + assert.equal(ANSI_RE.test(doctor.stdout), false); + assert.equal(doctor.stdout.trim().startsWith('{'), true); + JSON.parse(doctor.stdout); +}); diff --git a/package.json b/package.json index cc3f808e..126b005e 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "scripts": { "cli:build": "npm run build --workspace=cli", "cli:dev": "npm run dev --workspace=cli", - "test:cli:e2e": "npm run cli:build && node cli/test/e2e.mjs && node cli/test/package.test.mjs", + "test:cli:e2e": "npm run cli:build && node cli/test/e2e.mjs && node --test cli/test/*.test.mjs", "test:lua:e2e": "node scripts/lua-e2e.mjs", "test:app:e2e": "playwright test", "test:tauri:e2e": "cargo test --manifest-path src-tauri/Cargo.toml ws_server", From 124bbae048d8f8c518e3caa96ae3bff41a70d96e Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 01:37:09 -0400 Subject: [PATCH 33/68] cli: feather doctor --production --- cli/src/commands/doctor/checks.ts | 25 +++++++++ cli/src/commands/doctor/index.ts | 81 +++++++++++++++++++++++----- cli/src/index.ts | 4 +- cli/test/commands.test.mjs | 87 +++++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 13 deletions(-) diff --git a/cli/src/commands/doctor/checks.ts b/cli/src/commands/doctor/checks.ts index 6bc9aff4..7c35c167 100644 --- a/cli/src/commands/doctor/checks.ts +++ b/cli/src/commands/doctor/checks.ts @@ -19,6 +19,7 @@ export type DoctorOptions = { host?: string; port?: number; json?: boolean; + production?: boolean; }; export const severityOrder: Record = { @@ -80,6 +81,11 @@ export function luaBoolEnabled(src: string, key: string): boolean { return new RegExp(`${key}\\s*=\\s*true\\b`).test(src); } +export function luaStringValue(src: string, key: string): string | null { + const match = src.match(new RegExp(`${key}\\s*=\\s*["']([^"']*)["']`)); + return match?.[1] ?? null; +} + export function hasConfigArrayValue(src: string, key: string, value: string): boolean { const match = src.match(new RegExp(`${key}\\s*=\\s*\\{([\\s\\S]*?)\\}`)); return match ? new RegExp(`["']${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']`).test(match[1]) : false; @@ -95,6 +101,25 @@ export function isWeakApiKey(value: unknown): boolean { return typeof value !== 'string' || value.trim().length < 24 || value === 'change-me' || value === 'dev'; } +export function isLoopbackHost(host: string): boolean { + const normalized = host.trim().toLowerCase(); + return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1'; +} + +export function isWildcardHost(host: string): boolean { + const normalized = host.trim().toLowerCase(); + return normalized === '0.0.0.0' || normalized === '::'; +} + +export function isLanHost(host: string): boolean { + const normalized = host.trim().toLowerCase(); + if (isLoopbackHost(normalized) || isWildcardHost(normalized)) return false; + if (/^10\./.test(normalized)) return true; + if (/^192\.168\./.test(normalized)) return true; + const match = normalized.match(/^172\.(\d+)\./); + return match ? Number(match[1]) >= 16 && Number(match[1]) <= 31 : false; +} + export function shellArg(value: string): string { if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) return value; return `'${value.replaceAll("'", "'\\''")}'`; diff --git a/cli/src/commands/doctor/index.ts b/cli/src/commands/doctor/index.ts index 09fa453e..02c20442 100644 --- a/cli/src/commands/doctor/index.ts +++ b/cli/src/commands/doctor/index.ts @@ -16,8 +16,12 @@ import { commandVersion, configArrayValues, hasConfigArrayValue, + isLanHost, + isLoopbackHost, + isWildcardHost, isWeakApiKey, luaBoolEnabled, + luaStringValue, portReachable, readIfExists, shellArg, @@ -33,6 +37,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) const projectDir = gamePath ? resolve(gamePath) : process.cwd(); const installDir = normalizeInstallDir(opts.installDir ?? 'feather'); const checks: DoctorCheck[] = []; + const productionSeverity = (unsafe: boolean): DoctorCheck['severity'] => unsafe ? (opts.production ? 'fail' : 'warn') : 'pass'; const nodeVersion = process.versions.node; const [major] = nodeVersion.split('.').map(Number); @@ -115,6 +120,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) const managedMode = configSource ? parseManagedValue(configSource, 'mode') : null; const managedInstallDir = configSource ? parseManagedValue(configSource, 'installDir') : null; const effectiveInstallDir = normalizeInstallDir(opts.installDir ?? managedInstallDir ?? installDir); + const mode = typeof config?.mode === 'string' ? config.mode : 'socket'; if (managedMode) { add(checks, 'Project', 'Managed init metadata', 'pass', `mode=${managedMode}, installDir=${managedInstallDir ?? effectiveInstallDir}`); } else if (configSource) { @@ -236,17 +242,28 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) checks, 'Safety', '__DANGEROUS_INSECURE_CONNECTION__', - insecureConnection ? 'warn' : 'pass', + productionSeverity(insecureConnection), insecureConnection ? 'enabled' : 'disabled', insecureConnection ? 'Set appId in feather.config.lua and remove __DANGEROUS_INSECURE_CONNECTION__ before sharing or shipping.' : undefined, ); + const appId = typeof config?.appId === 'string' ? config.appId.trim() : ''; + const appIdMissing = mode !== 'disk' && !appId; + add( + checks, + 'Safety', + 'Desktop App ID', + appIdMissing ? (opts.production ? 'fail' : 'warn') : 'pass', + appIdMissing ? 'missing' : mode === 'disk' ? 'not needed for disk mode' : 'configured', + appIdMissing ? 'Set appId in feather.config.lua before shipping socket/network builds.' : undefined, + ); + const captureScreenshot = luaBoolEnabled(activeConfigSource, 'captureScreenshot'); add( checks, 'Safety', 'captureScreenshot', - captureScreenshot ? 'warn' : 'pass', + productionSeverity(captureScreenshot), captureScreenshot ? 'enabled' : 'disabled', captureScreenshot ? 'Set captureScreenshot = false in feather.config.lua unless you need visual error context.' : undefined, ); @@ -259,12 +276,12 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) checks, 'Safety', 'Hot reload', - hotReloadEnabled ? 'warn' : 'pass', + productionSeverity(hotReloadEnabled), hotReloadEnabled ? 'enabled' : 'disabled', hotReloadEnabled ? 'Set debugger.hotReload.enabled = false in feather.config.lua before shipping.' : undefined, ); if (hotReloadEnabled && broadHotReload) { - add(checks, 'Safety', 'Hot reload allowlist', 'warn', 'contains wildcard', 'Replace wildcard hot reload allow entries with exact module names in feather.config.lua.'); + add(checks, 'Safety', 'Hot reload allowlist', opts.production ? 'fail' : 'warn', 'contains wildcard', 'Replace wildcard hot reload allow entries with exact module names in feather.config.lua.'); } if (hotReloadEnabled && !hotReloadPluginIncluded) { add( @@ -277,7 +294,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) ); } if (hotReloadEnabled && persistToDisk) { - add(checks, 'Safety', 'Hot reload persistence', 'warn', 'persistToDisk=true', 'Set debugger.hotReload.persistToDisk = false in feather.config.lua.'); + add(checks, 'Safety', 'Hot reload persistence', opts.production ? 'fail' : 'warn', 'persistToDisk=true', 'Set debugger.hotReload.persistToDisk = false in feather.config.lua.'); } const consoleIncluded = hasConfigArrayValue(activeConfigSource, 'include', 'console') || pluginDirs.some((dir) => readPluginManifest(dir)?.id === 'console'); @@ -287,18 +304,18 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) checks, 'Safety', 'Console API key', - isWeakApiKey(apiKey) ? 'warn' : 'pass', + isWeakApiKey(apiKey) ? (opts.production ? 'fail' : 'warn') : 'pass', isWeakApiKey(apiKey) ? 'missing or weak' : 'configured', isWeakApiKey(apiKey) ? 'Set apiKey to a strong per-session secret in feather.config.lua when using Console.' : undefined, ); } - const debuggerEnabled = luaBoolEnabled(activeConfigSource, 'debugger'); + const debuggerEnabled = luaBoolEnabled(activeConfigSource, 'debugger') || /debugger\s*=\s*\{[\s\S]*?enabled\s*=\s*true/.test(activeConfigSource); add( checks, 'Safety', 'Step debugger', - debuggerEnabled ? 'warn' : 'pass', + productionSeverity(debuggerEnabled), debuggerEnabled ? 'enabled' : 'disabled', debuggerEnabled ? 'Set debugger = false in feather.config.lua unless this is a trusted development session.' : undefined, ); @@ -308,10 +325,19 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) checks, 'Safety', 'Disk logging', - writeToDisk ? 'warn' : 'pass', + productionSeverity(writeToDisk), writeToDisk ? 'enabled' : 'disabled', writeToDisk ? 'Set writeToDisk = false in feather.config.lua before committing or sharing the project.' : undefined, ); + } else if (mode !== 'disk') { + add( + checks, + 'Safety', + 'Desktop App ID', + opts.production ? 'fail' : 'warn', + 'missing', + 'Create feather.config.lua with a strong appId before shipping socket/network builds.', + ); } const lockfilePath = join(projectDir, 'feather.lock.json'); @@ -390,8 +416,39 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) } const configuredPort = opts.port ?? (typeof config?.port === 'number' ? config.port : 4004); - const configuredHost = opts.host ?? '127.0.0.1'; - const mode = typeof config?.mode === 'string' ? config.mode : 'socket'; + const configuredHost = opts.host ?? (typeof config?.host === 'string' ? config.host : luaStringValue(activeConfigSource, 'host')) ?? '127.0.0.1'; + if (mode !== 'disk') { + const wildcardHost = isWildcardHost(configuredHost); + const lanHost = isLanHost(configuredHost); + const loopbackHost = isLoopbackHost(configuredHost); + const weakNetworkAuth = !configSource || luaBoolEnabled(activeConfigSource, '__DANGEROUS_INSECURE_CONNECTION__') || !config?.appId; + add( + checks, + 'Safety', + 'Network host exposure', + wildcardHost || (lanHost && weakNetworkAuth) || (!loopbackHost && opts.production && weakNetworkAuth) ? (opts.production ? 'fail' : 'warn') : 'pass', + wildcardHost ? configuredHost : lanHost ? `${configuredHost} (LAN)` : loopbackHost ? configuredHost : `${configuredHost} (non-loopback)`, + wildcardHost + ? 'Use host = "127.0.0.1" for local development, or configure a strong appId before exposing Feather on a network.' + : lanHost && weakNetworkAuth + ? 'Set a strong appId before using a LAN-facing Feather host.' + : !loopbackHost && weakNetworkAuth + ? 'Use a loopback host or set a strong appId before shipping.' + : undefined, + ); + } + + if (opts.production && hasRuntime && (!configSource || !managedMode)) { + add( + checks, + 'Safety', + 'Managed runtime', + 'fail', + 'runtime present without managed init metadata', + 'Run `feather init` to regenerate managed metadata, or remove the embedded Feather runtime before shipping.', + ); + } + if (mode === 'disk') { add(checks, 'Connectivity', 'WebSocket mode', 'info', 'disk mode', 'Desktop WebSocket connectivity is not used in disk mode.'); } else { @@ -410,7 +467,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) const warnings = checks.filter((check) => check.severity === 'warn'); if (opts.json) { - printJson({ projectDir, installDir: effectiveInstallDir, failures: failures.length, warnings: warnings.length, checks }); + printJson({ projectDir, installDir: effectiveInstallDir, production: Boolean(opts.production), failures: failures.length, warnings: warnings.length, checks }); } else { renderReport(checks, projectDir); } diff --git a/cli/src/index.ts b/cli/src/index.ts index b7ae0ce6..dd4bd8ac 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -108,14 +108,16 @@ program .command('doctor [dir]') .description('Check the environment and project setup') .option('--install-dir ', 'Feather install directory override') - .option('--host ', 'Host to check for the Feather desktop WebSocket', '127.0.0.1') + .option('--host ', 'Host to check for the Feather desktop WebSocket') .option('--port ', 'Port to check for the Feather desktop WebSocket', (value) => Number(value)) .option('--json', 'Print machine-readable diagnostics') + .option('--production', 'Fail when project settings are unsafe for production builds') .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, }))); program diff --git a/cli/test/commands.test.mjs b/cli/test/commands.test.mjs index 24854772..52174c82 100644 --- a/cli/test/commands.test.mjs +++ b/cli/test/commands.test.mjs @@ -104,6 +104,13 @@ function parseDoctorJson(dir, extra = []) { return JSON.parse(result.stdout); } +function parseDoctorJsonResult(dir, extra = []) { + const result = run(['doctor', dir, '--json', ...extra]); + assert.equal(ANSI_RE.test(result.stdout), false); + assert.equal(result.stdout.trim().startsWith('{'), true, outputOf(result)); + return { result, parsed: JSON.parse(result.stdout) }; +} + test('run: non-interactive missing game path exits with compact error', () => { const result = run(['run']); assert.equal(result.exitCode, 1); @@ -256,6 +263,86 @@ test('doctor: human output honors NO_COLOR', () => { assert.ok(result.stdout.includes('feather plugin install console')); }); +test('doctor --production fails unsafe remote-control and production settings', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + `return { + __DANGEROUS_INSECURE_CONNECTION__ = true, + host = "0.0.0.0", + include = { "console", "hot-reload" }, + apiKey = "dev", + captureScreenshot = true, + writeToDisk = true, + debugger = { + enabled = true, + hotReload = { + enabled = true, + allow = { "game.*" }, + persistToDisk = true, + }, + }, +} +`, + ); + + const { result, parsed } = parseDoctorJsonResult(dir, ['--production']); + assert.equal(result.exitCode, 1); + assert.equal(parsed.production, true); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + for (const label of [ + '__DANGEROUS_INSECURE_CONNECTION__', + 'Desktop App ID', + 'captureScreenshot', + 'Hot reload', + 'Hot reload allowlist', + 'Hot reload persistence', + 'Console API key', + 'Step debugger', + 'Disk logging', + 'Network host exposure', + ]) { + assert.equal(labels.get(label)?.severity, 'fail', `${label} should fail in production`); + } +}); + +test('doctor --json keeps unsafe settings warning-oriented outside production', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + `return { + __DANGEROUS_INSECURE_CONNECTION__ = true, + host = "0.0.0.0", + include = { "console" }, + apiKey = "dev", +} +`, + ); + + const { result, parsed } = parseDoctorJsonResult(dir); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(parsed.production, false); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('__DANGEROUS_INSECURE_CONNECTION__')?.severity, 'warn'); + assert.equal(labels.get('Console API key')?.severity, 'warn'); + assert.equal(labels.get('Network host exposure')?.severity, 'warn'); +}); + +test('doctor --production fails unmanaged embedded runtime', () => { + const dir = makeTmp(); + writeGame(dir); + writeMinimalRuntime(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890", mode = "socket" }\n'); + + const { result, parsed } = parseDoctorJsonResult(dir, ['--production']); + assert.equal(result.exitCode, 1); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('Managed runtime')?.severity, 'fail'); + assert.ok(labels.get('Managed runtime')?.fix.includes('feather init')); +}); + test('command runtime: unexpected errors render compact stderr and exit 1', async () => { const { runCliAction } = await import('../dist/lib/command.js'); const originalError = console.error; From dfdc47b82c9424a5e6b9c7f27d714a8db835dfa0 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 01:43:35 -0400 Subject: [PATCH 34/68] cli: Harden plugin install/update --- cli/src/commands/doctor/index.ts | 38 +++++++++- cli/src/commands/plugin/install.ts | 15 +++- cli/src/commands/plugin/shared.ts | 7 +- cli/src/commands/plugin/update.ts | 17 ++++- cli/src/lib/install.ts | 60 +++++++++------- cli/src/lib/plugin-utils.ts | 107 ++++++++++++++++++++++++++++- cli/test/commands.test.mjs | 86 +++++++++++++++++++++++ 7 files changed, 296 insertions(+), 34 deletions(-) diff --git a/cli/src/commands/doctor/index.ts b/cli/src/commands/doctor/index.ts index 02c20442..321aaf64 100644 --- a/cli/src/commands/doctor/index.ts +++ b/cli/src/commands/doctor/index.ts @@ -8,7 +8,7 @@ import { printJson } from '../../lib/output.js'; import { auditLockfile } from '../../lib/package/audit.js'; import { readLockfile } from '../../lib/package/lockfile.js'; import { loadRegistry } from '../../lib/package/registry.js'; -import { parseManagedValue, findInstalledPluginDirs, readPluginManifest } from '../../lib/plugin-utils.js'; +import { classifyPluginTrust, dangerousPluginIds, parseManagedValue, findInstalledPluginDirs, pluginTrustLabel, readPluginManifest } from '../../lib/plugin-utils.js'; import { pluginCatalog } from '../../generated/plugin-catalog.js'; import { add, @@ -163,6 +163,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) const pluginDirs = findInstalledPluginDirs(pluginRoot); const installedPlugins = buildPluginIndex(pluginDirs); const knownPluginIds = new Set(pluginCatalog.map((plugin) => plugin.id)); + const pluginCatalogById = new Map(pluginCatalog.map((plugin) => [plugin.id, plugin])); const projectDirArg = shellArg(projectDir); const installDirArg = shellArg(effectiveInstallDir); if (hasRuntime) { @@ -175,7 +176,13 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) existsSync(pluginRoot) ? undefined : 'Core-only installs are valid; run `feather plugin` to manage plugins.', ); add(checks, 'Plugins', 'Installed plugins', pluginDirs.length > 0 ? 'pass' : 'info', `${pluginDirs.length}`); - const malformed = pluginDirs.filter((dir) => !readPluginManifest(dir)?.id); + const malformed = pluginDirs.filter((dir) => { + const manifest = readPluginManifest(dir); + return !manifest?.id || !manifest.version; + }); + const unknownInstalled = pluginDirs + .map((dir) => ({ dir, manifest: readPluginManifest(dir) })) + .filter((plugin) => plugin.manifest?.id && !knownPluginIds.has(plugin.manifest.id)); if (malformed.length > 0) { add( checks, @@ -188,6 +195,33 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) } else if (pluginDirs.length > 0) { add(checks, 'Plugins', 'Plugin manifests', 'pass', 'all installed plugins declare an id'); } + for (const { dir, manifest } of unknownInstalled) { + add( + checks, + 'Plugins', + `Plugin ${manifest?.id}`, + 'warn', + `installed with ${pluginTrustLabel(classifyPluginTrust(manifest, null))} trust`, + `Review ${dir} and remove it with \`feather plugin remove ${manifest?.id} --dir ${projectDirArg} --install-dir ${installDirArg} --yes\` if it should not ship.`, + ); + } + for (const dir of pluginDirs) { + const manifest = readPluginManifest(dir); + if (!manifest?.id || !knownPluginIds.has(manifest.id)) continue; + const trust = classifyPluginTrust(manifest, pluginCatalogById.get(manifest.id)); + if (dangerousPluginIds.has(manifest.id)) { + add( + checks, + 'Plugins', + `Plugin ${manifest.id} trust`, + 'warn', + `${pluginTrustLabel(trust)}; development-only`, + `Remove before shipping with \`feather plugin remove ${manifest.id} --dir ${projectDirArg} --install-dir ${installDirArg} --yes\`.`, + ); + } else { + add(checks, 'Plugins', `Plugin ${manifest.id} trust`, trust === 'bundled-opt-in' ? 'info' : 'pass', pluginTrustLabel(trust)); + } + } } for (const id of activeIncludedPluginIds) { diff --git a/cli/src/commands/plugin/install.ts b/cli/src/commands/plugin/install.ts index d93747c7..00dcd1f3 100644 --- a/cli/src/commands/plugin/install.ts +++ b/cli/src/commands/plugin/install.ts @@ -1,3 +1,5 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; import { fetchManifest, getLocalPluginIds, @@ -8,17 +10,24 @@ import { import { fail } from '../../lib/command.js'; import { createSpinner } from '../../lib/output.js'; import { resolveLocalLuaRoot } from '../../lib/paths.js'; -import { type PluginSourceOptions, resolvePluginProjectDir } from './shared.js'; +import { assertValidPluginId, pluginIdToSourceDir } from '../../lib/plugin-utils.js'; +import { type PluginSourceOptions, resolvePluginProjectDir, warnDangerousPlugin } from './shared.js'; export async function pluginInstallCommand(pluginId: string, opts: PluginSourceOptions): Promise { const projectDir = resolvePluginProjectDir(opts.dir); const branch = opts.branch ?? 'main'; const installDir = opts.installDir ?? 'feather'; + try { + assertValidPluginId(pluginId); + } catch (err) { + fail((err as Error).message); + } if (!opts.remote) { const sourceRoot = resolveLocalLuaRoot(opts); const available = getLocalPluginIds(sourceRoot); - if (!available.includes(pluginId)) { + const sourceExists = existsSync(join(sourceRoot, 'plugins', pluginIdToSourceDir(pluginId))); + if (!available.includes(pluginId) && !sourceExists) { fail(`Unknown plugin: ${pluginId}`, { details: ['Available: ' + available.join(', ')] }); } @@ -26,6 +35,7 @@ export async function pluginInstallCommand(pluginId: string, opts: PluginSourceO try { installPluginsFromLocal([pluginId], sourceRoot, projectDir, installDir); spinner.succeed(`Installed ${pluginId}`); + warnDangerousPlugin(pluginId); } catch (err) { spinner.fail((err as Error).message); fail((err as Error).message, { cause: err, silent: true }); @@ -52,6 +62,7 @@ export async function pluginInstallCommand(pluginId: string, opts: PluginSourceO 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 e390e9d8..cda30f9c 100644 --- a/cli/src/commands/plugin/shared.ts +++ b/cli/src/commands/plugin/shared.ts @@ -2,7 +2,8 @@ import { existsSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { normalizeInstallDir } from '../../lib/install.js'; import { findProjectDir } from '../../lib/paths.js'; -import { findInstalledPluginDirs, readPluginManifest } from '../../lib/plugin-utils.js'; +import { dangerousPluginIds, findInstalledPluginDirs, readPluginManifest } from '../../lib/plugin-utils.js'; +import { printWarning } from '../../lib/output.js'; export type PluginSourceOptions = { dir?: string; @@ -30,3 +31,7 @@ export function getInstalledPluginIds(projectDir: string, installDir = 'feather' .sort(); } +export function warnDangerousPlugin(pluginId: string): void { + if (!dangerousPluginIds.has(pluginId)) return; + printWarning(`! ${pluginId} is development-only and can execute remote/debug commands. Do not ship it in production builds.`); +} diff --git a/cli/src/commands/plugin/update.ts b/cli/src/commands/plugin/update.ts index 323622f8..8de8cc37 100644 --- a/cli/src/commands/plugin/update.ts +++ b/cli/src/commands/plugin/update.ts @@ -10,8 +10,9 @@ import { import { fail } from '../../lib/command.js'; import { createSpinner, printMuted } from '../../lib/output.js'; import { resolveLocalLuaRoot } from '../../lib/paths.js'; +import { assertValidPluginId, pluginIdToSourceDir } from '../../lib/plugin-utils.js'; import { choosePluginUpdateWorkflow } from '../../ui/plugin-workflow.js'; -import { getInstalledPluginIds, pluginsDir, resolvePluginProjectDir } from './shared.js'; +import { getInstalledPluginIds, pluginsDir, resolvePluginProjectDir, warnDangerousPlugin } from './shared.js'; export async function pluginUpdateCommand( pluginId: string | undefined, @@ -21,6 +22,13 @@ export async function pluginUpdateCommand( const branch = opts.branch ?? 'main'; const installDir = opts.installDir ?? 'feather'; const dirPath = pluginsDir(projectDir, installDir); + if (pluginId) { + try { + assertValidPluginId(pluginId); + } catch (err) { + fail((err as Error).message); + } + } const hasExplicitSource = opts.remote === true || !!opts.localSrc || opts.yes === true; if (!pluginId && process.stdin.isTTY && !hasExplicitSource) { @@ -58,14 +66,19 @@ export async function pluginUpdateCommand( const sourceRoot = resolveLocalLuaRoot(opts); const available = getLocalPluginIds(sourceRoot); const ids = pluginId ? [pluginId] : available.filter((id) => existsSync(join(dirPath, id.replace(/\./g, '/')))); + if (pluginId && !available.includes(pluginId) && !existsSync(join(sourceRoot, 'plugins', pluginIdToSourceDir(pluginId)))) { + fail(`Unknown plugin: ${pluginId}`, { details: ['Available: ' + available.join(', ')] }); + } for (const id of ids) { const s = createSpinner(`Updating ${id}…`).start(); try { installPluginsFromLocal([id], sourceRoot, projectDir, installDir); s.succeed(`Updated ${id}`); + warnDangerousPlugin(id); } catch (err) { s.fail(`${id}: ${(err as Error).message}`); + if (pluginId) fail((err as Error).message, { cause: err, silent: true }); } } return; @@ -90,8 +103,10 @@ export async function pluginUpdateCommand( try { await installPlugin(id, entries, projectDir, branch, undefined, installDir); s.succeed(`Updated ${id}`); + warnDangerousPlugin(id); } catch (err) { s.fail(`${id}: ${(err as Error).message}`); + if (pluginId) fail((err as Error).message, { cause: err, silent: true }); } } } diff --git a/cli/src/lib/install.ts b/cli/src/lib/install.ts index 1ce55354..1fb5c618 100644 --- a/cli/src/lib/install.ts +++ b/cli/src/lib/install.ts @@ -1,7 +1,14 @@ -import { cpSync, createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs"; +import { cpSync, createWriteStream, existsSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { pipeline } from "node:stream/promises"; import { Readable } from "node:stream"; +import { + assertValidPluginId, + findLocalPluginIds, + isSafePluginRelativePath, + pluginIdToSourceDir, + validatePluginManifest, +} from "./plugin-utils.js"; const GITHUB_RAW = "https://raw.githubusercontent.com/Kyonru/feather/{branch}/src-lua/{path}"; @@ -114,11 +121,15 @@ export function installPluginsFromLocal( if (!existsSync(pluginsRoot)) throw new Error(`No plugins directory found at ${pluginsRoot}`); const root = normalizeInstallDir(installDir); - for (const pluginId of pluginIds) { - const sourceDir = pluginId.replace(/\./g, "/"); + const plans = pluginIds.map((pluginId) => { + const sourceDir = pluginIdToSourceDir(pluginId); const source = join(pluginsRoot, sourceDir); if (!existsSync(source)) throw new Error(`Unknown plugin: ${pluginId}`); + validatePluginManifest(source, { expectedId: pluginId, expectedSourceDir: sourceDir }); + return { pluginId, sourceDir, source }; + }); + for (const { pluginId, sourceDir, source } of plans) { const dest = join(targetDir, root, "plugins", sourceDir); mkdirSync(dirname(dest), { recursive: true }); cpSync(source, dest, { recursive: true, force: true }); @@ -134,12 +145,22 @@ export async function installPlugin( onProgress?: (file: string) => void, installDir = "feather" ): Promise { + assertValidPluginId(pluginId); const pluginEntries = entries.filter((e) => e.type === "plugin" && e.plugin === pluginId); if (pluginEntries.length === 0) throw new Error(`Unknown plugin: ${pluginId}`); const root = normalizeInstallDir(installDir); - for (const entry of pluginEntries) { + const plans = pluginEntries.map((entry) => { const sourceDir = entry.sourceDir ?? pluginId.replace(/\./g, "/"); const file = entry.file ?? entry.path.replace(new RegExp(`^plugins/${sourceDir}/`), ""); + if (sourceDir !== pluginIdToSourceDir(pluginId)) throw new Error(`Plugin manifest path mismatch: ${pluginId} should live in plugins/${pluginIdToSourceDir(pluginId)}`); + if (!isSafePluginRelativePath(file)) throw new Error(`Unsafe plugin file path: ${file}`); + if (!isSafePluginRelativePath(entry.path) || !entry.path.startsWith(`plugins/${sourceDir}/`)) { + throw new Error(`Unsafe plugin manifest path: ${entry.path}`); + } + return { entry, sourceDir, file }; + }); + + for (const { entry, sourceDir, file } of plans) { const dest = join(targetDir, root, "plugins", sourceDir, file); mkdirSync(dirname(dest), { recursive: true }); await downloadFile(entry.path, dest, branch); @@ -148,7 +169,14 @@ export async function installPlugin( } export function getPluginIds(entries: ManifestEntry[]): string[] { - return [...new Set(entries.filter((e) => e.type === "plugin").map((e) => e.plugin!))]; + return [...new Set(entries.filter((e) => e.type === "plugin").map((e) => e.plugin!).filter((id) => { + try { + assertValidPluginId(id); + return true; + } catch { + return false; + } + }))]; } export function normalizeInstallDir(installDir = "feather"): string { @@ -156,25 +184,5 @@ export function normalizeInstallDir(installDir = "feather"): string { } export function getLocalPluginIds(sourceRoot: string): string[] { - const pluginsRoot = join(sourceRoot, "plugins"); - if (!existsSync(pluginsRoot)) return []; - - const ids: string[] = []; - const visit = (dir: string) => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const pluginDir = join(dir, entry.name); - const manifest = join(pluginDir, "manifest.lua"); - if (existsSync(manifest)) { - const src = readFileSync(manifest, "utf8"); - const id = src.match(/id\s*=\s*"([^"]+)"/)?.[1]; - if (id) ids.push(id); - } else { - visit(pluginDir); - } - } - }; - - visit(pluginsRoot); - return ids.sort(); + return findLocalPluginIds(sourceRoot); } diff --git a/cli/src/lib/plugin-utils.ts b/cli/src/lib/plugin-utils.ts index 1fda888f..dddb35fb 100644 --- a/cli/src/lib/plugin-utils.ts +++ b/cli/src/lib/plugin-utils.ts @@ -1,5 +1,15 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; -import { join } from 'node:path'; +import { isAbsolute, join, relative } from 'node:path'; + +export type PluginManifest = { + id: string; + name: string; + version: string; +}; + +export type PluginTrust = 'bundled-core' | 'bundled-opt-in' | 'local' | 'remote' | 'unknown' | 'malformed'; + +export const dangerousPluginIds = new Set(['console', 'hot-reload']); export function parseManagedValue(src: string, key: string): string | null { return src.match(new RegExp(`^--\\s*${key}:\\s*(.+)$`, 'm'))?.[1]?.trim() ?? null; @@ -21,10 +31,103 @@ export function findInstalledPluginDirs(root: string): string[] { return found; } -export function readPluginManifest(dir: string): { id: string; name: string; version: string } | null { +export function readPluginManifest(dir: string): PluginManifest | null { const manifestPath = join(dir, 'manifest.lua'); if (!existsSync(manifestPath)) return null; const src = readFileSync(manifestPath, 'utf8'); const get = (key: string) => src.match(new RegExp(`${key}\\s*=\\s*"([^"]+)"`))?.[1] ?? ''; return { id: get('id'), name: get('name'), version: get('version') }; } + +export function isValidPluginId(id: string): boolean { + return /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/.test(id) && !id.split('.').includes('..'); +} + +export function assertValidPluginId(id: string): void { + if (!isValidPluginId(id)) { + throw new Error(`Invalid plugin id: ${id}`); + } +} + +export function isValidPluginVersion(version: string): boolean { + return /^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(version); +} + +export function pluginIdToSourceDir(id: string): string { + assertValidPluginId(id); + return id.replace(/\./g, '/'); +} + +export function isSafePluginRelativePath(path: string): boolean { + const normalized = path.replace(/\\/g, '/'); + return Boolean(normalized) && !isAbsolute(normalized) && !normalized.split('/').some((part) => part === '' || part === '..'); +} + +export function validatePluginManifest( + dir: string, + options: { expectedId?: string; expectedSourceDir?: string } = {}, +): PluginManifest { + const manifest = readPluginManifest(dir); + if (!manifest) throw new Error(`Missing plugin manifest: ${join(dir, 'manifest.lua')}`); + if (!manifest.id) throw new Error(`Plugin manifest is missing id: ${join(dir, 'manifest.lua')}`); + if (!isValidPluginId(manifest.id)) throw new Error(`Plugin manifest has invalid id: ${manifest.id}`); + if (!manifest.version) throw new Error(`Plugin manifest is missing version: ${manifest.id}`); + if (!isValidPluginVersion(manifest.version)) throw new Error(`Plugin manifest has invalid version: ${manifest.id}`); + if (options.expectedId && manifest.id !== options.expectedId) { + throw new Error(`Plugin manifest id mismatch: expected ${options.expectedId}, found ${manifest.id}`); + } + if (options.expectedSourceDir) { + const expectedSourceDir = options.expectedSourceDir.replace(/\\/g, '/'); + const actualSourceDir = pluginIdToSourceDir(manifest.id); + if (actualSourceDir !== expectedSourceDir) { + throw new Error(`Plugin manifest path mismatch: ${manifest.id} should live in plugins/${actualSourceDir}`); + } + } + return manifest; +} + +export function classifyPluginTrust( + manifest: PluginManifest | null, + catalogEntry?: { optIn?: boolean } | null, +): PluginTrust { + if (!manifest?.id || !manifest.version) return 'malformed'; + if (catalogEntry) return catalogEntry.optIn ? 'bundled-opt-in' : 'bundled-core'; + return 'unknown'; +} + +export function pluginTrustLabel(trust: PluginTrust): string { + if (trust === 'bundled-core') return 'bundled/core'; + if (trust === 'bundled-opt-in') return 'opt-in bundled'; + if (trust === 'local') return 'local'; + if (trust === 'remote') return 'remote'; + if (trust === 'unknown') return 'unknown'; + return 'malformed'; +} + +export function findLocalPluginIds(sourceRoot: string): string[] { + const pluginsRoot = join(sourceRoot, 'plugins'); + if (!existsSync(pluginsRoot)) return []; + + const ids: string[] = []; + const visit = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const pluginDir = join(dir, entry.name); + const manifest = join(pluginDir, 'manifest.lua'); + if (existsSync(manifest)) { + try { + const sourceDir = relative(pluginsRoot, pluginDir).replace(/\\/g, '/'); + ids.push(validatePluginManifest(pluginDir, { expectedSourceDir: sourceDir }).id); + } catch { + // Invalid local plugins are intentionally omitted from discovery, but + // explicit install/update still validates and reports the concrete issue. + } + } else { + visit(pluginDir); + } + } + }; + + visit(pluginsRoot); + return ids.sort(); +} diff --git a/cli/test/commands.test.mjs b/cli/test/commands.test.mjs index 52174c82..1369f101 100644 --- a/cli/test/commands.test.mjs +++ b/cli/test/commands.test.mjs @@ -67,6 +67,24 @@ function writeMinimalRuntime(dir) { writeFileSync(join(dir, 'feather', 'plugin_manager.lua'), 'return {}\n'); } +function writeLocalPluginSource(root, id, options = {}) { + const sourceDir = options.sourceDir ?? id.replace(/\./g, '/'); + const pluginDir = join(root, 'plugins', sourceDir); + mkdirSync(pluginDir, { recursive: true }); + const manifestId = options.manifestId ?? id; + const versionLine = options.version === null ? '' : ` version = "${options.version ?? '1.0.0'}",\n`; + writeFileSync( + join(pluginDir, 'manifest.lua'), + `return { + id = "${manifestId}", + name = "${options.name ?? manifestId}", +${versionLine}} +`, + ); + writeFileSync(join(pluginDir, 'init.lua'), 'return {}\n'); + return pluginDir; +} + function writeLock(dir, packages) { writeFileSync(join(dir, 'feather.lock.json'), JSON.stringify({ lockfileVersion: 1, packages }, null, 2)); } @@ -216,6 +234,46 @@ test('plugin install: unknown local plugin exits 1', () => { assert.ok(outputOf(result).includes('Unknown plugin: zzz-missing')); }); +test('plugin install: local manifest is validated before copying', () => { + const dir = makeTmp(); + const source = join(makeTmp(), 'src-lua'); + writeLocalPluginSource(source, 'bad-plugin', { version: null }); + + const result = run(['plugin', 'install', 'bad-plugin', '--local-src', source, '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Plugin manifest is missing version: bad-plugin')); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'bad-plugin')), false); +}); + +test('plugin install: local manifest id must match plugin path', () => { + const dir = makeTmp(); + const source = join(makeTmp(), 'src-lua'); + writeLocalPluginSource(source, 'console', { manifestId: 'other-plugin' }); + + const result = run(['plugin', 'install', 'console', '--local-src', source, '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Plugin manifest id mismatch: expected console, found other-plugin')); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console')), false); +}); + +test('plugin install: rejects path traversal plugin ids', () => { + const dir = makeTmp(); + const result = run(['plugin', 'install', '../escape', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Invalid plugin id: ../escape')); +}); + +test('plugin update: explicit local update fails on invalid manifest', () => { + const dir = makeTmp(); + const source = join(makeTmp(), 'src-lua'); + writeLocalPluginSource(source, 'bad-plugin', { version: 'not valid' }); + + const result = run(['plugin', 'update', 'bad-plugin', '--local-src', source, '--dir', dir, '--yes']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Plugin manifest has invalid version: bad-plugin')); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'bad-plugin')), false); +}); + test('plugin list: malformed manifests do not crash and use directory fallback id', () => { const dir = makeTmp(); const pluginDir = join(dir, 'feather', 'plugins', 'bad-plugin'); @@ -228,6 +286,34 @@ test('plugin list: malformed manifests do not crash and use directory fallback i assert.ok(result.stdout.includes('Bad Plugin')); }); +test('doctor --json reports unknown installed plugin trust', () => { + const dir = makeTmp(); + writeGame(dir); + writeMinimalRuntime(dir); + writeLocalPluginSource(dir, 'custom-plugin'); + + const parsed = parseDoctorJson(dir); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + const trustCheck = labels.get('Plugin custom-plugin'); + assert.equal(trustCheck.severity, 'warn'); + assert.ok(trustCheck.detail.includes('unknown trust')); + assert.ok(trustCheck.fix.includes('feather plugin remove custom-plugin')); +}); + +test('doctor --json reports dangerous bundled plugin trust', () => { + const dir = makeTmp(); + writeGame(dir); + writeMinimalRuntime(dir); + const result = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + + const parsed = parseDoctorJson(dir); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + const trustCheck = labels.get('Plugin console trust'); + assert.equal(trustCheck.severity, 'warn'); + assert.ok(trustCheck.detail.includes('development-only')); +}); + test('doctor --json remains decoration-free and reports missing plugin directory', () => { const dir = makeTmp(); writeGame(dir); From 0c8ba9dab3f03e35f20cf02b645ab6d8b28f4407 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 01:51:14 -0400 Subject: [PATCH 35/68] cli: Protect filesystem mutations --- cli/src/commands/doctor/index.ts | 22 +++++- cli/src/commands/init.ts | 23 ++++-- cli/src/commands/package/remove.ts | 12 ++- cli/src/commands/plugin/remove.ts | 16 +++- cli/src/commands/remove.ts | 33 ++++---- cli/src/commands/update.ts | 9 ++- cli/src/lib/install.ts | 30 ++++--- cli/src/lib/package/target.ts | 10 ++- cli/src/lib/path-safety.ts | 123 +++++++++++++++++++++++++++++ cli/test/commands.test.mjs | 81 +++++++++++++++++++ 10 files changed, 322 insertions(+), 37 deletions(-) create mode 100644 cli/src/lib/path-safety.ts diff --git a/cli/src/commands/doctor/index.ts b/cli/src/commands/doctor/index.ts index 321aaf64..0835af33 100644 --- a/cli/src/commands/doctor/index.ts +++ b/cli/src/commands/doctor/index.ts @@ -5,6 +5,7 @@ import { loadConfig } from '../../lib/config.js'; import { normalizeInstallDir } from '../../lib/install.js'; import { fail } from '../../lib/command.js'; import { printJson } from '../../lib/output.js'; +import { findSymlinkEscapes } from '../../lib/path-safety.js'; import { auditLockfile } from '../../lib/package/audit.js'; import { readLockfile } from '../../lib/package/lockfile.js'; import { loadRegistry } from '../../lib/package/registry.js'; @@ -160,7 +161,26 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) const runtimePluginRoot = join(runtimeDir, 'plugins'); const sourceTreePluginRoot = join(projectDir, 'plugins'); const pluginRoot = existsSync(runtimePluginRoot) ? runtimePluginRoot : sourceTreePluginRoot; - const pluginDirs = findInstalledPluginDirs(pluginRoot); + let pluginRootUnsafe = false; + if (hasProjectDir) { + const symlinkEscapes = findSymlinkEscapes(projectDir, [runtimeDir, runtimePluginRoot, sourceTreePluginRoot]); + pluginRootUnsafe = symlinkEscapes.some((escape) => resolve(pluginRoot).startsWith(resolve(escape.path))); + const seenEscapes = new Set(); + for (const escape of symlinkEscapes) { + const key = `${escape.path}\0${escape.target}`; + if (seenEscapes.has(key)) continue; + seenEscapes.add(key); + add( + checks, + 'Safety', + 'Symlink escape', + 'warn', + `${escape.path} → ${escape.target}`, + 'Remove or replace symlinks that point outside the project before running update/remove or shipping.', + ); + } + } + const pluginDirs = pluginRootUnsafe ? [] : findInstalledPluginDirs(pluginRoot); const installedPlugins = buildPluginIndex(pluginDirs); const knownPluginIds = new Set(pluginCatalog.map((plugin) => plugin.id)); const pluginCatalogById = new Map(pluginCatalog.map((plugin) => [plugin.id, plugin])); diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index be8cf6ed..4116310b 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -16,6 +16,7 @@ import { pluginCatalog } from '../generated/plugin-catalog.js'; import { resolveLocalLuaRoot } from '../lib/paths.js'; import { fail } from '../lib/command.js'; import { createSpinner, icon, printLine, printMuted, printWarning, style } from '../lib/output.js'; +import { assertSafeProjectTarget } from '../lib/path-safety.js'; export interface InitOptions { branch?: string; @@ -77,7 +78,13 @@ function patchMainLuaForManual(mainPath: string): boolean { export async function initCommand(dir: string, opts: InitOptions): Promise { const target = resolve(dir); - if (!existsSync(join(target, 'main.lua'))) { + let mainPath: string; + try { + mainPath = assertSafeProjectTarget(target, 'main.lua', 'main.lua write target'); + } catch (err) { + fail((err as Error).message); + } + if (!existsSync(mainPath)) { fail(`No main.lua found in ${target}. Is this a love2d project?`); } @@ -95,8 +102,14 @@ export async function initCommand(dir: string, opts: InitOptions): Promise }; const mode = setup.mode; const installDir = normalizeInstallDir(setup.installDir); + let installInitPath: string; + try { + installInitPath = assertSafeProjectTarget(target, join(installDir, 'init.lua'), 'Core install target'); + } catch (err) { + fail((err as Error).message); + } const pluginsDisabled = opts.noPlugins || setup.installPlugins === false; - const alreadyInstalled = existsSync(join(target, installDir, 'init.lua')); + const alreadyInstalled = existsSync(installInitPath); const useRemote = opts.remote === true || setup.source === 'remote'; if (mode === 'cli') { @@ -191,8 +204,6 @@ export async function initCommand(dir: string, opts: InitOptions): Promise } } - const mainPath = join(target, 'main.lua'); - if (mode === 'auto') { const patched = patchMainLua(mainPath, installDir); if (patched) { @@ -244,7 +255,7 @@ function writeConfig( config: Record = {}, context: { mode?: string; installDir?: string; source?: string; manualEntrypoint?: string } = {}, ): void { - const configPath = join(target, 'feather.config.lua'); + const configPath = assertSafeProjectTarget(target, 'feather.config.lua', 'Config write target'); if (!existsSync(configPath)) { writeFileSync(configPath, configTemplate(config, context)); printLine(`${icon.success} Created feather.config.lua`); @@ -254,7 +265,7 @@ function writeConfig( } function writeManualDebugger(target: string, config: Record, pluginIds: string[], installDir: string): boolean { - const manualPath = join(target, 'feather.debugger.lua'); + const manualPath = assertSafeProjectTarget(target, 'feather.debugger.lua', 'Manual debugger write target'); if (existsSync(manualPath)) return false; writeFileSync(manualPath, buildManualDebuggerFile(config, pluginIds, installDir)); return true; diff --git a/cli/src/commands/package/remove.ts b/cli/src/commands/package/remove.ts index eb091956..2e159b48 100644 --- a/cli/src/commands/package/remove.ts +++ b/cli/src/commands/package/remove.ts @@ -1,10 +1,10 @@ import { existsSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; import { readLockfile, removeFromLockfile, writeLockfile } from '../../lib/package/lockfile.js'; import { fail } from '../../lib/command.js'; import { icon, printLine, printMuted, style } from '../../lib/output.js'; import { confirmAction } from '../../ui/confirm.js'; import { resolvePackageProjectDir } from './shared.js'; +import { resolveProjectTarget } from '../../lib/package/target.js'; export type PackageRemoveOptions = { dir?: string; @@ -20,7 +20,10 @@ export async function packageRemoveCommand(name: string, opts: PackageRemoveOpti fail(`"${name}" is not installed.`); } - const existingFiles = entry.files.filter((file) => existsSync(join(projectDir, file.target))); + const existingFiles = entry.files.filter((file) => { + const abs = resolveProjectTarget(projectDir, file.target); + return abs !== null && existsSync(abs); + }); if (!opts.yes && (!process.stdin.isTTY || !process.stdout.isTTY)) { fail(`Refusing to remove "${name}" without --yes in non-interactive mode.`); } @@ -43,7 +46,10 @@ export async function packageRemoveCommand(name: string, opts: PackageRemoveOpti } for (const file of entry.files) { - const abs = join(projectDir, file.target); + const abs = resolveProjectTarget(projectDir, file.target); + if (!abs) { + fail(`Refusing to remove unsafe package target: ${file.target}`); + } if (existsSync(abs)) { rmSync(abs); printMuted(` removed ${file.target}`); diff --git a/cli/src/commands/plugin/remove.ts b/cli/src/commands/plugin/remove.ts index 31b135dc..712ed4e1 100644 --- a/cli/src/commands/plugin/remove.ts +++ b/cli/src/commands/plugin/remove.ts @@ -1,16 +1,28 @@ import { existsSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { fail } from '../../lib/command.js'; +import { normalizeInstallDir } from '../../lib/install.js'; import { icon, printLine, printMuted } from '../../lib/output.js'; +import { assertSafeProjectTarget } from '../../lib/path-safety.js'; +import { pluginIdToSourceDir } from '../../lib/plugin-utils.js'; import { confirmAction } from '../../ui/confirm.js'; -import { pluginsDir, resolvePluginProjectDir } from './shared.js'; +import { resolvePluginProjectDir } from './shared.js'; export async function pluginRemoveCommand( pluginId: string, opts: { dir?: string; installDir?: string; yes?: boolean }, ): Promise { const projectDir = resolvePluginProjectDir(opts.dir); - const pluginDir = join(pluginsDir(projectDir, opts.installDir), pluginId.replace(/\./g, '/')); + let pluginDir: string; + try { + pluginDir = assertSafeProjectTarget( + projectDir, + join(normalizeInstallDir(opts.installDir), 'plugins', pluginIdToSourceDir(pluginId)), + 'Plugin remove target', + ); + } catch (err) { + fail((err as Error).message); + } if (!existsSync(pluginDir)) { fail(`Plugin not found: ${pluginId}`); diff --git a/cli/src/commands/remove.ts b/cli/src/commands/remove.ts index 27347477..b92ff41c 100644 --- a/cli/src/commands/remove.ts +++ b/cli/src/commands/remove.ts @@ -5,6 +5,7 @@ import { chooseRemoveWorkflow, type RemoveTarget } from "../ui/remove-workflow.j import { parseManagedValue } from "../lib/plugin-utils.js"; import { fail } from "../lib/command.js"; import { icon, printLine, printMuted, style } from "../lib/output.js"; +import { assertSafeProjectTarget } from "../lib/path-safety.js"; export interface RemoveOptions { yes?: boolean; @@ -22,21 +23,29 @@ type RemoveContext = { configPath: string; mainPath: string; manualEntrypoint: string; + manualPath: string; + runtimePath: string; }; function resolveContext(dir: string, opts: RemoveOptions): RemoveContext { const projectDir = resolve(dir); - const configPath = join(projectDir, "feather.config.lua"); + const configPath = assertSafeProjectTarget(projectDir, "feather.config.lua", "Config remove target"); const configSrc = existsSync(configPath) ? readFileSync(configPath, "utf8") : ""; const installDir = normalizeInstallDir(opts.installDir ?? parseManagedValue(configSrc, "installDir") ?? "feather"); const manualEntrypoint = parseManagedValue(configSrc, "manualEntrypoint") ?? "feather.debugger.lua"; + const runtimePath = assertSafeProjectTarget(projectDir, installDir, "Runtime remove target"); + const manualPath = manualEntrypoint === "(none)" + ? join(projectDir, "(none)") + : assertSafeProjectTarget(projectDir, manualEntrypoint, "Manual entrypoint remove target"); return { projectDir, installDir, configPath, - mainPath: join(projectDir, "main.lua"), + mainPath: assertSafeProjectTarget(projectDir, "main.lua", "main.lua update target"), manualEntrypoint, + manualPath, + runtimePath, }; } @@ -63,24 +72,22 @@ function discoverTargets(context: RemoveContext, opts: RemoveOptions): RemoveTar } } - const runtimePath = join(context.projectDir, context.installDir); - if (!opts.keepRuntime && existsSync(runtimePath)) { + if (!opts.keepRuntime && existsSync(context.runtimePath)) { targets.push({ id: "runtime", label: "Feather runtime", - path: runtimePath, + path: context.runtimePath, description: "Delete the installed Feather core and plugins directory.", defaultSelected: true, dangerous: true, }); } - const manualPath = join(context.projectDir, context.manualEntrypoint); - if (!opts.keepManual && context.manualEntrypoint !== "(none)" && existsSync(manualPath)) { + if (!opts.keepManual && context.manualEntrypoint !== "(none)" && existsSync(context.manualPath)) { targets.push({ id: "manual", label: "Manual debugger entrypoint", - path: manualPath, + path: context.manualPath, description: "Delete the generated manual setup file.", defaultSelected: true, dangerous: true, @@ -114,16 +121,14 @@ function applyTarget(id: string, context: RemoveContext, dryRun: boolean): strin } if (id === "runtime") { - const runtimePath = join(context.projectDir, context.installDir); - if (!existsSync(runtimePath)) return null; - if (!dryRun) rmSync(runtimePath, { recursive: true, force: true }); + if (!existsSync(context.runtimePath)) return null; + if (!dryRun) rmSync(context.runtimePath, { recursive: true, force: true }); return `Removed ${context.installDir}/`; } if (id === "manual") { - const manualPath = join(context.projectDir, context.manualEntrypoint); - if (!existsSync(manualPath)) return null; - if (!dryRun) rmSync(manualPath, { force: true }); + if (!existsSync(context.manualPath)) return null; + if (!dryRun) rmSync(context.manualPath, { force: true }); return `Removed ${context.manualEntrypoint}`; } diff --git a/cli/src/commands/update.ts b/cli/src/commands/update.ts index d866393a..c408f246 100644 --- a/cli/src/commands/update.ts +++ b/cli/src/commands/update.ts @@ -5,6 +5,7 @@ import { chooseCoreUpdateWorkflow } from "../ui/update-workflow.js"; import { resolveLocalLuaRoot } from "../lib/paths.js"; import { fail } from "../lib/command.js"; import { createSpinner, printLine, printMuted, style } from "../lib/output.js"; +import { assertSafeProjectTarget } from "../lib/path-safety.js"; export async function updateCommand( dir: string, @@ -12,8 +13,14 @@ export async function updateCommand( ): Promise { const target = resolve(dir); const installDir = normalizeInstallDir(opts.installDir); + let installedInit: string; + try { + installedInit = assertSafeProjectTarget(target, join(installDir, "init.lua"), "Core update target"); + } catch (err) { + fail((err as Error).message); + } - if (!existsSync(join(target, installDir, "init.lua"))) { + if (!existsSync(installedInit)) { fail(`Feather is not installed in ${target}. Run \`feather init\` first.`); } diff --git a/cli/src/lib/install.ts b/cli/src/lib/install.ts index 1fb5c618..3aa29351 100644 --- a/cli/src/lib/install.ts +++ b/cli/src/lib/install.ts @@ -5,10 +5,10 @@ import { Readable } from "node:stream"; import { assertValidPluginId, findLocalPluginIds, - isSafePluginRelativePath, pluginIdToSourceDir, validatePluginManifest, } from "./plugin-utils.js"; +import { assertSafeProjectTarget, assertSafeRelativePath } from "./path-safety.js"; const GITHUB_RAW = "https://raw.githubusercontent.com/Kyonru/feather/{branch}/src-lua/{path}"; @@ -87,8 +87,9 @@ export async function installCore( const root = normalizeInstallDir(installDir); for (const entry of entries) { if (entry.type !== "core") continue; - const dest = join(targetDir, root, entry.path.replace(/^feather\//, "")); - if (!existsSync(dirname(dest))) mkdirSync(dirname(dest), { recursive: true }); + const relativeTarget = join(root, entry.path.replace(/^feather\//, "")); + const dest = assertSafeProjectTarget(targetDir, relativeTarget, "Core install target"); + mkdirSync(dirname(dest), { recursive: true }); await downloadFile(entry.path, dest, branch); onProgress?.(entry.path); } @@ -104,7 +105,7 @@ export function installCoreFromLocal( if (!existsSync(source)) throw new Error(`No feather core found at ${source}`); const root = normalizeInstallDir(installDir); - const dest = join(targetDir, root); + const dest = assertSafeProjectTarget(targetDir, root, "Core install target"); mkdirSync(dirname(dest), { recursive: true }); cpSync(source, dest, { recursive: true, force: true }); onProgress?.("feather"); @@ -130,7 +131,7 @@ export function installPluginsFromLocal( }); for (const { pluginId, sourceDir, source } of plans) { - const dest = join(targetDir, root, "plugins", sourceDir); + const dest = assertSafeProjectTarget(targetDir, join(root, "plugins", sourceDir), "Plugin install target"); mkdirSync(dirname(dest), { recursive: true }); cpSync(source, dest, { recursive: true, force: true }); onProgress?.(pluginId); @@ -153,15 +154,24 @@ export async function installPlugin( const sourceDir = entry.sourceDir ?? pluginId.replace(/\./g, "/"); const file = entry.file ?? entry.path.replace(new RegExp(`^plugins/${sourceDir}/`), ""); if (sourceDir !== pluginIdToSourceDir(pluginId)) throw new Error(`Plugin manifest path mismatch: ${pluginId} should live in plugins/${pluginIdToSourceDir(pluginId)}`); - if (!isSafePluginRelativePath(file)) throw new Error(`Unsafe plugin file path: ${file}`); - if (!isSafePluginRelativePath(entry.path) || !entry.path.startsWith(`plugins/${sourceDir}/`)) { + try { + assertSafeRelativePath(file, "Plugin file path"); + } catch { + throw new Error(`Unsafe plugin file path: ${file}`); + } + try { + assertSafeRelativePath(entry.path, "Plugin manifest path"); + } catch { + throw new Error(`Unsafe plugin manifest path: ${entry.path}`); + } + if (!entry.path.startsWith(`plugins/${sourceDir}/`)) { throw new Error(`Unsafe plugin manifest path: ${entry.path}`); } return { entry, sourceDir, file }; }); for (const { entry, sourceDir, file } of plans) { - const dest = join(targetDir, root, "plugins", sourceDir, file); + const dest = assertSafeProjectTarget(targetDir, join(root, "plugins", sourceDir, file), "Plugin install target"); mkdirSync(dirname(dest), { recursive: true }); await downloadFile(entry.path, dest, branch); onProgress?.(entry.path); @@ -180,7 +190,9 @@ export function getPluginIds(entries: ManifestEntry[]): string[] { } export function normalizeInstallDir(installDir = "feather"): string { - return installDir.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "") || "feather"; + const normalized = installDir.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "") || "feather"; + assertSafeRelativePath(normalized, "Install directory"); + return normalized; } export function getLocalPluginIds(sourceRoot: string): string[] { diff --git a/cli/src/lib/package/target.ts b/cli/src/lib/package/target.ts index 55992db7..9d2dc5c9 100644 --- a/cli/src/lib/package/target.ts +++ b/cli/src/lib/package/target.ts @@ -1,4 +1,6 @@ +import { existsSync } from "node:fs"; import { isAbsolute, relative, resolve } from "node:path"; +import { assertNoSymlinkEscape } from "../path-safety.js"; export function resolveProjectTarget(projectDir: string, target: string): string | null { if (!target || isAbsolute(target)) return null; @@ -8,6 +10,12 @@ export function resolveProjectTarget(projectDir: string, target: string): string const relativeTarget = relative(root, absoluteTarget); if (relativeTarget.startsWith("..") || isAbsolute(relativeTarget)) return null; + if (existsSync(root)) { + try { + assertNoSymlinkEscape(root, absoluteTarget, "Package target"); + } catch { + return null; + } + } return absoluteTarget; } - diff --git a/cli/src/lib/path-safety.ts b/cli/src/lib/path-safety.ts new file mode 100644 index 00000000..568da0fa --- /dev/null +++ b/cli/src/lib/path-safety.ts @@ -0,0 +1,123 @@ +import { existsSync, lstatSync, readdirSync, realpathSync } from 'node:fs'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; + +export function isPathInside(root: string, target: string): boolean { + const rel = relative(resolve(root), resolve(target)); + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); +} + +export function assertPathInside(root: string, target: string, label: string): void { + if (!isPathInside(root, target)) { + throw new Error(`${label} must stay inside project root: ${target}`); + } +} + +export function isSafeRelativePath(path: string): boolean { + const normalized = path.replace(/\\/g, '/'); + return Boolean(normalized) && !isAbsolute(normalized) && !normalized.split('/').some((part) => part === '' || part === '..'); +} + +export function assertSafeRelativePath(path: string, label: string): void { + if (!isSafeRelativePath(path)) { + throw new Error(`${label} must be a relative path inside the project: ${path}`); + } +} + +function nearestExistingPath(path: string): string | null { + let current = resolve(path); + while (!existsSync(current)) { + const next = dirname(current); + if (next === current) return null; + current = next; + } + return current; +} + +export function assertNoSymlinkEscape(root: string, target: string, label: string): void { + const rootPath = resolve(root); + const targetPath = resolve(target); + assertPathInside(rootPath, targetPath, label); + const projectRoot = realpathSync(rootPath); + + const existing = nearestExistingPath(targetPath); + if (!existing) return; + + const resolvedExisting = realpathSync(existing); + if (!isPathInside(projectRoot, resolvedExisting)) { + throw new Error(`${label} resolves outside project root: ${existing}`); + } + + if (existsSync(targetPath)) { + const stat = lstatSync(targetPath); + if (stat.isSymbolicLink()) { + const resolvedTarget = realpathSync(targetPath); + if (!isPathInside(projectRoot, resolvedTarget)) { + throw new Error(`${label} symlink resolves outside project root: ${targetPath}`); + } + } + } +} + +export function assertSafeProjectTarget(root: string, relativePath: string, label: string): string { + assertSafeRelativePath(relativePath, label); + const target = join(resolve(root), relativePath); + assertNoSymlinkEscape(root, target, label); + return target; +} + +function inspectSymlink(projectRoot: string, path: string): { path: string; target: string } | null { + const target = realpathSync(path); + return isPathInside(projectRoot, target) ? null : { path, target }; +} + +function pathComponentEscape(root: string, path: string): { path: string; target: string } | null { + const rootPath = resolve(root); + const projectRoot = realpathSync(rootPath); + const absolute = resolve(path); + if (!isPathInside(rootPath, absolute)) return null; + const parts = relative(rootPath, absolute).split(sep).filter(Boolean); + let current = rootPath; + for (const part of parts) { + current = join(current, part); + if (!existsSync(current)) break; + const stat = lstatSync(current); + if (!stat.isSymbolicLink()) continue; + const escape = inspectSymlink(projectRoot, current); + if (escape) return escape; + } + return null; +} + +export function findSymlinkEscapes(root: string, paths: string[]): Array<{ path: string; target: string }> { + const projectRoot = realpathSync(resolve(root)); + const seen = new Set(); + const escapes: Array<{ path: string; target: string }> = []; + + const visit = (path: string) => { + const absolute = resolve(path); + if (seen.has(absolute) || !existsSync(absolute)) return; + seen.add(absolute); + + const componentEscape = pathComponentEscape(root, absolute); + if (componentEscape) { + escapes.push(componentEscape); + return; + } + + const stat = lstatSync(absolute); + if (stat.isSymbolicLink()) { + const escape = inspectSymlink(projectRoot, absolute); + if (escape) escapes.push(escape); + return; + } + if (!stat.isDirectory()) return; + + for (const entry of readdirSync(absolute, { withFileTypes: true })) { + visit(join(absolute, entry.name)); + } + }; + + for (const path of paths) visit(path); + + return escapes; +} diff --git a/cli/test/commands.test.mjs b/cli/test/commands.test.mjs index 1369f101..6fac35b8 100644 --- a/cli/test/commands.test.mjs +++ b/cli/test/commands.test.mjs @@ -13,6 +13,7 @@ import { mkdirSync, mkdtempSync, readFileSync, + symlinkSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -263,6 +264,18 @@ test('plugin install: rejects path traversal plugin ids', () => { assert.ok(outputOf(result).includes('Invalid plugin id: ../escape')); }); +test('plugin install: refuses install directory symlink escaping project', () => { + const dir = makeTmp(); + const outside = join(makeTmp(), 'outside-runtime'); + mkdirSync(outside, { recursive: true }); + symlinkSync(outside, join(dir, 'feather'), 'dir'); + + const result = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Plugin install target resolves outside project root')); + assert.equal(existsSync(join(outside, 'plugins', 'console')), false); +}); + test('plugin update: explicit local update fails on invalid manifest', () => { const dir = makeTmp(); const source = join(makeTmp(), 'src-lua'); @@ -274,6 +287,40 @@ test('plugin update: explicit local update fails on invalid manifest', () => { assert.equal(existsSync(join(dir, 'feather', 'plugins', 'bad-plugin')), false); }); +test('plugin remove: refuses plugin directory symlink escaping project', () => { + const dir = makeTmp(); + const outside = join(makeTmp(), 'outside-runtime'); + writeLocalPluginSource(outside, 'console'); + symlinkSync(outside, join(dir, 'feather'), 'dir'); + + const result = run(['plugin', 'remove', 'console', '--dir', dir, '--yes']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Plugin remove target resolves outside project root')); + assert.equal(existsSync(join(outside, 'plugins', 'console', 'manifest.lua')), true); +}); + +test('remove: refuses runtime symlink escaping project', () => { + const dir = makeTmp(); + const outside = join(makeTmp(), 'outside-runtime'); + writeGame(dir); + writeMinimalRuntime(outside); + symlinkSync(join(outside, 'feather'), join(dir, 'feather'), 'dir'); + writeFileSync(join(dir, 'feather.config.lua'), [ + '-- FEATHER-MANAGED-BEGIN', + '-- mode: auto', + '-- installDir: feather', + '-- manualEntrypoint: (none)', + '-- FEATHER-MANAGED-END', + 'return { appId = "feather-app-test" }', + '', + ].join('\n')); + + const result = run(['remove', dir, '--yes']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Runtime remove target resolves outside project root')); + assert.equal(existsSync(join(outside, 'feather', 'init.lua')), true); +}); + test('plugin list: malformed manifests do not crash and use directory fallback id', () => { const dir = makeTmp(); const pluginDir = join(dir, 'feather', 'plugins', 'bad-plugin'); @@ -314,6 +361,19 @@ test('doctor --json reports dangerous bundled plugin trust', () => { assert.ok(trustCheck.detail.includes('development-only')); }); +test('doctor --json warns about runtime symlinks escaping project', () => { + const dir = makeTmp(); + const outside = join(makeTmp(), 'outside-runtime'); + writeGame(dir); + writeMinimalRuntime(outside); + symlinkSync(join(outside, 'feather'), join(dir, 'feather'), 'dir'); + + const parsed = parseDoctorJson(dir); + const symlinkCheck = parsed.checks.find((check) => check.label === 'Symlink escape'); + assert.equal(symlinkCheck.severity, 'warn'); + assert.ok(symlinkCheck.detail.includes('outside-runtime')); +}); + test('doctor --json remains decoration-free and reports missing plugin directory', () => { const dir = makeTmp(); writeGame(dir); @@ -504,3 +564,24 @@ test('json commands used by scripts stay parseable and decoration-free', () => { assert.equal(doctor.stdout.trim().startsWith('{'), true); JSON.parse(doctor.stdout); }); + +test('package remove: refuses lockfile target through symlink escaping project', () => { + const dir = makeTmp(); + const outside = join(makeTmp(), 'outside-lib'); + mkdirSync(outside, { recursive: true }); + writeFileSync(join(outside, 'helper.lua'), 'return {}\n'); + symlinkSync(outside, join(dir, 'lib'), 'dir'); + writeLock(dir, { + helper: { + version: 'url', + trust: 'experimental', + source: { url: 'https://example.com/helper.lua' }, + files: [{ name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: sha256('return {}\n') }], + }, + }); + + const result = run(['package', 'remove', 'helper', '--dir', dir, '--yes']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Refusing to remove unsafe package target: lib/helper.lua')); + assert.equal(existsSync(join(outside, 'helper.lua')), true); +}); From ee8a4dcdca4647bda4f6c6c1f33f2ac97eca4b4c Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 01:58:27 -0400 Subject: [PATCH 36/68] cli: Improve package/security provenance --- cli/src/commands/doctor/index.ts | 19 ++++++ cli/src/commands/package/install.ts | 28 +++++++++ cli/src/lib/package/install.ts | 16 +++--- cli/src/lib/package/provenance.ts | 89 +++++++++++++++++++++++++++++ cli/test/package.test.mjs | 65 +++++++++++++++++++++ 5 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 cli/src/lib/package/provenance.ts diff --git a/cli/src/commands/doctor/index.ts b/cli/src/commands/doctor/index.ts index 0835af33..ae4493b0 100644 --- a/cli/src/commands/doctor/index.ts +++ b/cli/src/commands/doctor/index.ts @@ -8,6 +8,7 @@ import { printJson } from '../../lib/output.js'; import { findSymlinkEscapes } from '../../lib/path-safety.js'; import { auditLockfile } from '../../lib/package/audit.js'; import { readLockfile } from '../../lib/package/lockfile.js'; +import { lockfileUrlFindings } from '../../lib/package/provenance.js'; import { loadRegistry } from '../../lib/package/registry.js'; import { classifyPluginTrust, dangerousPluginIds, parseManagedValue, findInstalledPluginDirs, pluginTrustLabel, readPluginManifest } from '../../lib/plugin-utils.js'; import { pluginCatalog } from '../../generated/plugin-catalog.js'; @@ -435,6 +436,24 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) ); } + const provenanceFindings = lockfileUrlFindings(lockfile); + const provenanceByPackage = new Map(); + for (const finding of provenanceFindings) { + provenanceByPackage.set(finding.id, [...(provenanceByPackage.get(finding.id) ?? []), finding]); + } + for (const [id, findings] of [...provenanceByPackage.entries()].sort(([a], [b]) => a.localeCompare(b))) { + const reasons = [...new Set(findings.map((finding) => finding.reason))].join(', '); + const targets = [...new Set(findings.map((finding) => finding.target))].slice(0, 3).join(', '); + add( + checks, + 'Packages', + `Package ${id} source`, + 'warn', + `${findings.length} untrusted URL(s): ${reasons}${targets ? ` (${targets})` : ''}`, + `Review feather.lock.json; repair only if trusted with \`feather package install --dir ${projectDirArg} --allow-untrusted\`.`, + ); + } + for (const [id, entry] of Object.entries(lockfile.packages).sort(([a], [b]) => a.localeCompare(b))) { if (entry.trust === 'experimental') continue; diff --git a/cli/src/commands/package/install.ts b/cli/src/commands/package/install.ts index 5140d864..1c057877 100644 --- a/cli/src/commands/package/install.ts +++ b/cli/src/commands/package/install.ts @@ -1,6 +1,7 @@ import { auditLockfile } from '../../lib/package/audit.js'; import { installFromUrl, restorePackage } from '../../lib/package/install.js'; import { readLockfile, writeLockfile } from '../../lib/package/lockfile.js'; +import { lockfileEntryRequiresUntrustedRepair, lockfileEntrySourceSummary } from '../../lib/package/provenance.js'; import { resolveMany } from '../../lib/package/resolve.js'; import { fail } from '../../lib/command.js'; import { @@ -127,6 +128,33 @@ export async function packageInstallCommand(names: string[], opts: PackageInstal return; } + const untrustedRepairs = entries.filter(([id, entry]) => broken.has(id) && lockfileEntryRequiresUntrustedRepair(id, entry)); + if (untrustedRepairs.length > 0 && !opts.allowUntrusted) { + printWarning('Repairing untrusted or experimental package sources'); + printKeyValues(untrustedRepairs.map(([id, entry]) => [ + id, + `${entry.version} (${lockfileEntrySourceSummary(id, entry)})`, + ])); + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + printBlank(); + printDanger('Use --allow-untrusted to repair these lockfile entries in non-interactive mode.'); + fail('', { silent: true }); + } + + const confirmed = await confirmAction({ + title: 'feather package install', + label: 'Repair these unreviewed package sources?', + hint: 'Only continue if you trust the lockfile sources and target paths.', + danger: true, + rows: untrustedRepairs.map(([id, entry]) => `${id}: ${lockfileEntrySourceSummary(id, entry)}`), + }); + if (!confirmed) { + printMuted('Install cancelled.'); + return; + } + } + let failed = false; for (const [id, entry] of entries) { if (!broken.has(id)) { diff --git a/cli/src/lib/package/install.ts b/cli/src/lib/package/install.ts index 3dc3f357..92cec26c 100644 --- a/cli/src/lib/package/install.ts +++ b/cli/src/lib/package/install.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { sha256Buffer } from "./checksum.js"; import { addToLockfile, type Lockfile, type LockfileEntry } from "./lockfile.js"; +import { lockfileFileUrl } from "./provenance.js"; import { resolveProjectTarget } from "./target.js"; import type { ResolvedPackage } from "./resolve.js"; @@ -144,7 +145,11 @@ export async function installPackage( parent: pkg.entry.parent, version: effectiveTag, trust: pkg.versionOverride ? "experimental" : pkg.entry.trust, - source: { repo: src.repo ?? pkg.id, tag: effectiveTag }, + source: { + repo: src.repo ?? pkg.id, + tag: effectiveTag, + ...(!pkg.versionOverride && src.commitSha ? { resolvedRef: src.commitSha, commitSha: src.commitSha } : {}), + }, files: lockedFiles, }); } @@ -197,8 +202,8 @@ export async function installFromUrl( addToLockfile(lockfile, name.replace(/\.lua$/, ""), { version: "0.0.0", trust: "experimental", - source: { url }, - files: [{ name, target, sha256: hash }], + source: { kind: "url", url, urls: [url] }, + files: [{ name, url, target, sha256: hash }], }); return { ok: true, sha256: hash, size: buf.byteLength, target }; @@ -251,10 +256,7 @@ export async function restorePackage( onFileStart?.(file.name); - const url = file.url - ?? ("url" in entry.source - ? entry.source.url - : `https://raw.githubusercontent.com/${entry.source.repo}/${entry.source.tag}/${file.name}`); + const url = lockfileFileUrl(entry, file); const result = await downloadVerified(url, file.sha256); if ("error" in result) { diff --git a/cli/src/lib/package/provenance.ts b/cli/src/lib/package/provenance.ts new file mode 100644 index 00000000..ff7e9ddf --- /dev/null +++ b/cli/src/lib/package/provenance.ts @@ -0,0 +1,89 @@ +import type { Lockfile, LockfileEntry } from "./lockfile.js"; + +export type PackageUrlTrust = { + trusted: boolean; + reason?: string; + host?: string; +}; + +export type LockfileUrlFinding = { + id: string; + name: string; + target: string; + url: string; + reason: string; +}; + +const TRUSTED_PACKAGE_URL_HOSTS = new Set(["raw.githubusercontent.com"]); + +export function lockfileFileUrl(entry: LockfileEntry, file: LockfileEntry["files"][number]): string { + if (file.url) return file.url; + if ("url" in entry.source) return entry.source.url; + + const ref = entry.source.resolvedRef ?? entry.source.commitSha ?? entry.source.tag; + return `https://raw.githubusercontent.com/${entry.source.repo}/${ref}/${file.name}`; +} + +export function assessPackageUrl(url: string): PackageUrlTrust { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return { trusted: false, reason: "invalid URL" }; + } + + if (parsed.protocol !== "https:") { + return { trusted: false, reason: `${parsed.protocol || "unknown"} URL`, host: parsed.host || undefined }; + } + + if (!TRUSTED_PACKAGE_URL_HOSTS.has(parsed.hostname)) { + return { trusted: false, reason: `untrusted host ${parsed.hostname}`, host: parsed.hostname }; + } + + return { trusted: true, host: parsed.hostname }; +} + +export function lockfileEntryUrlFindings(id: string, entry: LockfileEntry): LockfileUrlFinding[] { + const findings: LockfileUrlFinding[] = []; + + for (const file of entry.files) { + const url = lockfileFileUrl(entry, file); + const trust = assessPackageUrl(url); + if (!trust.trusted) { + findings.push({ + id, + name: file.name, + target: file.target, + url, + reason: trust.reason ?? "untrusted URL", + }); + } + } + + return findings; +} + +export function lockfileUrlFindings(lockfile: Lockfile): LockfileUrlFinding[] { + return Object.entries(lockfile.packages).flatMap(([id, entry]) => lockfileEntryUrlFindings(id, entry)); +} + +export function lockfileEntryRequiresUntrustedRepair(id: string, entry: LockfileEntry): boolean { + return entry.trust === "experimental" || lockfileEntryUrlFindings(id, entry).length > 0; +} + +function packageUrlSummary(url: string): string { + try { + const parsed = new URL(url); + if (parsed.host) return `${parsed.protocol}//${parsed.host}`; + return `${parsed.protocol} URL`; + } catch { + return "invalid URL"; + } +} + +export function lockfileEntrySourceSummary(id: string, entry: LockfileEntry): string { + if ("url" in entry.source) return packageUrlSummary(entry.source.url); + if (entry.source.commitSha) return `${entry.source.repo}@${entry.source.commitSha}`; + if (entry.source.resolvedRef) return `${entry.source.repo}@${entry.source.resolvedRef}`; + return `${entry.source.repo}@${entry.source.tag || id}`; +} diff --git a/cli/test/package.test.mjs b/cli/test/package.test.mjs index 3b11c3be..187b0949 100644 --- a/cli/test/package.test.mjs +++ b/cli/test/package.test.mjs @@ -561,6 +561,41 @@ test('install (no args): url package already on disk with correct hash is skippe assert.ok(stdout.includes('up to date')); }); +test('install (no args): broken untrusted url package requires explicit repair consent', () => { + const dir = makeTmp(); + const content = 'return {}\n'; + writeLock(dir, { + helper: { + version: 'url', + trust: 'experimental', + source: { kind: 'url', url: 'data:text/plain,return%20%7B%7D%0A', urls: ['data:text/plain,return%20%7B%7D%0A'] }, + files: [{ name: 'helper.lua', target: 'lib/helper.lua', sha256: sha256(content) }], + }, + }); + + const result = run(['package', 'install', '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('--allow-untrusted')); + assert.equal(existsSync(join(dir, 'lib', 'helper.lua')), false); +}); + +test('install (no args): --allow-untrusted repairs broken url package', () => { + const dir = makeTmp(); + const content = 'return {}\n'; + writeLock(dir, { + helper: { + version: 'url', + trust: 'experimental', + source: { kind: 'url', url: 'data:text/plain,return%20%7B%7D%0A', urls: ['data:text/plain,return%20%7B%7D%0A'] }, + files: [{ name: 'helper.lua', target: 'lib/helper.lua', sha256: sha256(content) }], + }, + }); + + const result = run(['package', 'install', '--dir', dir, '--allow-untrusted']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(readFileSync(join(dir, 'lib', 'helper.lua'), 'utf8'), content); +}); + test('remove: url package removes file and lockfile entry', () => { const dir = makeTmp(); mkdirSync(join(dir, 'lib'), { recursive: true }); @@ -911,6 +946,36 @@ test('doctor --json reports package file recovery and stale bundled registry ver assert.equal(labels.has('Package helper version'), false); }); +test('doctor --json reports untrusted lockfile source URLs', () => { + const dir = makeTmp(); + writeGame(dir); + const commitSha = '0123456789abcdef0123456789abcdef01234567'; + writeLock(dir, { + helper: { + version: 'url', + trust: 'experimental', + source: { kind: 'url', url: 'https://example.com/helper.lua', urls: ['https://example.com/helper.lua'] }, + files: [{ name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: sha256('expected') }], + }, + 'raw-helper': { + version: 'main', + trust: 'experimental', + source: { repo: 'me/pkg', tag: 'main', resolvedRef: commitSha, commitSha }, + files: [{ name: 'raw.lua', target: 'lib/raw.lua', sha256: sha256('expected') }], + }, + }); + + const result = run(['doctor', dir, '--json']); + const parsed = JSON.parse(result.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + const sourceCheck = labels.get('Package helper source'); + + assert.equal(sourceCheck.severity, 'warn'); + assert.ok(sourceCheck.detail.includes('example.com')); + assert.ok(sourceCheck.fix.includes('--allow-untrusted')); + assert.equal(labels.has('Package raw-helper source'), false); +}); + test('doctor --json reports non-experimental packages missing from bundled registry', () => { const dir = makeTmp(); writeGame(dir); From 6c1af67046fdfb59fc56a5dafde2a724254c3a06 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 02:05:02 -0400 Subject: [PATCH 37/68] cli: Auditability and sterile output --- cli/src/commands/doctor/checks.ts | 1 + cli/src/commands/doctor/index.ts | 132 ++++++++++++++++++++++------ cli/src/commands/doctor/security.ts | 118 +++++++++++++++++++++++++ cli/src/index.ts | 2 + cli/src/lib/command.ts | 10 +-- cli/src/lib/output.ts | 5 +- cli/src/lib/package/provenance.ts | 2 +- cli/src/lib/redact.ts | 21 +++++ cli/test/commands.test.mjs | 57 ++++++++++++ 9 files changed, 315 insertions(+), 33 deletions(-) create mode 100644 cli/src/commands/doctor/security.ts create mode 100644 cli/src/lib/redact.ts diff --git a/cli/src/commands/doctor/checks.ts b/cli/src/commands/doctor/checks.ts index 7c35c167..148c451b 100644 --- a/cli/src/commands/doctor/checks.ts +++ b/cli/src/commands/doctor/checks.ts @@ -20,6 +20,7 @@ export type DoctorOptions = { port?: number; json?: boolean; production?: boolean; + security?: boolean; }; export const severityOrder: Record = { diff --git a/cli/src/commands/doctor/index.ts b/cli/src/commands/doctor/index.ts index ae4493b0..e47904e7 100644 --- a/cli/src/commands/doctor/index.ts +++ b/cli/src/commands/doctor/index.ts @@ -8,9 +8,9 @@ import { printJson } from '../../lib/output.js'; import { findSymlinkEscapes } from '../../lib/path-safety.js'; import { auditLockfile } from '../../lib/package/audit.js'; import { readLockfile } from '../../lib/package/lockfile.js'; -import { lockfileUrlFindings } from '../../lib/package/provenance.js'; +import { lockfileEntrySourceSummary, lockfileUrlFindings } from '../../lib/package/provenance.js'; import { loadRegistry } from '../../lib/package/registry.js'; -import { classifyPluginTrust, dangerousPluginIds, parseManagedValue, findInstalledPluginDirs, pluginTrustLabel, readPluginManifest } from '../../lib/plugin-utils.js'; +import { classifyPluginTrust, dangerousPluginIds, parseManagedValue, findInstalledPluginDirs, pluginTrustLabel, readPluginManifest, type PluginTrust } from '../../lib/plugin-utils.js'; import { pluginCatalog } from '../../generated/plugin-catalog.js'; import { add, @@ -32,6 +32,7 @@ import { type DoctorOptions, } from './checks.js'; import { renderReport } from './report.js'; +import { buildSecurityReport, securityChecks, type SecurityPackageReport, type SecurityPluginReport } from './security.js'; export type { DoctorOptions } from './checks.js'; @@ -40,6 +41,24 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) const installDir = normalizeInstallDir(opts.installDir ?? 'feather'); const checks: DoctorCheck[] = []; const productionSeverity = (unsafe: boolean): DoctorCheck['severity'] => unsafe ? (opts.production ? 'fail' : 'warn') : 'pass'; + let symlinkEscapeCount = 0; + let consoleIncluded = false; + let weakApiKey = true; + let insecureConnection = false; + let appIdMissing = true; + let captureScreenshot = false; + let hotReloadEnabled = false; + let broadHotReload = false; + let hotReloadPersistence = false; + let debuggerEnabled = false; + let writeToDisk = false; + let weakNetworkAuth = true; + let networkExposure: 'loopback' | 'wildcard' | 'lan' | 'non-loopback' = 'loopback'; + const missingIncludedPlugins: string[] = []; + const unknownIncludedPlugins: string[] = []; + const installedPluginReports: SecurityPluginReport[] = []; + const packageReports: SecurityPackageReport[] = []; + let packageProvenanceFindings: ReturnType = []; const nodeVersion = process.versions.node; const [major] = nodeVersion.split('.').map(Number); @@ -165,6 +184,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) let pluginRootUnsafe = false; if (hasProjectDir) { const symlinkEscapes = findSymlinkEscapes(projectDir, [runtimeDir, runtimePluginRoot, sourceTreePluginRoot]); + symlinkEscapeCount = symlinkEscapes.length; pluginRootUnsafe = symlinkEscapes.some((escape) => resolve(pluginRoot).startsWith(resolve(escape.path))); const seenEscapes = new Set(); for (const escape of symlinkEscapes) { @@ -185,6 +205,16 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) const installedPlugins = buildPluginIndex(pluginDirs); const knownPluginIds = new Set(pluginCatalog.map((plugin) => plugin.id)); const pluginCatalogById = new Map(pluginCatalog.map((plugin) => [plugin.id, plugin])); + for (const dir of pluginDirs) { + const manifest = readPluginManifest(dir); + const trust: PluginTrust = classifyPluginTrust(manifest, manifest?.id ? pluginCatalogById.get(manifest.id) : null); + installedPluginReports.push({ + id: manifest?.id || dir.split(/[\\/]/).pop() || 'unknown', + trust, + dangerous: Boolean(manifest?.id && dangerousPluginIds.has(manifest.id)), + malformed: trust === 'malformed', + }); + } const projectDirArg = shellArg(projectDir); const installDirArg = shellArg(effectiveInstallDir); if (hasRuntime) { @@ -247,6 +277,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) for (const id of activeIncludedPluginIds) { if (!knownPluginIds.has(id)) { + unknownIncludedPlugins.push(id); add( checks, 'Plugins', @@ -259,6 +290,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) } if (!installedPlugins.has(id)) { + missingIncludedPlugins.push(id); add( checks, 'Plugins', @@ -292,7 +324,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) } if (configSource) { - const insecureConnection = luaBoolEnabled(activeConfigSource, '__DANGEROUS_INSECURE_CONNECTION__'); + insecureConnection = luaBoolEnabled(activeConfigSource, '__DANGEROUS_INSECURE_CONNECTION__'); add( checks, 'Safety', @@ -303,7 +335,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) ); const appId = typeof config?.appId === 'string' ? config.appId.trim() : ''; - const appIdMissing = mode !== 'disk' && !appId; + appIdMissing = mode !== 'disk' && !appId; add( checks, 'Safety', @@ -313,7 +345,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) appIdMissing ? 'Set appId in feather.config.lua before shipping socket/network builds.' : undefined, ); - const captureScreenshot = luaBoolEnabled(activeConfigSource, 'captureScreenshot'); + captureScreenshot = luaBoolEnabled(activeConfigSource, 'captureScreenshot'); add( checks, 'Safety', @@ -323,10 +355,10 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) captureScreenshot ? 'Set captureScreenshot = false in feather.config.lua unless you need visual error context.' : undefined, ); - const hotReloadEnabled = /hotReload\s*=\s*\{[\s\S]*?enabled\s*=\s*true/.test(activeConfigSource); + hotReloadEnabled = /hotReload\s*=\s*\{[\s\S]*?enabled\s*=\s*true/.test(activeConfigSource); const hotReloadPluginIncluded = hasConfigArrayValue(activeConfigSource, 'include', 'hot-reload') || pluginDirs.some((dir) => readPluginManifest(dir)?.id === 'hot-reload'); - const persistToDisk = /hotReload\s*=\s*\{[\s\S]*?persistToDisk\s*=\s*true/.test(activeConfigSource); - const broadHotReload = /allow\s*=\s*\{[\s\S]*["'][^"']+\.\*["']/.test(activeConfigSource); + hotReloadPersistence = /hotReload\s*=\s*\{[\s\S]*?persistToDisk\s*=\s*true/.test(activeConfigSource); + broadHotReload = /allow\s*=\s*\{[\s\S]*["'][^"']+\.\*["']/.test(activeConfigSource); add( checks, 'Safety', @@ -348,24 +380,25 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) `Run \`feather plugin install hot-reload --dir ${projectDirArg} --install-dir ${installDirArg}\`, or remove debugger.hotReload from feather.config.lua.`, ); } - if (hotReloadEnabled && persistToDisk) { + if (hotReloadEnabled && hotReloadPersistence) { add(checks, 'Safety', 'Hot reload persistence', opts.production ? 'fail' : 'warn', 'persistToDisk=true', 'Set debugger.hotReload.persistToDisk = false in feather.config.lua.'); } - const consoleIncluded = hasConfigArrayValue(activeConfigSource, 'include', 'console') || pluginDirs.some((dir) => readPluginManifest(dir)?.id === 'console'); + consoleIncluded = hasConfigArrayValue(activeConfigSource, 'include', 'console') || pluginDirs.some((dir) => readPluginManifest(dir)?.id === 'console'); if (consoleIncluded) { const apiKey = config?.apiKey; + weakApiKey = isWeakApiKey(apiKey); add( checks, 'Safety', 'Console API key', - isWeakApiKey(apiKey) ? (opts.production ? 'fail' : 'warn') : 'pass', - isWeakApiKey(apiKey) ? 'missing or weak' : 'configured', - isWeakApiKey(apiKey) ? 'Set apiKey to a strong per-session secret in feather.config.lua when using Console.' : undefined, + weakApiKey ? (opts.production ? 'fail' : 'warn') : 'pass', + weakApiKey ? 'missing or weak' : 'configured', + weakApiKey ? 'Set apiKey to a strong per-session secret in feather.config.lua when using Console.' : undefined, ); } - const debuggerEnabled = luaBoolEnabled(activeConfigSource, 'debugger') || /debugger\s*=\s*\{[\s\S]*?enabled\s*=\s*true/.test(activeConfigSource); + debuggerEnabled = luaBoolEnabled(activeConfigSource, 'debugger') || /debugger\s*=\s*\{[\s\S]*?enabled\s*=\s*true/.test(activeConfigSource); add( checks, 'Safety', @@ -375,7 +408,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) debuggerEnabled ? 'Set debugger = false in feather.config.lua unless this is a trusted development session.' : undefined, ); - const writeToDisk = luaBoolEnabled(activeConfigSource, 'writeToDisk'); + writeToDisk = luaBoolEnabled(activeConfigSource, 'writeToDisk'); add( checks, 'Safety', @@ -402,6 +435,14 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) const auditResults = await auditLockfile(projectDir, lockfile); const badPackages = new Set(auditResults.filter((result) => result.status !== 'verified').map((result) => result.id)); const registry = await loadRegistry({ offline: true }); + for (const [id, entry] of Object.entries(lockfile.packages).sort(([a], [b]) => a.localeCompare(b))) { + packageReports.push({ + id, + version: entry.version, + trust: entry.trust, + source: lockfileEntrySourceSummary(id, entry), + }); + } add( checks, 'Packages', @@ -436,9 +477,9 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) ); } - const provenanceFindings = lockfileUrlFindings(lockfile); - const provenanceByPackage = new Map(); - for (const finding of provenanceFindings) { + packageProvenanceFindings = lockfileUrlFindings(lockfile); + const provenanceByPackage = new Map(); + for (const finding of packageProvenanceFindings) { provenanceByPackage.set(finding.id, [...(provenanceByPackage.get(finding.id) ?? []), finding]); } for (const [id, findings] of [...provenanceByPackage.entries()].sort(([a], [b]) => a.localeCompare(b))) { @@ -494,7 +535,8 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) const wildcardHost = isWildcardHost(configuredHost); const lanHost = isLanHost(configuredHost); const loopbackHost = isLoopbackHost(configuredHost); - const weakNetworkAuth = !configSource || luaBoolEnabled(activeConfigSource, '__DANGEROUS_INSECURE_CONNECTION__') || !config?.appId; + networkExposure = wildcardHost ? 'wildcard' : lanHost ? 'lan' : loopbackHost ? 'loopback' : 'non-loopback'; + weakNetworkAuth = !configSource || luaBoolEnabled(activeConfigSource, '__DANGEROUS_INSECURE_CONNECTION__') || !config?.appId; add( checks, 'Safety', @@ -511,12 +553,12 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) ); } - if (opts.production && hasRuntime && (!configSource || !managedMode)) { + if ((opts.production || opts.security) && hasRuntime && (!configSource || !managedMode)) { add( checks, 'Safety', 'Managed runtime', - 'fail', + opts.production ? 'fail' : 'warn', 'runtime present without managed init metadata', 'Run `feather init` to regenerate managed metadata, or remove the embedded Feather runtime before shipping.', ); @@ -536,13 +578,53 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) ); } - const failures = checks.filter((check) => check.severity === 'fail'); - const warnings = checks.filter((check) => check.severity === 'warn'); + const displayedChecks = opts.security ? securityChecks(checks) : checks; + const failures = displayedChecks.filter((check) => check.severity === 'fail'); + const warnings = displayedChecks.filter((check) => check.severity === 'warn'); + const securityReport = opts.security + ? buildSecurityReport({ + checks, + mode, + installDir: effectiveInstallDir, + host: configuredHost, + port: configuredPort, + appIdMissing, + consoleIncluded, + weakApiKey, + insecureConnection, + captureScreenshot, + hotReloadEnabled, + broadHotReload, + hotReloadPersistence, + debuggerEnabled, + writeToDisk, + networkExposure, + weakNetworkAuth, + runtimeEmbedded: hasRuntime, + runtimeManaged: Boolean(managedMode), + symlinkEscapes: symlinkEscapeCount, + includedPlugins: activeIncludedPluginIds, + missingIncludedPlugins, + unknownIncludedPlugins, + installedPlugins: installedPluginReports, + packages: packageReports, + untrustedPackageSources: packageProvenanceFindings, + }) + : undefined; if (opts.json) { - printJson({ projectDir, installDir: effectiveInstallDir, production: Boolean(opts.production), failures: failures.length, warnings: warnings.length, checks }); + printJson({ + projectDir, + installDir: effectiveInstallDir, + production: Boolean(opts.production), + security: Boolean(opts.security), + failures: failures.length, + warnings: warnings.length, + checks: displayedChecks, + ...(securityReport ? { report: securityReport } : {}), + }); } else { - renderReport(checks, projectDir); + renderReport(displayedChecks, projectDir); } if (failures.length > 0) { diff --git a/cli/src/commands/doctor/security.ts b/cli/src/commands/doctor/security.ts new file mode 100644 index 00000000..cc3b974d --- /dev/null +++ b/cli/src/commands/doctor/security.ts @@ -0,0 +1,118 @@ +import type { PluginTrust } from '../../lib/plugin-utils.js'; +import type { LockfileUrlFinding } from '../../lib/package/provenance.js'; +import { packageUrlSummary } from '../../lib/package/provenance.js'; +import type { DoctorCheck } from './checks.js'; + +export type SecurityPluginReport = { + id: string; + trust: PluginTrust; + dangerous: boolean; + malformed: boolean; +}; + +export type SecurityPackageReport = { + id: string; + version: string; + trust: string; + source: string; +}; + +export type SecurityReportInput = { + checks: DoctorCheck[]; + mode: string; + installDir: string; + host: string; + port: number; + appIdMissing: boolean; + consoleIncluded: boolean; + weakApiKey: boolean; + insecureConnection: boolean; + captureScreenshot: boolean; + hotReloadEnabled: boolean; + broadHotReload: boolean; + hotReloadPersistence: boolean; + debuggerEnabled: boolean; + writeToDisk: boolean; + networkExposure: 'loopback' | 'wildcard' | 'lan' | 'non-loopback'; + weakNetworkAuth: boolean; + runtimeEmbedded: boolean; + runtimeManaged: boolean; + symlinkEscapes: number; + includedPlugins: string[]; + missingIncludedPlugins: string[]; + unknownIncludedPlugins: string[]; + installedPlugins: SecurityPluginReport[]; + packages: SecurityPackageReport[]; + untrustedPackageSources: LockfileUrlFinding[]; +}; + +export function isSecurityCheck(check: DoctorCheck): boolean { + return ( + check.group === 'Safety' + || check.group === 'Plugins' + || check.group === 'Packages' + || check.group === 'Runtime' + || check.label === 'feather.config.lua' + ); +} + +export function securityChecks(checks: DoctorCheck[]): DoctorCheck[] { + return checks.filter(isSecurityCheck); +} + +export function buildSecurityReport(input: SecurityReportInput): Record { + const checks = securityChecks(input.checks); + const failures = checks.filter((check) => check.severity === 'fail').length; + const warnings = checks.filter((check) => check.severity === 'warn').length; + + return { + summary: { + failures, + warnings, + dangerousPlugins: input.installedPlugins.filter((plugin) => plugin.dangerous).length, + untrustedPackageSources: input.untrustedPackageSources.length, + }, + config: { + mode: input.mode, + appId: input.appIdMissing ? 'missing' : input.mode === 'disk' ? 'not-required' : 'configured', + apiKeyStatus: input.consoleIncluded ? (input.weakApiKey ? 'weak-or-missing' : 'configured') : 'not-required', + insecureConnection: input.insecureConnection, + consoleIncluded: input.consoleIncluded, + captureScreenshot: input.captureScreenshot, + hotReloadEnabled: input.hotReloadEnabled, + hotReloadWildcardAllowlist: input.broadHotReload, + hotReloadPersistence: input.hotReloadPersistence, + stepDebugger: input.debuggerEnabled, + writeToDisk: input.writeToDisk, + }, + network: { + host: input.host, + port: input.port, + exposure: input.networkExposure, + weakAuth: input.weakNetworkAuth, + }, + runtime: { + embedded: input.runtimeEmbedded, + managed: input.runtimeManaged, + installDir: input.installDir, + symlinkEscapes: input.symlinkEscapes, + }, + plugins: { + included: input.includedPlugins, + missingIncluded: input.missingIncludedPlugins, + unknownIncluded: input.unknownIncludedPlugins, + installed: input.installedPlugins, + }, + packages: { + total: input.packages.length, + experimental: input.packages.filter((pkg) => pkg.trust === 'experimental').length, + installed: input.packages, + untrustedSources: input.untrustedPackageSources.map((finding) => ({ + id: finding.id, + target: finding.target, + source: packageUrlSummary(finding.url), + reason: finding.reason, + })), + }, + }; +} diff --git a/cli/src/index.ts b/cli/src/index.ts index dd4bd8ac..b492af23 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -112,12 +112,14 @@ program .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') .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, }))); program diff --git a/cli/src/lib/command.ts b/cli/src/lib/command.ts index a069514c..9c1486e4 100644 --- a/cli/src/lib/command.ts +++ b/cli/src/lib/command.ts @@ -1,4 +1,5 @@ import { statusLine, style } from './output.js'; +import { redactSensitiveText } from './redact.js'; export type CliErrorOptions = { exitCode?: number; @@ -33,19 +34,18 @@ export async function runCliAction(action: () => Promise | void | } catch (err) { if (err instanceof CliError) { if (!err.silent && err.message) { - console.error(statusLine('error', err.message)); - for (const detail of err.details) console.error(style.muted(` ${detail}`)); + console.error(statusLine('error', redactSensitiveText(err.message))); + for (const detail of err.details) console.error(style.muted(` ${redactSensitiveText(detail)}`)); } process.exitCode = err.exitCode; return; } const message = err instanceof Error ? err.message : String(err); - console.error(statusLine('error', message || 'Unexpected error')); + console.error(statusLine('error', redactSensitiveText(message || 'Unexpected error'))); if (process.env.FEATHER_DEBUG === '1' && err instanceof Error && err.stack) { - console.error(style.muted(err.stack)); + console.error(style.muted(redactSensitiveText(err.stack))); } process.exitCode = 1; } } - diff --git a/cli/src/lib/output.ts b/cli/src/lib/output.ts index 8baab376..ab8b89cb 100644 --- a/cli/src/lib/output.ts +++ b/cli/src/lib/output.ts @@ -1,5 +1,6 @@ import chalk from 'chalk'; import ora, { type Ora } from 'ora'; +import { redactSensitiveText, redactSensitiveValue } from './redact.js'; export const icon = { success: chalk.green('✔'), @@ -27,11 +28,11 @@ export function statusLine(kind: keyof typeof icon, message: string): string { } export function printJson(value: unknown): void { - console.log(JSON.stringify(value, null, 2)); + console.log(JSON.stringify(redactSensitiveValue(value), null, 2)); } export function printLine(message = ''): void { - console.log(message); + console.log(redactSensitiveText(message)); } export function printBlank(): void { diff --git a/cli/src/lib/package/provenance.ts b/cli/src/lib/package/provenance.ts index ff7e9ddf..1b21ae9e 100644 --- a/cli/src/lib/package/provenance.ts +++ b/cli/src/lib/package/provenance.ts @@ -71,7 +71,7 @@ export function lockfileEntryRequiresUntrustedRepair(id: string, entry: Lockfile return entry.trust === "experimental" || lockfileEntryUrlFindings(id, entry).length > 0; } -function packageUrlSummary(url: string): string { +export function packageUrlSummary(url: string): string { try { const parsed = new URL(url); if (parsed.host) return `${parsed.protocol}//${parsed.host}`; diff --git a/cli/src/lib/redact.ts b/cli/src/lib/redact.ts new file mode 100644 index 00000000..27c93bbd --- /dev/null +++ b/cli/src/lib/redact.ts @@ -0,0 +1,21 @@ +const SENSITIVE_KEY_RE = /^(?:apiKey|api_key|apikey|authorization|password|secret|token)$/i; +const SENSITIVE_ASSIGNMENT_RE = + /\b(apiKey|api_key|apikey|authorization|password|secret|token)\b(\s*[:=]\s*)(["']?)([^"',\s}\]]+)(\3)/gi; + +export function redactSensitiveText(value: string): string { + return value.replace(SENSITIVE_ASSIGNMENT_RE, (_match, key: string, separator: string, quote: string) => { + return `${key}${separator}${quote}[redacted]${quote}`; + }); +} + +export function redactSensitiveValue(value: T): T { + if (typeof value === "string") return redactSensitiveText(value) as T; + if (Array.isArray(value)) return value.map((item) => redactSensitiveValue(item)) as T; + if (!value || typeof value !== "object") return value; + + const output: Record = {}; + for (const [key, item] of Object.entries(value as Record)) { + output[key] = SENSITIVE_KEY_RE.test(key) ? "[redacted]" : redactSensitiveValue(item); + } + return output as T; +} diff --git a/cli/test/commands.test.mjs b/cli/test/commands.test.mjs index 6fac35b8..2c103bba 100644 --- a/cli/test/commands.test.mjs +++ b/cli/test/commands.test.mjs @@ -23,6 +23,7 @@ import { fileURLToPath } from 'node:url'; const CLI = fileURLToPath(new URL('../dist/index.js', import.meta.url)); const ROOT = fileURLToPath(new URL('../..', import.meta.url)); const LOCAL_SRC = join(ROOT, 'src-lua'); +// eslint-disable-next-line no-control-regex const ANSI_RE = /\x1B\[[0-?]*[ -/]*[@-~]/; const sha256 = (value) => createHash('sha256').update(value).digest('hex'); @@ -476,6 +477,36 @@ test('doctor --json keeps unsafe settings warning-oriented outside production', assert.equal(labels.get('Network host exposure')?.severity, 'warn'); }); +test('doctor --security --json emits a sterile security report without secrets', () => { + const dir = makeTmp(); + const secret = 'StrongSecretValue1234567890!'; + writeGame(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + `return { + appId = "feather-app-test-1234567890", + host = "127.0.0.1", + include = { "console" }, + apiKey = "${secret}", +} +`, + ); + + const result = run(['doctor', dir, '--security', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + assert.equal(result.stdout.includes('✔'), false); + assert.equal(result.stdout.includes(secret), false); + + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.security, true); + assert.equal(parsed.report.config.apiKeyStatus, 'configured'); + assert.equal(parsed.report.config.consoleIncluded, true); + assert.equal(parsed.report.network.exposure, 'loopback'); + assert.ok(parsed.checks.every((check) => ['Safety', 'Plugins', 'Packages', 'Runtime', 'Project'].includes(check.group))); + assert.equal(parsed.checks.some((check) => check.label === 'Node.js'), false); +}); + test('doctor --production fails unmanaged embedded runtime', () => { const dir = makeTmp(); writeGame(dir); @@ -489,6 +520,32 @@ test('doctor --production fails unmanaged embedded runtime', () => { assert.ok(labels.get('Managed runtime')?.fix.includes('feather init')); }); +test('command runtime redacts API keys from compact and debug errors', async () => { + const { runCliAction } = await import('../dist/lib/command.js'); + const originalError = console.error; + const previousExitCode = process.exitCode; + const previousDebug = process.env.FEATHER_DEBUG; + const secret = 'StrongSecretValue1234567890!'; + const lines = []; + process.env.FEATHER_DEBUG = '1'; + process.exitCode = undefined; + console.error = (line = '') => lines.push(String(line)); + try { + await runCliAction(async () => { + throw new Error(`Failed to parse config: apiKey = "${secret}"`); + }); + const output = lines.join('\n'); + assert.equal(process.exitCode, 1); + assert.equal(output.includes(secret), false); + assert.ok(output.includes('apiKey = "[redacted]"')); + } finally { + console.error = originalError; + process.exitCode = previousExitCode; + if (previousDebug === undefined) delete process.env.FEATHER_DEBUG; + else process.env.FEATHER_DEBUG = previousDebug; + } +}); + test('command runtime: unexpected errors render compact stderr and exit 1', async () => { const { runCliAction } = await import('../dist/lib/command.js'); const originalError = console.error; From 701623a8c3d188303ae6e6faeca3ba7c960f329e Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 02:09:11 -0400 Subject: [PATCH 38/68] docs: improve documentation --- cli/README.md | 10 ++++++++++ docs/configuration.md | 22 ++++++++++++++++++++++ docs/index.md | 1 + docs/installation.md | 12 ++++++++++++ docs/recommendations.md | 20 ++++++++++++++++++-- packages/README.md | 20 ++++++++++++++++++++ 6 files changed, 83 insertions(+), 2 deletions(-) diff --git a/cli/README.md b/cli/README.md index 4f6c6e1d..929650a0 100644 --- a/cli/README.md +++ b/cli/README.md @@ -282,6 +282,8 @@ feather doctor path/to/my-game feather doctor . --install-dir lib/feather feather doctor . --host 127.0.0.1 --port 4004 feather doctor . --json +feather doctor . --production +feather doctor . --security --json ``` Doctor checks: @@ -290,6 +292,8 @@ Doctor checks: - `main.lua`, `feather.config.lua`, and managed init metadata - embedded runtime files for auto/manual setups - installed plugin manifests +- missing, unknown, malformed, or development-only plugins +- package lockfile integrity, version drift, and source provenance - `USE_DEBUGGER` guards and `FEATHER-INIT` markers - risky settings such as hot reload, screenshot capture, and Console API keys - Feather desktop WebSocket reachability @@ -297,6 +301,12 @@ Doctor checks: > [!TIP] > `feather doctor --json` is useful in CI or pre-release scripts. It exits with a nonzero status only when it finds blockers. +Use `--production` as a release gate. It fails on production-dangerous settings such as `__DANGEROUS_INSECURE_CONNECTION__ = true`, Console with a weak or missing `apiKey`, hot reload, broad hot reload allowlists, debugger/screenshot/disk persistence settings, wildcard or LAN-facing hosts with weak auth, and unmanaged embedded Feather runtime. + +Use `--security --json` when automation needs a security-focused report without environment noise. It emits JSON only, filters checks to security-relevant groups, and includes config posture, network exposure, runtime management, plugin trust, and package provenance. + +Sensitive values such as `apiKey`, tokens, secrets, and passwords are redacted from human output, JSON output, compact errors, and `FEATHER_DEBUG=1` stack output. + **Example output:** ``` diff --git a/docs/configuration.md b/docs/configuration.md index 78900057..e724a17b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -73,6 +73,15 @@ See [Hot Reload](hot-reload.md) for the full workflow. Feather uses a **nonce-based challenge-response handshake** to authenticate each connection before any game data is exchanged. +The CLI can audit these settings: + +```bash +feather doctor path/to/my-game --production +feather doctor path/to/my-game --security --json +``` + +`--production` fails on unsafe release settings. `--security --json` emits a machine-readable security report and redacts sensitive values such as `apiKey`. + ### How it works 1. When the game connects, the desktop immediately sends a one-time challenge nonce. @@ -112,6 +121,19 @@ The desktop will accept the connection and show an insecure badge on the session > [!CAUTION] > Insecure mode allows **any** Feather desktop on the network to send commands to your game, including triggering the console plugin if installed. Do not ship production builds with insecure mode enabled. +### Console API key + +The Console plugin can evaluate Lua inside the running game. If `console` is included, configure a strong `apiKey` and match it in Feather desktop Settings: + +```lua +return { + include = { "console" }, + apiKey = "a-long-random-per-session-secret", +} +``` + +Doctor reports whether the key is configured or weak, but does not print the key value. + --- ## Connecting diff --git a/docs/index.md b/docs/index.md index e8b95b63..0887014c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -81,6 +81,7 @@ end Before shipping a production build: ```bash +feather doctor path/to/my-game --production feather remove --dry-run feather remove --yes ``` diff --git a/docs/installation.md b/docs/installation.md index 9507c411..72ccd1ea 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -49,6 +49,18 @@ USE_DEBUGGER=1 love . See [CLI](cli.md) for all commands, flags, and `feather.config.lua` options. +Before sharing or packaging a managed project, run: + +```bash +feather doctor path/to/my-game --production +``` + +For CI or release scripts that need a security-only JSON report: + +```bash +feather doctor path/to/my-game --security --json +``` + --- ## Option 2: Install Script diff --git a/docs/recommendations.md b/docs/recommendations.md index 39bbe9e0..3d6c68a1 100644 --- a/docs/recommendations.md +++ b/docs/recommendations.md @@ -25,11 +25,11 @@ The game rejects commands from any other Feather desktop instance. Only the matc return { -- Any Feather desktop on the network can send commands to this game. -- Set this only when you cannot use appId (e.g. shared dev machine, CI). - insecureConnection = true, + __DANGEROUS_INSECURE_CONNECTION__ = true, } ``` -Setting `insecureConnection = true` is your acknowledgment that the game accepts commands from any Feather desktop on the network. Feather will not start without one of these two options — there is no silent fallback. +Setting `__DANGEROUS_INSECURE_CONNECTION__ = true` is your acknowledgment that the game accepts commands from any Feather desktop on the network. Feather will not start without one of these two options — there is no silent fallback. `feather init` prompts for the App ID during setup and writes it to `feather.config.lua` automatically. To find it: open the Feather desktop app → **Settings** → **Security** → **Desktop App ID**. @@ -95,6 +95,22 @@ USE_DEBUGGER=1 love . For production builds, leave `USE_DEBUGGER` unset and run `feather remove --yes` before packaging. +Run doctor as a release gate before packaging: + +```bash +feather doctor path/to/my-game --production +``` + +`--production` exits with code `1` for release blockers such as insecure connections, weak Console auth, hot reload, debugger/screenshot/disk persistence settings, network exposure with weak auth, or unmanaged embedded Feather runtime. + +For CI systems that need a security-only machine-readable report: + +```bash +feather doctor path/to/my-game --security --json +``` + +The security report includes config posture, plugin trust, package provenance, and network exposure. It redacts sensitive values such as `apiKey`. + --- ## Performance diff --git a/packages/README.md b/packages/README.md index cfc8e3d6..f4753495 100644 --- a/packages/README.md +++ b/packages/README.md @@ -22,6 +22,14 @@ Version overrides (`feather package install anim8@v2.2.0`) are treated as `exper Every install, update, or remove updates `feather.lock.json` in your project root. Commit this file — it records the exact version and SHA-256 of every installed file so anyone cloning your project can restore dependencies with `feather package install` and verify them with `feather package audit`. +Custom GitHub repo installs also record the resolved commit SHA when available. Custom URL installs record the primary URL plus the selected URL list, and every custom file records its own URL and SHA-256. Older lockfiles remain compatible. + +`feather doctor` includes lockfile verification and warns when package file URLs point outside trusted raw GitHub HTTPS sources: + +```sh +feather doctor --security --json +``` + --- ## Commands @@ -65,6 +73,14 @@ feather package install This is the command to run after cloning a project that has a lockfile. +If a missing or modified lockfile entry uses an experimental or untrusted source, non-interactive repair requires explicit consent: + +```sh +feather package install --allow-untrusted +``` + +Without `--allow-untrusted`, Feather exits before downloading from that source. `--yes` does not bypass this trust check. + ### `feather package install [name2...]` Install one or more packages. Files are verified against their SHA-256 before being written. The lockfile is updated on success. @@ -167,6 +183,8 @@ feather package add --dir path/to/my-game > [!CAUTION] > Packages installed this way have `trust: experimental` — they have not been reviewed by the Feather team. Only install files from sources you trust. The SHA-256 of each file is recorded in the lockfile so future `feather package audit` runs will detect any tampering. +> +> Repo installs store the selected repo/tag and resolved commit SHA when available. URL installs store the primary URL, all selected URLs, per-file URLs, and per-file SHA-256 values. ### `feather package install --from-url --target ` @@ -180,6 +198,8 @@ feather package install --from-url https://example.com/mylib.lua \ --allow-untrusted ``` +`--yes` alone never confirms an untrusted URL. In scripts and CI, pass `--allow-untrusted` only after reviewing the source and target path. + ### `feather package update [name]` Update an installed package to the latest version in the registry. Omit the name to update all installed packages. From 15a15deb413b265d7c826b88cc4d161f287f1df4 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 02:10:30 -0400 Subject: [PATCH 39/68] docs: improve documentation --- mkdocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkdocs.yml b/mkdocs.yml index b02717c1..9dd5cdb0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -42,7 +42,7 @@ nav: - Recommendations: recommendations.md - Plugins: - Overview: plugins.md - - Plugin UI: plugin-ui.md + - Plugin UI: plugins-ui.md - Animation Inspector: plugins/animation-inspector.md - Audio Debug: plugins/audio-debug.md - Bookmark: plugins/bookmark.md From 1760a2158e61fafdb00fd9cdb6a601d7f9c99cbc Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 11:00:18 -0400 Subject: [PATCH 40/68] cli: add build and upload commands --- cli/README.md | 105 +++++++++++++ cli/src/commands/build.ts | 88 +++++++++++ cli/src/commands/doctor/checks.ts | 2 + cli/src/commands/doctor/index.ts | 143 ++++++++++++++++++ cli/src/commands/upload.ts | 73 +++++++++ cli/src/index.ts | 54 +++++++ cli/src/lib/build/archive.ts | 114 ++++++++++++++ cli/src/lib/build/build.ts | 242 ++++++++++++++++++++++++++++++ cli/src/lib/build/config.ts | 170 +++++++++++++++++++++ cli/src/lib/build/files.ts | 179 ++++++++++++++++++++++ cli/src/lib/build/upload.ts | 84 +++++++++++ cli/test/commands.test.mjs | 214 +++++++++++++++++++++++++- docs/index.md | 6 +- docs/installation.md | 9 ++ docs/recommendations.md | 10 ++ 15 files changed, 1491 insertions(+), 2 deletions(-) create mode 100644 cli/src/commands/build.ts create mode 100644 cli/src/commands/upload.ts create mode 100644 cli/src/lib/build/archive.ts create mode 100644 cli/src/lib/build/build.ts create mode 100644 cli/src/lib/build/config.ts create mode 100644 cli/src/lib/build/files.ts create mode 100644 cli/src/lib/build/upload.ts diff --git a/cli/README.md b/cli/README.md index 929650a0..4a90071c 100644 --- a/cli/README.md +++ b/cli/README.md @@ -284,6 +284,8 @@ feather doctor . --host 127.0.0.1 --port 4004 feather doctor . --json feather doctor . --production feather doctor . --security --json +feather doctor . --build-target web +feather doctor . --upload-target itch ``` Doctor checks: @@ -294,6 +296,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 - `USE_DEBUGGER` guards and `FEATHER-INIT` markers - risky settings such as hot reload, screenshot capture, and Console API keys - Feather desktop WebSocket reachability @@ -328,6 +331,108 @@ Doctor passed with 1 warning. --- +### `feather build ` + +Build a LÖVE game into local release artifacts. V1 supports `web`, `windows`, `macos`, `linux`, and `steamos`; `android` and `ios` are registered so scripts can target the stable command surface, but they currently return planned-support errors. + +```bash +feather build web --dir path/to/my-game +feather build linux --dir path/to/my-game +feather build steamos --dir path/to/my-game --json +feather build web --dry-run +feather build web --allow-unsafe +``` + +Builds read `feather.build.json` from the project root. Missing config is allowed for simple desktop builds, but web builds need a local love.js player directory and uploads need store metadata. + +```json +{ + "name": "My Game", + "version": "1.0.0", + "sourceDir": ".", + "outDir": "builds", + "exclude": ["screenshots/**", "tmp/**"], + "targets": { + "web": { + "loveJsDir": "vendor/love.js" + } + }, + "upload": { + "itch": { + "project": "my-user/my-game", + "channels": { + "web": "html5", + "linux": "linux" + } + } + } +} +``` + +Build behavior: + +- creates a deterministic `.love` archive from the staged project +- excludes `.git`, `node_modules`, `.featherlog`, build output, and Feather runtime/config files by default +- runs a production safety preflight unless `--allow-unsafe` is passed +- writes `feather-build-manifest.json` in the output directory +- packages `web` by copying the configured love.js player, adding `game.love`, patching the page title/game URL, and creating an HTML zip +- delegates desktop targets to `love-release`; `steamos` uses the Linux packaging path with Steam-friendly target naming + +**Options:** + +| Option | Description | +| ------------------- | ----------------------------------------------------------------- | +| `--dir ` | Project directory (default: current directory). | +| `--config ` | Path to `feather.build.json`. | +| `--out-dir ` | Build output directory override. | +| `--name ` | Product name override. | +| `--version ` | Product version override. | +| `--clean` | Remove the output directory before building. | +| `--dry-run` | Show planned files/artifacts without writing them. | +| `--json` | Print machine-readable output only. | +| `--allow-unsafe` | Skip the production safety preflight for intentional dev builds. | + +Run `feather doctor --build-target ` to see missing local dependencies and exact setup guidance before building. + +--- + +### `feather upload ` + +Upload a built artifact. V1 supports Itch through `butler`; Steam is registered but returns a planned-support error. + +```bash +feather upload itch web --dir path/to/my-game +feather upload itch web --channel html5 --if-changed +feather upload itch web --dry-run --json +feather upload steam linux +``` + +`feather upload itch` reads `feather-build-manifest.json`, chooses the artifact for the requested build target, and runs: + +```bash +butler push : --userversion +``` + +The Itch project and default channels come from `feather.build.json`. Use `--channel` or `--user-version` to override them in CI. + +**Options:** + +| Option | Description | +| -------------------------- | --------------------------------------------------- | +| `--dir ` | Project directory (default: current directory). | +| `--config ` | Path to `feather.build.json`. | +| `--build-dir ` | Directory containing `feather-build-manifest.json`. | +| `--channel ` | Upload channel override. | +| `--user-version ` | Store-facing version override. | +| `--dry-run` | Show the upload command without running it. | +| `--if-changed` | Pass `--if-changed` to supported uploaders. | +| `--hidden` | Pass `--hidden` to supported uploaders. | +| `--json` | Print machine-readable output only. | + +Run `feather doctor --upload-target itch` to check for `butler`, Itch project config, and CI auth hints. Use `BUTLER_API_KEY` in CI or `butler login` locally. + +--- + ### `feather update [dir]` Update the Feather core library in a project. diff --git a/cli/src/commands/build.ts b/cli/src/commands/build.ts new file mode 100644 index 00000000..490f4f0f --- /dev/null +++ b/cli/src/commands/build.ts @@ -0,0 +1,88 @@ +import { fail } from '../lib/command.js'; +import { + printBlank, + printJson, + printKeyValues, + printMuted, + printStatus, + printTable, + createSpinner, + style, +} from '../lib/output.js'; +import { + buildTargets, + isBuildTarget, + type BuildTarget, +} from '../lib/build/config.js'; +import { describeArtifact, runBuild } from '../lib/build/build.js'; + +export type BuildCommandOptions = { + dir?: string; + config?: string; + outDir?: string; + name?: string; + version?: string; + clean?: boolean; + dryRun?: boolean; + json?: boolean; + allowUnsafe?: boolean; +}; + +export async function buildCommand(targetValue: string, opts: BuildCommandOptions = {}): Promise { + if (!isBuildTarget(targetValue)) { + fail(`Unknown build target: ${targetValue}`, { details: [`Available: ${buildTargets.join(', ')}`] }); + } + const target: BuildTarget = targetValue; + const spinner = opts.json || opts.dryRun ? null : createSpinner(`Building ${target}…`).start(); + const result = runBuild({ + target, + projectDir: opts.dir, + configPath: opts.config, + outDir: opts.outDir, + name: opts.name, + version: opts.version, + clean: opts.clean, + dryRun: opts.dryRun, + allowUnsafe: opts.allowUnsafe, + }); + + if (!result.ok) { + spinner?.fail(result.error); + fail(result.error, { silent: Boolean(spinner) }); + } + + if (opts.json) { + printJson(result); + return; + } + + if (result.dryRun) { + printStatus('info', `Build plan for ${style.heading(result.target)}`); + } else { + spinner?.succeed(`Built ${result.target}`); + } + + printBlank(); + printKeyValues([ + ['Project', result.projectDir], + ['Output', result.outDir], + ['Name', result.name], + ['Version', result.version], + ['Files', result.files.length], + ]); + printBlank(); + printTable({ + columns: [ + { key: 'type', label: 'Type' }, + { key: 'path', label: 'Path' }, + ], + rows: result.artifacts.map((artifact) => ({ + type: artifact.type, + path: result.dryRun ? artifact.path : describeArtifact(artifact), + })), + }); + if (result.manifestPath && !result.dryRun) { + printBlank(); + printMuted(`Manifest: ${result.manifestPath}`); + } +} diff --git a/cli/src/commands/doctor/checks.ts b/cli/src/commands/doctor/checks.ts index 148c451b..0da43b92 100644 --- a/cli/src/commands/doctor/checks.ts +++ b/cli/src/commands/doctor/checks.ts @@ -21,6 +21,8 @@ export type DoctorOptions = { json?: boolean; production?: boolean; security?: boolean; + buildTarget?: string; + uploadTarget?: string; }; export const severityOrder: Record = { diff --git a/cli/src/commands/doctor/index.ts b/cli/src/commands/doctor/index.ts index e47904e7..d8d4fc34 100644 --- a/cli/src/commands/doctor/index.ts +++ b/cli/src/commands/doctor/index.ts @@ -6,6 +6,15 @@ import { normalizeInstallDir } from '../../lib/install.js'; import { fail } from '../../lib/command.js'; import { printJson } from '../../lib/output.js'; import { findSymlinkEscapes } from '../../lib/path-safety.js'; +import { + buildTargets, + isBuildTarget, + isSupportedBuildTarget, + isUploadTarget, + loadBuildConfig, + outDirWritableDetail, + uploadTargets, +} from '../../lib/build/config.js'; import { auditLockfile } from '../../lib/package/audit.js'; import { readLockfile } from '../../lib/package/lockfile.js'; import { lockfileEntrySourceSummary, lockfileUrlFindings } from '../../lib/package/provenance.js'; @@ -98,6 +107,52 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) add(checks, 'Environment', 'Platform', 'info', `${process.platform} ${process.arch}`); + if (opts.buildTarget) { + if (!isBuildTarget(opts.buildTarget)) { + add( + checks, + 'Build', + 'Build target', + 'fail', + opts.buildTarget, + `Use one of: ${buildTargets.join(', ')}.`, + ); + } else { + add( + checks, + 'Build', + 'Build target', + isSupportedBuildTarget(opts.buildTarget) ? 'pass' : 'warn', + opts.buildTarget, + isSupportedBuildTarget(opts.buildTarget) + ? undefined + : `${opts.buildTarget} support is planned; use web, windows, macos, linux, or steamos for now.`, + ); + } + } + + if (opts.uploadTarget) { + if (!isUploadTarget(opts.uploadTarget)) { + add( + checks, + 'Upload', + 'Upload target', + 'fail', + opts.uploadTarget, + `Use one of: ${uploadTargets.join(', ')}.`, + ); + } else { + add( + checks, + 'Upload', + 'Upload target', + opts.uploadTarget === 'itch' ? 'pass' : 'warn', + opts.uploadTarget, + opts.uploadTarget === 'itch' ? undefined : 'Steam upload support is planned; use itch for now.', + ); + } + } + const hasProjectDir = existsSync(projectDir); add( checks, @@ -108,6 +163,94 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) hasProjectDir ? undefined : 'Pass the game directory to `feather doctor `.', ); + if (hasProjectDir && (opts.buildTarget || opts.uploadTarget)) { + try { + const buildConfig = loadBuildConfig({ projectDir }); + const configExists = existsSync(buildConfig.configPath); + add( + checks, + 'Build', + 'feather.build.json', + configExists ? 'pass' : 'warn', + configExists ? buildConfig.configPath : 'missing', + configExists ? undefined : 'Create feather.build.json to share local and CI build settings.', + ); + const writable = outDirWritableDetail(buildConfig.outDir); + add( + checks, + 'Build', + 'Build output directory', + writable.ok ? 'pass' : 'fail', + writable.detail, + writable.ok ? undefined : 'Choose a writable outDir in feather.build.json or pass --out-dir.', + ); + + if (opts.buildTarget && isBuildTarget(opts.buildTarget)) { + if (opts.buildTarget === 'web') { + const loveJsDir = buildConfig.targets.web?.loveJsDir; + add( + checks, + 'Build', + 'love.js player', + loveJsDir && existsSync(resolve(projectDir, loveJsDir)) ? 'pass' : 'fail', + loveJsDir ?? 'not configured', + 'Set targets.web.loveJsDir in feather.build.json to a local love.js checkout or build output.', + ); + } else if (isSupportedBuildTarget(opts.buildTarget)) { + const loveRelease = commandVersion('love-release', ['--version']); + add( + checks, + 'Build', + 'love-release', + loveRelease ? 'pass' : 'fail', + loveRelease ? loveRelease : 'not found', + loveRelease ? undefined : 'Install with `luarocks install love-release` and make sure love-release is on PATH.', + ); + const luarocks = commandVersion('luarocks', ['--version']); + add( + checks, + 'Build', + 'LuaRocks', + luarocks ? 'pass' : 'warn', + luarocks ? luarocks : 'not found', + luarocks ? undefined : 'Install LuaRocks if you need to install love-release locally.', + ); + } + } + + if (opts.uploadTarget === 'itch') { + const itchProject = buildConfig.upload.itch?.project; + add( + checks, + 'Upload', + 'Itch project', + itchProject ? 'pass' : 'fail', + itchProject ?? 'missing', + 'Set upload.itch.project in feather.build.json, for example "user/game".', + ); + const butler = commandVersion('butler', ['--version']); + add( + checks, + 'Upload', + 'butler', + butler ? 'pass' : 'fail', + butler ? butler : 'not found', + butler ? undefined : 'Install butler from https://itch.io/docs/butler/ and make sure it is on PATH.', + ); + add( + checks, + 'Upload', + 'BUTLER_API_KEY', + process.env.BUTLER_API_KEY ? 'pass' : 'warn', + process.env.BUTLER_API_KEY ? 'configured' : 'missing', + process.env.BUTLER_API_KEY ? undefined : 'Set BUTLER_API_KEY in CI or run `butler login` locally.', + ); + } + } catch (err) { + add(checks, 'Build', 'feather.build.json', 'fail', (err as Error).message, 'Fix feather.build.json before building or uploading.'); + } + } + const mainPath = join(projectDir, 'main.lua'); const mainSource = readIfExists(mainPath); const hasMain = mainSource !== null; diff --git a/cli/src/commands/upload.ts b/cli/src/commands/upload.ts new file mode 100644 index 00000000..47a16c47 --- /dev/null +++ b/cli/src/commands/upload.ts @@ -0,0 +1,73 @@ +import { fail } from '../lib/command.js'; +import { + createSpinner, + printBlank, + printJson, + printKeyValues, + printMuted, + printStatus, + style, +} from '../lib/output.js'; +import { isUploadTarget, uploadTargets, type UploadTarget } from '../lib/build/config.js'; +import { runUpload } from '../lib/build/upload.js'; + +export type UploadCommandOptions = { + dir?: string; + config?: string; + buildDir?: string; + channel?: string; + userVersion?: string; + dryRun?: boolean; + ifChanged?: boolean; + hidden?: boolean; + json?: boolean; +}; + +export async function uploadCommand(targetValue: string, buildTarget: string | undefined, opts: UploadCommandOptions = {}): Promise { + if (!isUploadTarget(targetValue)) { + fail(`Unknown upload target: ${targetValue}`, { details: [`Available: ${uploadTargets.join(', ')}`] }); + } + const target: UploadTarget = targetValue; + const spinner = opts.json || opts.dryRun ? null : createSpinner(`Uploading to ${target}…`).start(); + const result = runUpload({ + target, + buildTarget, + projectDir: opts.dir, + configPath: opts.config, + buildDir: opts.buildDir, + channel: opts.channel, + userVersion: opts.userVersion, + dryRun: opts.dryRun, + ifChanged: opts.ifChanged, + hidden: opts.hidden, + }); + + if (!result.ok) { + spinner?.fail(result.error); + fail(result.error, { silent: Boolean(spinner) }); + } + + if (opts.json) { + printJson(result); + return; + } + + if (result.dryRun) { + printStatus('info', `Upload plan for ${style.heading(result.target)}`); + } else { + spinner?.succeed(`Uploaded ${result.buildTarget} to ${result.project}:${result.channel}`); + } + printBlank(); + printKeyValues([ + ['Target', result.target], + ['Build', result.buildTarget], + ['Artifact', result.artifact], + ['Project', result.project], + ['Channel', result.channel], + ['Version', result.userVersion], + ]); + if (result.dryRun) { + printBlank(); + printMuted(`Command: ${result.command.join(' ')}`); + } +} diff --git a/cli/src/index.ts b/cli/src/index.ts index b492af23..b9cf4a19 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -6,6 +6,8 @@ import { initCommand } from './commands/init.js'; import { removeCommand } from './commands/remove.js'; import { doctorCommand } from './commands/doctor.js'; import { updateCommand } from './commands/update.js'; +import { buildCommand } from './commands/build.js'; +import { uploadCommand } from './commands/upload.js'; import { pluginListCommand, pluginInstallCommand, @@ -113,6 +115,8 @@ program .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('--build-target ', 'Check dependencies for a build target') + .option('--upload-target ', 'Check dependencies for an upload target') .action((dir: string | undefined, opts) => runCliAction(() => doctorCommand(dir, { installDir: opts.installDir as string | undefined, host: opts.host as string | undefined, @@ -120,6 +124,56 @@ program json: opts.json as boolean | undefined, production: opts.production as boolean | undefined, security: opts.security as boolean | undefined, + buildTarget: opts.buildTarget as string | undefined, + uploadTarget: opts.uploadTarget as string | undefined, + }))); + +program + .command('build ') + .description('Build a LÖVE game for web, desktop, or planned mobile targets') + .option('--dir ', 'Project directory (default: current directory)') + .option('--config ', 'Path to feather.build.json') + .option('--out-dir ', 'Build output directory') + .option('--name ', 'Build product name') + .option('--version ', 'Build product version') + .option('--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') + .action((target: string, opts) => runCliAction(() => buildCommand(target, { + dir: opts.dir as string | undefined, + config: opts.config as string | undefined, + outDir: opts.outDir as string | undefined, + name: opts.name as string | undefined, + version: opts.version as string | undefined, + clean: opts.clean as boolean | undefined, + dryRun: opts.dryRun as boolean | undefined, + json: opts.json as boolean | undefined, + allowUnsafe: opts.allowUnsafe as boolean | undefined, + }))); + +program + .command('upload [build-target]') + .description('Upload built artifacts to itch.io or planned 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('--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') + .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, + 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, }))); program diff --git a/cli/src/lib/build/archive.ts b/cli/src/lib/build/archive.ts new file mode 100644 index 00000000..94f4d3c5 --- /dev/null +++ b/cli/src/lib/build/archive.ts @@ -0,0 +1,114 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; + +export type ZipEntry = { + name: string; + data: Uint8Array; +}; + +const DOS_EPOCH = new Date('1980-01-01T00:00:00Z'); +const CRC_TABLE = new Uint32Array(256); +for (let i = 0; i < 256; i += 1) { + let c = i; + for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + CRC_TABLE[i] = c >>> 0; +} + +function crc32(data: Uint8Array): number { + let crc = 0xffffffff; + for (const byte of data) crc = CRC_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +function dosTime(date = DOS_EPOCH): { time: number; date: number } { + const year = Math.max(1980, date.getUTCFullYear()); + return { + time: (date.getUTCHours() << 11) | (date.getUTCMinutes() << 5) | Math.floor(date.getUTCSeconds() / 2), + date: ((year - 1980) << 9) | ((date.getUTCMonth() + 1) << 5) | date.getUTCDate(), + }; +} + +function u16(value: number): Buffer { + const buf = Buffer.alloc(2); + buf.writeUInt16LE(value); + return buf; +} + +function u32(value: number): Buffer { + const buf = Buffer.alloc(4); + buf.writeUInt32LE(value >>> 0); + return buf; +} + +export function createZipBuffer(entries: ZipEntry[]): Buffer { + const files = entries + .map((entry) => ({ ...entry, name: entry.name.replace(/\\/g, '/').replace(/^\/+/, '') })) + .filter((entry) => entry.name && !entry.name.endsWith('/')) + .sort((a, b) => a.name.localeCompare(b.name)); + const { time, date } = dosTime(); + const localParts: Buffer[] = []; + const centralParts: Buffer[] = []; + let offset = 0; + + for (const file of files) { + const name = Buffer.from(file.name, 'utf8'); + const data = Buffer.from(file.data); + const crc = crc32(data); + const local = Buffer.concat([ + u32(0x04034b50), + u16(20), + u16(0x0800), + u16(0), + u16(time), + u16(date), + u32(crc), + u32(data.length), + u32(data.length), + u16(name.length), + u16(0), + name, + data, + ]); + localParts.push(local); + + centralParts.push(Buffer.concat([ + u32(0x02014b50), + u16(20), + u16(20), + u16(0x0800), + u16(0), + u16(time), + u16(date), + u32(crc), + u32(data.length), + u32(data.length), + u16(name.length), + u16(0), + u16(0), + u16(0), + u16(0), + u32(0), + u32(offset), + name, + ])); + offset += local.length; + } + + const central = Buffer.concat(centralParts); + const end = Buffer.concat([ + u32(0x06054b50), + u16(0), + u16(0), + u16(files.length), + u16(files.length), + u32(central.length), + u32(offset), + u16(0), + ]); + return Buffer.concat([...localParts, central, end]); +} + +export function writeZip(path: string, entries: ZipEntry[]): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, createZipBuffer(entries)); +} diff --git a/cli/src/lib/build/build.ts b/cli/src/lib/build/build.ts new file mode 100644 index 00000000..39c48174 --- /dev/null +++ b/cli/src/lib/build/build.ts @@ -0,0 +1,242 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { assertNoSymlinkEscape } from '../path-safety.js'; +import { + artifactBaseName, + copyDirectory, + fileSize, + latestManifestPath, + listProjectFiles, + stageProject, + writeDirectoryZip, + writeJson, + writeLoveArchive, + type BuildArtifact, + type BuildManifest, +} from './files.js'; +import { + isSupportedBuildTarget, + loadBuildConfig, + type BuildTarget, + type LoadBuildConfigOptions, + type ResolvedBuildConfig, + type SupportedBuildTarget, +} from './config.js'; + +export type BuildOptions = LoadBuildConfigOptions & { + target: BuildTarget; + clean?: boolean; + dryRun?: boolean; + allowUnsafe?: boolean; +}; + +export type BuildResult = { + ok: true; + dryRun: boolean; + target: BuildTarget; + projectDir: string; + outDir: string; + name: string; + version: string; + artifacts: BuildArtifact[]; + files: string[]; + manifestPath?: string; + command?: string[]; +} | { + ok: false; + error: string; +}; + +export function assertBuildTargetSupported(target: BuildTarget): asserts target is SupportedBuildTarget { + if (!isSupportedBuildTarget(target)) { + throw new Error(`Build target "${target}" is planned but not supported yet. Run \`feather doctor --build-target ${target}\` for setup guidance.`); + } +} + +export function assertProductionBuildSafe(config: ResolvedBuildConfig, allowUnsafe = false): void { + if (allowUnsafe) return; + const configPath = join(config.projectDir, 'feather.config.lua'); + if (!existsSync(configPath)) return; + const source = readFileSync(configPath, 'utf8'); + const socketMode = !/mode\s*=\s*["']disk["']/.test(source); + const hasAppId = /appId\s*=\s*["'][^"']+["']/.test(source); + const host = source.match(/host\s*=\s*["']([^"']+)["']/)?.[1] ?? '127.0.0.1'; + const unsafe = [ + /__DANGEROUS_INSECURE_CONNECTION__\s*=\s*true/.test(source) ? '__DANGEROUS_INSECURE_CONNECTION__ is enabled' : '', + socketMode && !hasAppId ? 'appId is missing for socket/network mode' : '', + host === '0.0.0.0' || host === '::' ? `network host is exposed (${host})` : '', + /include\s*=\s*\{[\s\S]*["']console["']/.test(source) ? 'console plugin is included' : '', + /hotReload\s*=\s*\{[\s\S]*enabled\s*=\s*true/.test(source) ? 'hot reload is enabled' : '', + /allow\s*=\s*\{[\s\S]*["'][^"']+\.\*["']/.test(source) ? 'hot reload allowlist contains a wildcard' : '', + /debugger\s*=\s*(true|\{[\s\S]*enabled\s*=\s*true)/.test(source) ? 'debugger is enabled' : '', + /captureScreenshot\s*=\s*true/.test(source) ? 'captureScreenshot is enabled' : '', + /writeToDisk\s*=\s*true/.test(source) ? 'writeToDisk is enabled' : '', + ].filter(Boolean); + if (unsafe.length > 0) { + throw new Error(`Production build preflight failed. Run \`feather doctor --production\` or pass --allow-unsafe.\n${unsafe.join('\n')}`); + } +} + +export function planBuild(options: BuildOptions): Omit, 'artifacts'> & { artifacts: BuildArtifact[] } { + const config = loadBuildConfig(options); + return { + ok: true, + dryRun: true, + target: options.target, + projectDir: config.projectDir, + outDir: config.outDir, + name: config.name, + version: config.version, + files: listProjectFiles(config), + artifacts: plannedArtifacts(config, options.target), + manifestPath: latestManifestPath(config.outDir), + }; +} + +export function runBuild(options: BuildOptions): BuildResult { + try { + assertBuildTargetSupported(options.target); + const config = loadBuildConfig(options); + assertProductionBuildSafe(config, options.allowUnsafe); + assertNoSymlinkEscape(config.projectDir, config.outDir, 'Build output directory'); + + if (options.dryRun) return planBuild(options); + if (options.clean) rmSync(config.outDir, { recursive: true, force: true }); + mkdirSync(config.outDir, { recursive: true }); + + const staged = stageProject(config); + try { + const artifacts = options.target === 'web' + ? buildWeb(config, staged.dir) + : buildDesktop(config, options.target, staged.dir); + const manifest: BuildManifest = { + name: config.name, + version: config.version, + target: options.target, + createdAt: new Date().toISOString(), + artifacts, + }; + writeJson(latestManifestPath(config.outDir), manifest); + return { + ok: true, + dryRun: false, + target: options.target, + projectDir: config.projectDir, + outDir: config.outDir, + name: config.name, + version: config.version, + files: staged.files, + artifacts, + manifestPath: latestManifestPath(config.outDir), + }; + } finally { + staged.cleanup(); + } + } catch (err) { + return { ok: false, error: (err as Error).message }; + } +} + +function plannedArtifacts(config: ResolvedBuildConfig, target: BuildTarget): BuildArtifact[] { + const base = artifactBaseName(config); + if (target === 'web') { + return [ + { target, type: 'love', path: join(config.outDir, `${base}.love`) }, + { target, type: 'html', path: join(config.outDir, `${base}-html`) }, + { target, type: 'zip', path: join(config.outDir, `${base}-html.zip`) }, + ]; + } + return [ + { target, type: 'love', path: join(config.outDir, `${base}.love`) }, + { target, type: 'external', path: config.outDir }, + ]; +} + +function buildWeb(config: ResolvedBuildConfig, stageDir: string): BuildArtifact[] { + const webConfig = config.targets.web ?? {}; + const loveJsDir = webConfig.loveJsDir ? resolve(config.projectDir, webConfig.loveJsDir) : ''; + if (!loveJsDir || !existsSync(loveJsDir)) { + throw new Error('Web build requires targets.web.loveJsDir in feather.build.json.'); + } + const base = artifactBaseName(config); + const lovePath = writeLoveArchive(stageDir, config.outDir, base); + const htmlDir = join(config.outDir, webConfig.outputName ?? `${base}-html`); + copyDirectory(loveJsDir, htmlDir); + const gameLovePath = join(htmlDir, 'game.love'); + writeFileSync(gameLovePath, readFileSync(lovePath)); + patchLoveJsIndex(join(htmlDir, 'index.html'), webConfig.title ?? config.name); + const zipPath = writeDirectoryZip(htmlDir, join(config.outDir, `${base}-html.zip`)); + return [ + { target: 'web', type: 'love', path: lovePath }, + { target: 'web', type: 'html', path: htmlDir }, + { target: 'web', type: 'zip', path: zipPath }, + ]; +} + +function patchLoveJsIndex(indexPath: string, title: string): void { + const fallback = [ + '', + '', + 'löve.js', + '', + '', + '', + ].join('\n'); + const existing = existsSync(indexPath) ? readFileSync(indexPath, 'utf8') : fallback; + let next = existing.replace(/[\s\S]*?<\/title>/i, `<title>${escapeHtml(title)}`); + if (next === existing && !//i.test(next)) { + next = next.replace(/<head[^>]*>/i, (match) => `${match}<title>${escapeHtml(title)}`); + } + next = next.replace(/player(?:\.min)?\.js(?:\?g=[^"']*)?/g, (match) => { + const script = match.startsWith('player.min') ? 'player.min.js' : 'player.js'; + return `${script}?g=game.love`; + }); + if (!/\?g=game\.love/.test(next)) { + next = next.replace('', ''); + } + writeFileSync(indexPath, next); +} + +function buildDesktop(config: ResolvedBuildConfig, target: SupportedBuildTarget, stageDir: string): BuildArtifact[] { + const normalizedTarget = target === 'steamos' ? 'linux' : target; + const base = artifactBaseName(config); + const lovePath = writeLoveArchive(stageDir, config.outDir, base); + const args = [ + '--output', + config.outDir, + '--name', + config.name, + '--version', + config.version, + '--target', + normalizedTarget, + stageDir, + ]; + const result = spawnSync('love-release', args, { encoding: 'utf8' }); + if (result.error) throw new Error(`love-release not found. Run \`feather doctor --build-target ${target}\`.`); + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || `love-release failed for ${target}`).trim()); + } + return [ + { target, type: 'love', path: lovePath }, + { target, type: 'external', path: config.outDir }, + ]; +} + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (char) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[char]!)); +} + +export function describeArtifact(artifact: BuildArtifact): string { + const size = existsSync(artifact.path) && artifact.type !== 'html' && artifact.type !== 'external' + ? ` (${fileSize(artifact.path)} bytes)` + : ''; + return `${artifact.type}: ${artifact.path}${size}`; +} diff --git a/cli/src/lib/build/config.ts b/cli/src/lib/build/config.ts new file mode 100644 index 00000000..00951b67 --- /dev/null +++ b/cli/src/lib/build/config.ts @@ -0,0 +1,170 @@ +import { accessSync, constants, existsSync, readFileSync } from 'node:fs'; +import { basename, dirname, join, resolve } from 'node:path'; +import { assertNoSymlinkEscape, assertSafeRelativePath, isPathInside } from '../path-safety.js'; + +export const buildTargets = ['web', 'android', 'ios', 'windows', 'macos', 'linux', 'steamos'] as const; +export const supportedBuildTargets = ['web', 'windows', 'macos', 'linux', 'steamos'] as const; +export const uploadTargets = ['itch', 'steam'] as const; + +export type BuildTarget = typeof buildTargets[number]; +export type SupportedBuildTarget = typeof supportedBuildTargets[number]; +export type UploadTarget = typeof uploadTargets[number]; + +export type FeatherBuildConfig = { + name?: string; + version?: string; + loveVersion?: string; + sourceDir?: string; + outDir?: string; + include?: string[]; + exclude?: string[]; + icon?: string; + includeRuntime?: boolean; + targets?: { + web?: { + loveJsDir?: string; + title?: string; + outputName?: string; + }; + windows?: Record; + macos?: Record; + linux?: Record; + steamos?: Record; + }; + upload?: { + itch?: { + project?: string; + channels?: Record; + }; + }; +}; + +export type ResolvedBuildConfig = { + configPath: string; + projectDir: string; + sourceDir: string; + outDir: string; + name: string; + version: string; + loveVersion?: string; + include: string[]; + exclude: string[]; + icon?: string; + includeRuntime: boolean; + targets: NonNullable; + upload: NonNullable; +}; + +export type LoadBuildConfigOptions = { + projectDir?: string; + configPath?: string; + outDir?: string; + name?: string; + version?: string; +}; + +const DEFAULT_EXCLUDES = [ + '.git', + 'node_modules', + '.featherlog', + 'feather', + 'feather.config.lua', + 'feather.lock.json', + 'feather.build.json', +]; + +export function isBuildTarget(value: string): value is BuildTarget { + return (buildTargets as readonly string[]).includes(value); +} + +export function isSupportedBuildTarget(value: BuildTarget): value is SupportedBuildTarget { + return (supportedBuildTargets as readonly string[]).includes(value); +} + +export function isUploadTarget(value: string): value is UploadTarget { + return (uploadTargets as readonly string[]).includes(value); +} + +export function buildConfigPath(projectDir: string, configPath?: string): string { + return configPath ? resolve(projectDir, configPath) : join(projectDir, 'feather.build.json'); +} + +export function readBuildConfig(projectDir: string, configPath?: string): FeatherBuildConfig { + const path = buildConfigPath(projectDir, configPath); + if (!existsSync(path)) return {}; + try { + const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Config root must be an object.'); + } + return parsed as FeatherBuildConfig; + } catch (err) { + throw new Error(`Invalid feather.build.json: ${(err as Error).message}`); + } +} + +function stringArray(value: unknown, label: string): string[] { + if (value === undefined) return []; + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { + throw new Error(`${label} must be an array of strings.`); + } + return value; +} + +function projectPath(projectDir: string, value: string | undefined, fallback: string, label: string): string { + const relative = value?.trim() || fallback; + assertSafeRelativePath(relative, label); + const absolute = resolve(projectDir, relative); + assertNoSymlinkEscape(projectDir, absolute, label); + return absolute; +} + +export function loadBuildConfig(options: LoadBuildConfigOptions = {}): ResolvedBuildConfig { + const projectDir = resolve(options.projectDir ?? process.cwd()); + const raw = readBuildConfig(projectDir, options.configPath); + const configPath = buildConfigPath(projectDir, options.configPath); + const name = (options.name ?? raw.name ?? basename(projectDir)).trim(); + const version = (options.version ?? raw.version ?? '0.1.0').trim(); + if (!name) throw new Error('Build name must not be empty.'); + if (!version) throw new Error('Build version must not be empty.'); + + const sourceDir = projectPath(projectDir, raw.sourceDir, '.', 'Build source directory'); + const outDir = projectPath(projectDir, options.outDir ?? raw.outDir, 'builds', 'Build output directory'); + const outRel = isPathInside(projectDir, outDir) ? outDirRelative(projectDir, outDir) : ''; + const includeRuntime = Boolean(raw.includeRuntime); + const exclude = [ + ...DEFAULT_EXCLUDES.filter((item) => includeRuntime ? item !== 'feather' : true), + ...(outRel ? [outRel] : []), + ...stringArray(raw.exclude, 'exclude'), + ]; + + return { + configPath, + projectDir, + sourceDir, + outDir, + name, + version, + loveVersion: raw.loveVersion, + include: stringArray(raw.include, 'include'), + exclude, + icon: raw.icon, + includeRuntime, + targets: raw.targets ?? {}, + upload: raw.upload ?? {}, + }; +} + +export function outDirWritableDetail(outDir: string): { ok: boolean; detail: string } { + const target = existsSync(outDir) ? outDir : dirname(outDir); + try { + accessSync(target, constants.W_OK); + return { ok: true, detail: outDir }; + } catch { + return { ok: false, detail: `${target} is not writable` }; + } +} + +function outDirRelative(projectDir: string, outDir: string): string { + return resolve(outDir).slice(resolve(projectDir).length + 1).replace(/\\/g, '/'); +} diff --git a/cli/src/lib/build/files.ts b/cli/src/lib/build/files.ts new file mode 100644 index 00000000..a7810f63 --- /dev/null +++ b/cli/src/lib/build/files.ts @@ -0,0 +1,179 @@ +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, relative, resolve } from 'node:path'; +import type { ResolvedBuildConfig } from './config.js'; +import { writeZip, type ZipEntry } from './archive.js'; + +export type StagedProject = { + dir: string; + files: string[]; + cleanup: () => void; +}; + +function patternToRegExp(pattern: string): RegExp { + const normalized = pattern.replace(/\\/g, '/').replace(/^\/+/, ''); + const escaped = normalized + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*\*/g, '::DOUBLE_STAR::') + .replace(/\*/g, '[^/]*') + .replace(/::DOUBLE_STAR::/g, '.*'); + return new RegExp(`^${escaped}(?:/.*)?$`); +} + +function matcher(patterns: string[]): (path: string) => boolean { + const regexes = patterns.map(patternToRegExp); + return (path) => regexes.some((regex) => regex.test(path)); +} + +export function buildSlug(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') || 'game'; +} + +export function artifactBaseName(config: ResolvedBuildConfig): string { + return `${buildSlug(config.name)}-${config.version}`; +} + +export function listProjectFiles(config: ResolvedBuildConfig): string[] { + const includes = matcher(config.include); + const excludes = matcher(config.exclude); + const files: string[] = []; + + const visit = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const abs = join(dir, entry.name); + const rel = relative(config.sourceDir, abs).replace(/\\/g, '/'); + if (!rel || excludes(rel)) continue; + if (entry.isDirectory()) { + visit(abs); + } else if (entry.isFile() && (config.include.length === 0 || includes(rel))) { + files.push(rel); + } + } + }; + + visit(config.sourceDir); + return files.sort((a, b) => a.localeCompare(b)); +} + +export function stageProject(config: ResolvedBuildConfig): StagedProject { + const root = mkdtempSync(join(tmpdir(), 'feather-build-')); + const stageDir = join(root, 'game'); + const files = listProjectFiles(config); + mkdirSync(stageDir, { recursive: true }); + for (const file of files) { + const src = join(config.sourceDir, file); + const dest = join(stageDir, file); + mkdirSync(dirname(dest), { recursive: true }); + cpSync(src, dest, { force: true }); + } + return { + dir: stageDir, + files, + cleanup: () => rmSync(root, { recursive: true, force: true }), + }; +} + +export function zipEntriesFromDir(dir: string): ZipEntry[] { + const entries: ZipEntry[] = []; + const visit = (current: string) => { + for (const entry of readdirSync(current, { withFileTypes: true })) { + const abs = join(current, entry.name); + if (entry.isDirectory()) { + visit(abs); + } else if (entry.isFile()) { + entries.push({ + name: relative(dir, abs).replace(/\\/g, '/'), + data: readFileSync(abs), + }); + } + } + }; + visit(dir); + return entries; +} + +export function writeLoveArchive(stageDir: string, outDir: string, basenameWithoutExt: string): string { + const path = join(outDir, `${basenameWithoutExt}.love`); + writeZip(path, zipEntriesFromDir(stageDir)); + return path; +} + +export function writeDirectoryZip(dir: string, zipPath: string): string { + writeZip(zipPath, zipEntriesFromDir(dir)); + return zipPath; +} + +export function copyDirectory(source: string, dest: string): void { + mkdirSync(dirname(dest), { recursive: true }); + cpSync(source, dest, { + recursive: true, + force: true, + filter: (src) => { + const rel = relative(source, src).replace(/\\/g, '/'); + return !rel.startsWith('.git'); + }, + }); +} + +export function writeJson(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +export function latestManifestPath(outDir: string): string { + return join(outDir, 'feather-build-manifest.json'); +} + +export function readLatestManifest(outDir: string): BuildManifest | null { + const path = latestManifestPath(outDir); + if (!existsSync(path)) return null; + return JSON.parse(readFileSync(path, 'utf8')) as BuildManifest; +} + +export type BuildArtifact = { + target: string; + type: string; + path: string; +}; + +export type BuildManifest = { + name: string; + version: string; + target: string; + createdAt: string; + artifacts: BuildArtifact[]; +}; + +export function fileSize(path: string): number { + return statSync(path).size; +} + +export function artifactForTarget(manifest: BuildManifest, target: string): BuildArtifact | null { + const artifacts = manifest.artifacts.filter((artifact) => artifact.target === target && artifact.type !== 'metadata'); + return artifacts.find((artifact) => artifact.type === 'zip') + ?? artifacts.find((artifact) => artifact.type === 'external') + ?? artifacts.find((artifact) => artifact.type === 'love') + ?? null; +} + +export function resolveArtifactPath(path: string): string { + return resolve(path); +} + +export function pathName(path: string): string { + return basename(path); +} diff --git a/cli/src/lib/build/upload.ts b/cli/src/lib/build/upload.ts new file mode 100644 index 00000000..492a232b --- /dev/null +++ b/cli/src/lib/build/upload.ts @@ -0,0 +1,84 @@ +import { existsSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { loadBuildConfig, type LoadBuildConfigOptions, type UploadTarget } from './config.js'; +import { artifactForTarget, readLatestManifest, resolveArtifactPath } from './files.js'; + +export type UploadOptions = LoadBuildConfigOptions & { + target: UploadTarget; + buildTarget?: string; + buildDir?: string; + channel?: string; + userVersion?: string; + dryRun?: boolean; + ifChanged?: boolean; + hidden?: boolean; +}; + +export type UploadResult = { + ok: true; + dryRun: boolean; + target: UploadTarget; + buildTarget: string; + artifact: string; + project: string; + channel: string; + userVersion: string; + command: string[]; +} | { + ok: false; + error: string; +}; + +export function runUpload(options: UploadOptions): UploadResult { + try { + if (options.target === 'steam') { + return { ok: false, error: 'Upload target "steam" is planned but not supported yet.' }; + } + + const config = loadBuildConfig(options); + const outDir = options.buildDir ? resolve(config.projectDir, options.buildDir) : config.outDir; + const manifest = readLatestManifest(outDir); + if (!manifest) throw new Error(`No build manifest found in ${outDir}. Run \`feather build \` first.`); + const buildTarget = options.buildTarget ?? manifest.target; + const artifact = artifactForTarget(manifest, buildTarget); + if (!artifact) throw new Error(`No artifact found for ${buildTarget}. Run \`feather build ${buildTarget}\` first.`); + if (!existsSync(artifact.path)) throw new Error(`Build artifact is missing: ${artifact.path}`); + + const itch = config.upload.itch ?? {}; + const project = itch.project; + if (!project) throw new Error('Itch upload requires upload.itch.project in feather.build.json.'); + const channel = options.channel ?? itch.channels?.[buildTarget] ?? buildTarget; + const userVersion = options.userVersion ?? config.version; + const command = [ + 'butler', + 'push', + resolveArtifactPath(artifact.path), + `${project}:${channel}`, + '--userversion', + userVersion, + ...(options.ifChanged ? ['--if-changed'] : []), + ...(options.hidden ? ['--hidden'] : []), + ]; + + if (!options.dryRun) { + const result = spawnSync(command[0]!, command.slice(1), { encoding: 'utf8', stdio: 'pipe' }); + if (result.error) throw new Error('butler not found. Run `feather doctor --upload-target itch`.'); + if (result.status !== 0) throw new Error((result.stderr || result.stdout || 'butler push failed').trim()); + } + + return { + ok: true, + dryRun: Boolean(options.dryRun), + target: 'itch', + buildTarget, + artifact: resolveArtifactPath(artifact.path), + project, + channel, + userVersion, + command, + }; + } catch (err) { + return { ok: false, error: (err as Error).message }; + } +} diff --git a/cli/test/commands.test.mjs b/cli/test/commands.test.mjs index 2c103bba..a73c14b7 100644 --- a/cli/test/commands.test.mjs +++ b/cli/test/commands.test.mjs @@ -17,7 +17,7 @@ import { writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { delimiter, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const CLI = fileURLToPath(new URL('../dist/index.js', import.meta.url)); @@ -116,6 +116,40 @@ process.exit(${JSON.stringify(exitCode)}); return { fakePath, recordPath }; } +function writeFakeCommand(dir, name, script) { + const binDir = join(dir, 'bin'); + mkdirSync(binDir, { recursive: true }); + const commandPath = join(binDir, name); + writeFileSync(commandPath, `#!/usr/bin/env node\n${script}\n`); + chmodSync(commandPath, 0o755); + return { binDir, commandPath }; +} + +function envWithPath(binDir, extra = {}) { + return { + ...process.env, + NO_COLOR: '1', + FORCE_COLOR: '0', + PATH: `${binDir}${delimiter}${process.env.PATH ?? ''}`, + ...extra, + }; +} + +function writeFakeLoveJs(dir) { + const loveJsDir = join(dir, 'love.js'); + mkdirSync(loveJsDir, { recursive: true }); + writeFileSync( + join(loveJsDir, 'index.html'), + 'löve.js', + ); + writeFileSync(join(loveJsDir, 'player.min.js'), 'console.log("love.js");\n'); + return loveJsDir; +} + +function writeBuildConfig(dir, config) { + writeFileSync(join(dir, 'feather.build.json'), `${JSON.stringify(config, null, 2)}\n`); +} + function parseDoctorJson(dir, extra = []) { const result = run(['doctor', dir, '--json', ...extra]); assert.equal(result.exitCode, 0, outputOf(result)); @@ -520,6 +554,184 @@ test('doctor --production fails unmanaged embedded runtime', () => { assert.ok(labels.get('Managed runtime')?.fix.includes('feather init')); }); +test('build web: creates love archive, love.js html package, zip, and manifest', () => { + const dir = makeTmp(); + writeGame(dir); + const loveJsDir = writeFakeLoveJs(dir); + writeBuildConfig(dir, { + name: 'Command Game', + version: '1.2.3', + targets: { web: { loveJsDir: 'love.js' } }, + upload: { itch: { project: 'tester/command-game', channels: { web: 'html5' } } }, + }); + + const result = run(['build', 'web', '--dir', dir, '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, true); + assert.equal(parsed.target, 'web'); + assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'love'), true); + assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'zip'), true); + assert.equal(existsSync(join(dir, 'builds', 'command-game-1.2.3.love')), true); + assert.equal(existsSync(join(dir, 'builds', 'command-game-1.2.3-html.zip')), true); + const index = readFileSync(join(dir, 'builds', 'command-game-1.2.3-html', 'index.html'), 'utf8'); + assert.ok(index.includes('Command Game')); + assert.ok(index.includes('player.min.js?g=game.love')); + const manifest = JSON.parse(readFileSync(join(dir, 'builds', 'feather-build-manifest.json'), 'utf8')); + assert.equal(manifest.target, 'web'); +}); + +test('build linux: delegates desktop packaging to love-release and writes manifest', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Desktop Game', version: '2.0.0' }); +const recordPath = join(dir, 'love-release-record.json'); + const { binDir } = writeFakeCommand(dir, 'love-release', ` +if (process.argv.length === 3 && process.argv[2] === '--version') { + console.log('love-release test'); + process.exit(0); +} +const fs = require('node:fs'); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ argv: process.argv.slice(2) }, null, 2)); +process.exit(0); +`); + + const result = run(['build', 'linux', '--dir', dir, '--json'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, true); + assert.equal(existsSync(join(dir, 'builds', 'desktop-game-2.0.0.love')), true); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.ok(record.argv.includes('--target')); + assert.ok(record.argv.includes('linux')); + assert.ok(record.argv.includes('--name')); + assert.ok(record.argv.includes('Desktop Game')); +}); + +test('build: unsupported mobile targets fail with planned-support guidance', () => { + const dir = makeTmp(); + writeGame(dir); + const result = run(['build', 'android', '--dir', dir, '--json']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('planned but not supported yet')); +}); + +test('build: production preflight blocks unsafe Feather config unless explicitly allowed', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeBuildConfig(dir, { name: 'Unsafe Game', version: '1.0.0', targets: { web: { loveJsDir: 'love.js' } } }); + writeFileSync(join(dir, 'feather.config.lua'), 'return { include = { "console" }, apiKey = "dev" }\n'); + + const blocked = run(['build', 'web', '--dir', dir, '--json']); + assert.equal(blocked.exitCode, 1); + assert.ok(outputOf(blocked).includes('Production build preflight failed')); + + const allowed = run(['build', 'web', '--dir', dir, '--json', '--allow-unsafe']); + assert.equal(allowed.exitCode, 0, outputOf(allowed)); + assert.equal(JSON.parse(allowed.stdout).ok, true); +}); + +test('upload itch: dry-run uses build manifest and configured channel', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeBuildConfig(dir, { + name: 'Upload Game', + version: '3.4.5', + targets: { web: { loveJsDir: 'love.js' } }, + upload: { itch: { project: 'tester/upload-game', channels: { web: 'html5' } } }, + }); + const build = run(['build', 'web', '--dir', dir, '--json']); + assert.equal(build.exitCode, 0, outputOf(build)); + + const result = run(['upload', 'itch', 'web', '--dir', dir, '--dry-run', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, true); + assert.equal(parsed.dryRun, true); + assert.equal(parsed.project, 'tester/upload-game'); + assert.equal(parsed.channel, 'html5'); + assert.equal(parsed.userVersion, '3.4.5'); + assert.deepEqual(parsed.command.slice(0, 2), ['butler', 'push']); +}); + +test('upload itch: fake butler receives artifact, channel, version, and flags', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeBuildConfig(dir, { + name: 'Butler Game', + version: '4.0.0', + targets: { web: { loveJsDir: 'love.js' } }, + upload: { itch: { project: 'tester/butler-game', channels: { web: 'html5' } } }, + }); + const build = run(['build', 'web', '--dir', dir, '--json']); + assert.equal(build.exitCode, 0, outputOf(build)); + const recordPath = join(dir, 'butler-record.json'); + const { binDir } = writeFakeCommand(dir, 'butler', ` +if (process.argv.includes('--version')) { + console.log('butler test'); + process.exit(0); +} +const fs = require('node:fs'); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ argv: process.argv.slice(2) }, null, 2)); +process.exit(0); +`); + + const result = run(['upload', 'itch', 'web', '--dir', dir, '--if-changed', '--hidden', '--json'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.equal(record.argv[0], 'push'); + assert.ok(record.argv[1].endsWith('butler-game-4.0.0.love') || record.argv[1].endsWith('butler-game-4.0.0-html.zip')); + assert.equal(record.argv[2], 'tester/butler-game:html5'); + assert.ok(record.argv.includes('--userversion')); + assert.ok(record.argv.includes('4.0.0')); + assert.ok(record.argv.includes('--if-changed')); + assert.ok(record.argv.includes('--hidden')); +}); + +test('upload steam: planned target fails cleanly', () => { + const dir = makeTmp(); + writeGame(dir); + const result = run(['upload', 'steam', '--dir', dir, '--json']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('planned but not supported yet')); +}); + +test('doctor: build and upload target checks report missing and configured dependencies', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { + name: 'Doctor Build Game', + version: '1.0.0', + upload: { itch: { project: 'tester/doctor-build-game' } }, + }); + const { parsed: missing } = parseDoctorJsonResult(dir, ['--build-target', 'web', '--upload-target', 'itch']); + const missingLabels = new Map(missing.checks.map((check) => [check.label, check])); + assert.equal(missingLabels.get('love.js player')?.severity, 'fail'); + assert.equal(missingLabels.get('butler')?.severity, 'fail'); + assert.ok(missingLabels.get('love.js player')?.fix.includes('targets.web.loveJsDir')); + + writeFakeLoveJs(dir); + writeBuildConfig(dir, { + name: 'Doctor Build Game', + version: '1.0.0', + targets: { web: { loveJsDir: 'love.js' } }, + upload: { itch: { project: 'tester/doctor-build-game' } }, + }); + const { binDir } = writeFakeCommand(dir, 'butler', `console.log('butler test'); process.exit(0);`); + const configured = run(['doctor', dir, '--json', '--build-target', 'web', '--upload-target', 'itch'], { env: envWithPath(binDir, { BUTLER_API_KEY: 'test-key' }) }); + assert.equal(configured.exitCode, 0, outputOf(configured)); + const parsed = JSON.parse(configured.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('love.js player')?.severity, 'pass'); + assert.equal(labels.get('butler')?.severity, 'pass'); + assert.equal(labels.get('BUTLER_API_KEY')?.severity, 'pass'); +}); + test('command runtime redacts API keys from compact and debug errors', async () => { const { runCliAction } = await import('../dist/lib/command.js'); const originalError = console.error; diff --git a/docs/index.md b/docs/index.md index 0887014c..2b49b9c4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,6 +20,7 @@ Like Flipper or React DevTools, but for your game. Inspect logs, variables, perf - 🖼️ **Asset inspector** — Browse loaded textures, fonts, and audio sources with previews, zoom, pan, and pixel grid. - 📁 **Log file viewer** — Open `.featherlog` files for offline inspection. - 🖥️ **CLI-first workflow** — `feather init`, `feather run`, and `feather remove` manage setup and cleanup. +- 🚢 **Build/upload helpers** — `feather build` creates local web/desktop release artifacts and `feather upload itch` pushes them with Butler. - ⚡ **Guarded in-game setup** — Generated imports load only when `USE_DEBUGGER` is enabled. - 📦 **Config file support** — `feather.config.lua` keeps project settings outside game code. @@ -82,6 +83,9 @@ Before shipping a production build: ```bash feather doctor path/to/my-game --production +feather doctor path/to/my-game --build-target web --upload-target itch +feather build web --dir path/to/my-game +feather upload itch web --dir path/to/my-game --dry-run feather remove --dry-run feather remove --yes ``` @@ -90,7 +94,7 @@ feather remove --yes ## Documentation -- [CLI](cli.md) — Run games without touching their code, `feather run`, `feather init`, `feather doctor` +- [CLI](cli.md) — Run games without touching their code, `feather run`, `feather init`, `feather doctor`, `feather build`, `feather upload` - [Installation](installation.md) — Download, install script, LuaRocks, custom paths - [Configuration](configuration.md) — All config options, connecting, mobile debugging - [Usage](usage.md) — Observers, logging, console / REPL, step debugger diff --git a/docs/installation.md b/docs/installation.md index 72ccd1ea..4f0fcd8e 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -55,6 +55,15 @@ Before sharing or packaging a managed project, run: feather doctor path/to/my-game --production ``` +To check release dependencies for Feather's local build/upload helpers: + +```bash +feather doctor path/to/my-game --build-target web +feather doctor path/to/my-game --upload-target itch +``` + +`feather build` supports web, Windows, macOS, Linux, and SteamOS artifacts. Web builds need a local love.js player directory configured in `feather.build.json`; desktop builds expect `love-release` on `PATH`. `feather upload itch` expects Butler on `PATH` and either `BUTLER_API_KEY` in CI or a local `butler login` session. + For CI or release scripts that need a security-only JSON report: ```bash diff --git a/docs/recommendations.md b/docs/recommendations.md index 3d6c68a1..9cf950a3 100644 --- a/docs/recommendations.md +++ b/docs/recommendations.md @@ -99,6 +99,7 @@ Run doctor as a release gate before packaging: ```bash feather doctor path/to/my-game --production +feather doctor path/to/my-game --build-target web ``` `--production` exits with code `1` for release blockers such as insecure connections, weak Console auth, hot reload, debugger/screenshot/disk persistence settings, network exposure with weak auth, or unmanaged embedded Feather runtime. @@ -111,6 +112,15 @@ feather doctor path/to/my-game --security --json The security report includes config posture, plugin trust, package provenance, and network exposure. It redacts sensitive values such as `apiKey`. +If you use Feather's local release helpers, keep release metadata in `feather.build.json` and let doctor check the target-specific tools before CI runs the build: + +```bash +feather build web --dir path/to/my-game --json +feather upload itch web --dir path/to/my-game --dry-run --json +``` + +Web builds package a local love.js player; desktop targets delegate packaging to `love-release`; Itch uploads use Butler. Android, iOS, and Steam upload are intentionally planned-support paths until their release flows are hardened. + --- ## Performance From fd026ebc7e77873fbbd52936886ef01ec3bc9a27 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 11:18:00 -0400 Subject: [PATCH 41/68] cli: add android and ios build --- cli/README.md | 26 ++- cli/src/commands/doctor/index.ts | 111 ++++++++++++ cli/src/lib/build/build.ts | 281 ++++++++++++++++++++++++++++++- cli/src/lib/build/config.ts | 43 ++++- cli/test/commands.test.mjs | 249 ++++++++++++++++++++++++++- docs/index.md | 2 +- docs/installation.md | 2 +- docs/recommendations.md | 2 +- 8 files changed, 703 insertions(+), 13 deletions(-) diff --git a/cli/README.md b/cli/README.md index 4a90071c..4bacae73 100644 --- a/cli/README.md +++ b/cli/README.md @@ -333,28 +333,48 @@ Doctor passed with 1 warning. ### `feather build ` -Build a LÖVE game into local release artifacts. V1 supports `web`, `windows`, `macos`, `linux`, and `steamos`; `android` and `ios` are registered so scripts can target the stable command surface, but they currently return planned-support errors. +Build a LÖVE game into local release artifacts. V1 supports `web`, `android`, `ios`, `windows`, `macos`, `linux`, and `steamos`. Android and iOS support creates development artifacts from local native template checkouts; signed release APK/AAB, IPA export, notarization, and store upload are later phases. ```bash feather build web --dir path/to/my-game +feather build android --dir path/to/my-game +feather build ios --dir path/to/my-game feather build linux --dir path/to/my-game feather build steamos --dir path/to/my-game --json feather build web --dry-run feather build web --allow-unsafe ``` -Builds read `feather.build.json` from the project root. Missing config is allowed for simple desktop builds, but web builds need a local love.js player directory and uploads need store metadata. +Builds read `feather.build.json` from the project root. Missing config is allowed for simple desktop builds, but web builds need a local love.js player directory, mobile builds need local LÖVE native template paths, and uploads need store metadata. ```json { "name": "My Game", "version": "1.0.0", + "productId": "com.example.mygame", + "company": "Example Studio", + "website": "https://example.com", "sourceDir": ".", "outDir": "builds", "exclude": ["screenshots/**", "tmp/**"], "targets": { "web": { "loveJsDir": "vendor/love.js" + }, + "android": { + "loveAndroidDir": "vendor/love-android", + "displayName": "My Game", + "orientation": "landscape", + "recordAudio": false, + "versionCode": 1, + "versionName": "1.0.0" + }, + "ios": { + "loveIosDir": "vendor/love-ios", + "bundleIdentifier": "com.example.mygame", + "displayName": "My Game", + "scheme": "love-ios", + "sdk": "iphonesimulator" } }, "upload": { @@ -376,6 +396,8 @@ Build behavior: - runs a production safety preflight unless `--allow-unsafe` is passed - writes `feather-build-manifest.json` in the output directory - packages `web` by copying the configured love.js player, adding `game.love`, patching the page title/game URL, and creating an HTML zip +- packages `android` by copying a configured love-android checkout, embedding `game.love`, patching obvious app metadata, running Gradle, and copying the APK +- packages `ios` on macOS by copying a configured LÖVE iOS source tree, embedding `game.love`, running `xcodebuild`, and copying the `.app` - delegates desktop targets to `love-release`; `steamos` uses the Linux packaging path with Steam-friendly target naming **Options:** diff --git a/cli/src/commands/doctor/index.ts b/cli/src/commands/doctor/index.ts index d8d4fc34..621df534 100644 --- a/cli/src/commands/doctor/index.ts +++ b/cli/src/commands/doctor/index.ts @@ -196,6 +196,117 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) loveJsDir ?? 'not configured', 'Set targets.web.loveJsDir in feather.build.json to a local love.js checkout or build output.', ); + } else if (opts.buildTarget === 'android') { + const androidConfig = buildConfig.targets.android ?? {}; + const loveAndroidDir = androidConfig.loveAndroidDir ? resolve(projectDir, androidConfig.loveAndroidDir) : ''; + const hasLoveAndroidDir = Boolean(loveAndroidDir && existsSync(loveAndroidDir)); + add( + checks, + 'Build', + 'love-android template', + hasLoveAndroidDir ? 'pass' : 'fail', + androidConfig.loveAndroidDir ?? 'not configured', + 'Set targets.android.loveAndroidDir in feather.build.json to a local love-android checkout.', + ); + const gradleWrapper = hasLoveAndroidDir && (existsSync(join(loveAndroidDir, 'gradlew')) || existsSync(join(loveAndroidDir, 'gradlew.bat'))); + add( + checks, + 'Build', + 'Android Gradle wrapper', + gradleWrapper ? 'pass' : 'fail', + gradleWrapper ? loveAndroidDir : 'not found', + 'Use a love-android checkout that includes gradlew, or restore the Gradle wrapper files.', + ); + const java = commandVersion('java', ['-version']); + add( + checks, + 'Build', + 'JDK', + java ? 'pass' : 'fail', + java ?? 'not found', + 'Install a JDK compatible with the configured Android Gradle Plugin and make sure java is on PATH.', + ); + const androidSdk = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT; + add( + checks, + 'Build', + 'Android SDK', + androidSdk ? 'pass' : 'fail', + androidSdk ?? 'ANDROID_HOME/ANDROID_SDK_ROOT missing', + 'Install Android SDK command-line tools and set ANDROID_HOME or ANDROID_SDK_ROOT.', + ); + const productId = androidConfig.productId ?? buildConfig.productId; + add( + checks, + 'Build', + 'Android product id', + productId ? 'pass' : 'warn', + productId ?? 'missing', + 'Set productId or targets.android.productId in feather.build.json.', + ); + add( + checks, + 'Build', + 'Android signing', + 'warn', + 'debug/dev build', + 'Signed release APK/AAB support is a later phase; use native Android signing for store releases.', + ); + } else if (opts.buildTarget === 'ios') { + const iosConfig = buildConfig.targets.ios ?? {}; + const loveIosDir = iosConfig.loveIosDir ? resolve(projectDir, iosConfig.loveIosDir) : ''; + const hasLoveIosDir = Boolean(loveIosDir && existsSync(loveIosDir)); + add( + checks, + 'Build', + 'macOS host', + process.platform === 'darwin' ? 'pass' : 'fail', + process.platform, + 'iOS builds require macOS with Xcode.', + ); + const xcodebuild = commandVersion('xcodebuild', ['-version']); + add( + checks, + 'Build', + 'xcodebuild', + xcodebuild ? 'pass' : 'fail', + xcodebuild ?? 'not found', + 'Install Xcode and command line tools, then run `xcode-select --install` if needed.', + ); + add( + checks, + 'Build', + 'LÖVE iOS template', + hasLoveIosDir ? 'pass' : 'fail', + iosConfig.loveIosDir ?? 'not configured', + 'Set targets.ios.loveIosDir in feather.build.json to a local LÖVE iOS source checkout.', + ); + const xcodeProject = hasLoveIosDir && existsSync(join(loveIosDir, 'platform', 'xcode', 'love.xcodeproj')); + add( + checks, + 'Build', + 'LÖVE iOS Xcode project', + xcodeProject ? 'pass' : 'fail', + xcodeProject ? join(loveIosDir, 'platform', 'xcode', 'love.xcodeproj') : 'not found', + 'Use a LÖVE iOS source tree that includes platform/xcode/love.xcodeproj.', + ); + const bundleId = iosConfig.bundleIdentifier ?? iosConfig.productId ?? buildConfig.productId; + add( + checks, + 'Build', + 'iOS bundle id', + bundleId ? 'pass' : 'warn', + bundleId ?? 'missing', + 'Set productId, targets.ios.productId, or targets.ios.bundleIdentifier in feather.build.json.', + ); + add( + checks, + 'Build', + 'iOS signing team', + iosConfig.teamId ? 'pass' : 'warn', + iosConfig.teamId ?? 'missing', + 'Set targets.ios.teamId for device builds; simulator debug builds can usually omit it.', + ); } else if (isSupportedBuildTarget(opts.buildTarget)) { const loveRelease = commandVersion('love-release', ['--version']); add( diff --git a/cli/src/lib/build/build.ts b/cli/src/lib/build/build.ts index 39c48174..449d3588 100644 --- a/cli/src/lib/build/build.ts +++ b/cli/src/lib/build/build.ts @@ -1,9 +1,11 @@ -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, type Dirent } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve, sep } from 'node:path'; import { spawnSync } from 'node:child_process'; import { assertNoSymlinkEscape } from '../path-safety.js'; import { artifactBaseName, + buildSlug, copyDirectory, fileSize, latestManifestPath, @@ -24,6 +26,8 @@ import { type SupportedBuildTarget, } from './config.js'; +type DesktopBuildTarget = Exclude; + export type BuildOptions = LoadBuildConfigOptions & { target: BuildTarget; clean?: boolean; @@ -109,7 +113,11 @@ export function runBuild(options: BuildOptions): BuildResult { try { const artifacts = options.target === 'web' ? buildWeb(config, staged.dir) - : buildDesktop(config, options.target, staged.dir); + : options.target === 'android' + ? buildAndroid(config, staged.dir) + : options.target === 'ios' + ? buildIos(config, staged.dir) + : buildDesktop(config, options.target, staged.dir); const manifest: BuildManifest = { name: config.name, version: config.version, @@ -147,6 +155,18 @@ function plannedArtifacts(config: ResolvedBuildConfig, target: BuildTarget): Bui { target, type: 'zip', path: join(config.outDir, `${base}-html.zip`) }, ]; } + if (target === 'android') { + return [ + { target, type: 'love', path: join(config.outDir, `${base}.love`) }, + { target, type: 'apk', path: join(config.outDir, `${base}-android.apk`) }, + ]; + } + if (target === 'ios') { + return [ + { target, type: 'love', path: join(config.outDir, `${base}.love`) }, + { target, type: 'app', path: join(config.outDir, `${base}-ios.app`) }, + ]; + } return [ { target, type: 'love', path: join(config.outDir, `${base}.love`) }, { target, type: 'external', path: config.outDir }, @@ -198,7 +218,132 @@ function patchLoveJsIndex(indexPath: string, title: string): void { writeFileSync(indexPath, next); } -function buildDesktop(config: ResolvedBuildConfig, target: SupportedBuildTarget, stageDir: string): BuildArtifact[] { +function buildAndroid(config: ResolvedBuildConfig, stageDir: string): BuildArtifact[] { + const androidConfig = config.targets.android ?? {}; + const loveAndroidDir = androidConfig.loveAndroidDir ? resolve(config.projectDir, androidConfig.loveAndroidDir) : ''; + if (!loveAndroidDir || !existsSync(loveAndroidDir)) { + throw new Error('Android build requires targets.android.loveAndroidDir in feather.build.json.'); + } + + const base = artifactBaseName(config); + const lovePath = writeLoveArchive(stageDir, config.outDir, base); + const workRoot = mkdtempSync(join(tmpdir(), 'feather-android-')); + const workDir = join(workRoot, 'love-android'); + + try { + copyDirectory(loveAndroidDir, workDir); + const embeddedLovePath = join(workDir, 'app', 'src', 'embed', 'assets', 'game.love'); + mkdirSync(dirname(embeddedLovePath), { recursive: true }); + cpSync(lovePath, embeddedLovePath, { force: true }); + + patchAndroidProject(config, workDir); + + const gradleCommand = process.platform === 'win32' ? join(workDir, 'gradlew.bat') : join(workDir, 'gradlew'); + if (!existsSync(gradleCommand)) { + throw new Error('Android build requires a Gradle wrapper in targets.android.loveAndroidDir.'); + } + const gradleTask = androidConfig.gradleTask + ?? (androidConfig.recordAudio ? 'assembleEmbedRecordDebug' : 'assembleEmbedNoRecordDebug'); + const result = spawnSync(gradleCommand, [gradleTask], { + cwd: workDir, + encoding: 'utf8', + shell: process.platform === 'win32', + }); + if (result.error) throw new Error(`Gradle wrapper failed to start: ${result.error.message}`); + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || `Gradle task ${gradleTask} failed`).trim()); + } + + const apkSource = androidConfig.artifactPath + ? resolveWorkspacePath(workDir, androidConfig.artifactPath, 'Android artifact path') + : findFirstPath(join(workDir, 'app', 'build', 'outputs'), (_path, entry) => entry.isFile() && entry.name.endsWith('.apk')); + if (!apkSource || !existsSync(apkSource)) { + throw new Error('Android build completed but no APK artifact was found. Set targets.android.artifactPath if your template writes elsewhere.'); + } + const apkPath = join(config.outDir, `${base}-android.apk`); + cpSync(apkSource, apkPath, { force: true }); + + return [ + { target: 'android', type: 'love', path: lovePath }, + { target: 'android', type: 'apk', path: apkPath }, + ]; + } finally { + rmSync(workRoot, { recursive: true, force: true }); + } +} + +function buildIos(config: ResolvedBuildConfig, stageDir: string): BuildArtifact[] { + if (process.platform !== 'darwin' && process.env.FEATHER_TEST_ALLOW_IOS_BUILD !== '1') { + throw new Error('iOS builds require macOS with Xcode. Run `feather doctor --build-target ios` for setup guidance.'); + } + + const iosConfig = config.targets.ios ?? {}; + const loveIosDir = iosConfig.loveIosDir ? resolve(config.projectDir, iosConfig.loveIosDir) : ''; + if (!loveIosDir || !existsSync(loveIosDir)) { + throw new Error('iOS build requires targets.ios.loveIosDir in feather.build.json.'); + } + + const base = artifactBaseName(config); + const lovePath = writeLoveArchive(stageDir, config.outDir, base); + const workRoot = mkdtempSync(join(tmpdir(), 'feather-ios-')); + const workDir = join(workRoot, 'love-ios'); + const derivedDataPath = iosConfig.derivedDataPath + ? resolve(config.projectDir, iosConfig.derivedDataPath) + : join(workRoot, 'DerivedData'); + + try { + copyDirectory(loveIosDir, workDir); + const xcodeProject = join(workDir, 'platform', 'xcode', 'love.xcodeproj'); + if (!existsSync(xcodeProject)) { + throw new Error('iOS build requires platform/xcode/love.xcodeproj in targets.ios.loveIosDir.'); + } + + const gameLovePath = join(workDir, 'platform', 'xcode', 'game.love'); + mkdirSync(dirname(gameLovePath), { recursive: true }); + cpSync(lovePath, gameLovePath, { force: true }); + patchIosProject(join(xcodeProject, 'project.pbxproj')); + + const bundleIdentifier = iosConfig.bundleIdentifier ?? iosConfig.productId ?? config.productId ?? defaultProductId(config, 'ios'); + const args = [ + '-project', + xcodeProject, + '-scheme', + iosConfig.scheme ?? 'love-ios', + '-configuration', + iosConfig.configuration ?? 'Debug', + '-sdk', + iosConfig.sdk ?? 'iphonesimulator', + '-derivedDataPath', + derivedDataPath, + `PRODUCT_BUNDLE_IDENTIFIER=${bundleIdentifier}`, + `INFOPLIST_KEY_CFBundleDisplayName=${iosConfig.displayName ?? config.name}`, + ]; + if (iosConfig.teamId) args.push(`DEVELOPMENT_TEAM=${iosConfig.teamId}`); + args.push('build'); + + const result = spawnSync('xcodebuild', args, { cwd: workDir, encoding: 'utf8' }); + if (result.error) throw new Error('xcodebuild not found. Run `feather doctor --build-target ios`.'); + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || 'xcodebuild failed').trim()); + } + + const appSource = findFirstPath(derivedDataPath, (_path, entry) => entry.isDirectory() && entry.name.endsWith('.app')); + if (!appSource || !existsSync(appSource)) { + throw new Error('iOS build completed but no .app artifact was found. Check targets.ios.derivedDataPath or Xcode build settings.'); + } + const appPath = join(config.outDir, `${base}-ios.app`); + copyDirectory(appSource, appPath); + + return [ + { target: 'ios', type: 'love', path: lovePath }, + { target: 'ios', type: 'app', path: appPath }, + ]; + } finally { + rmSync(workRoot, { recursive: true, force: true }); + } +} + +function buildDesktop(config: ResolvedBuildConfig, target: DesktopBuildTarget, stageDir: string): BuildArtifact[] { const normalizedTarget = target === 'steamos' ? 'linux' : target; const base = artifactBaseName(config); const lovePath = writeLoveArchive(stageDir, config.outDir, base); @@ -224,6 +369,134 @@ function buildDesktop(config: ResolvedBuildConfig, target: SupportedBuildTarget, ]; } +function patchAndroidProject(config: ResolvedBuildConfig, workDir: string): void { + const androidConfig = config.targets.android ?? {}; + const productId = androidConfig.productId ?? config.productId ?? defaultProductId(config, 'android'); + const versionName = androidConfig.versionName ?? config.version; + const versionCode = androidConfig.versionCode ?? 1; + const displayName = androidConfig.displayName ?? config.name; + const orientation = androidConfig.orientation ?? 'landscape'; + + for (const file of ['app/build.gradle', 'app/build.gradle.kts', 'build.gradle', 'build.gradle.kts']) { + patchTextFile(join(workDir, file), (source) => source + .replace(/applicationId\s*(?:=)?\s*["'][^"']+["']/g, (match) => assignmentValue(match, 'applicationId', productId)) + .replace(/namespace\s*(?:=)?\s*["'][^"']+["']/g, (match) => assignmentValue(match, 'namespace', productId)) + .replace(/versionName\s*(?:=)?\s*["'][^"']+["']/g, (match) => assignmentValue(match, 'versionName', versionName)) + .replace(/versionCode\s*(?:=)?\s*\d+/g, (match) => assignmentValue(match, 'versionCode', String(versionCode), false))); + } + + for (const file of ['app/src/main/res/values/strings.xml', 'app/src/embed/res/values/strings.xml']) { + patchTextFile(join(workDir, file), (source) => source.replace( + /[\s\S]*?<\/string>/, + `${escapeXml(displayName)}`, + )); + } + + for (const file of ['app/src/main/AndroidManifest.xml', 'app/src/embed/AndroidManifest.xml']) { + patchTextFile(join(workDir, file), (source) => { + let next = source + .replace(/android:label=["'][^"']*["']/g, `android:label="${escapeXml(displayName)}"`) + .replace(/android:screenOrientation=["'][^"']*["']/g, `android:screenOrientation="${escapeXml(orientation)}"`); + if (!/android:label=/.test(next)) { + next = next.replace(/]*)>/, ``); + } + if (!/android:screenOrientation=/.test(next)) { + next = next.replace(/]*)>/, ``); + } + if (androidConfig.recordAudio) { + if (!/android\.permission\.RECORD_AUDIO/.test(next)) { + next = next.replace(/]*>/, (match) => `${match}\n `); + } + } else { + next = next + .split('\n') + .filter((line) => !line.includes('android.permission.RECORD_AUDIO')) + .join('\n'); + } + return next; + }); + } +} + +function patchIosProject(projectPath: string): void { + if (!existsSync(projectPath)) return; + const source = readFileSync(projectPath, 'utf8'); + if (source.includes('game.love')) return; + const buildFileId = 'FEATHERGAMELOVE000000000001'; + const fileRefId = 'FEATHERGAMELOVE000000000002'; + let next = source; + if (next.includes('/* Begin PBXBuildFile section */')) { + next = next.replace( + /\/\* Begin PBXBuildFile section \*\/\n/, + `/* Begin PBXBuildFile section */\n\t\t${buildFileId} /* game.love in Resources */ = {isa = PBXBuildFile; fileRef = ${fileRefId} /* game.love */; };\n`, + ); + } + if (next.includes('/* Begin PBXFileReference section */')) { + next = next.replace( + /\/\* Begin PBXFileReference section \*\/\n/, + `/* Begin PBXFileReference section */\n\t\t${fileRefId} /* game.love */ = {isa = PBXFileReference; lastKnownFileType = archive.love; name = game.love; path = game.love; sourceTree = ""; };\n`, + ); + } + next = next.replace( + /(isa = PBXResourcesBuildPhase;[\s\S]*?files = \(\n)/, + `$1\t\t\t\t${buildFileId} /* game.love in Resources */,\n`, + ); + if (next === source) next = `${source}\n/* Feather: include game.love in the app resources. */\n`; + writeFileSync(projectPath, next); +} + +function patchTextFile(path: string, update: (source: string) => string): void { + if (!existsSync(path)) return; + const source = readFileSync(path, 'utf8'); + const next = update(source); + if (next !== source) writeFileSync(path, next); +} + +function assignmentValue(match: string, key: string, value: string, quote = true): string { + const separator = match.includes('=') ? ' = ' : ' '; + return `${key}${separator}${quote ? `"${value}"` : value}`; +} + +function defaultProductId(config: ResolvedBuildConfig, target: 'android' | 'ios'): string { + const slug = buildSlug(config.name) + .replace(/[^a-z0-9]+/g, '.') + .replace(/^\.+|\.+$/g, '') + || 'game'; + return `org.feather.${slug}.${target}`; +} + +function resolveWorkspacePath(root: string, path: string, label: string): string { + const absolute = resolve(root, path); + const normalizedRoot = resolve(root); + if (absolute !== normalizedRoot && !absolute.startsWith(`${normalizedRoot}${sep}`)) { + throw new Error(`${label} must stay inside the native build workspace.`); + } + return absolute; +} + +function findFirstPath(root: string, predicate: (path: string, entry: Dirent) => boolean): string | null { + if (!existsSync(root)) return null; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (predicate(path, entry)) return path; + if (entry.isDirectory()) { + const found = findFirstPath(path, predicate); + if (found) return found; + } + } + return null; +} + +function escapeXml(value: string): string { + return value.replace(/[<>&"']/g, (char) => ({ + '<': '<', + '>': '>', + '&': '&', + '"': '"', + "'": ''', + }[char]!)); +} + function escapeHtml(value: string): string { return value.replace(/[&<>"']/g, (char) => ({ '&': '&', diff --git a/cli/src/lib/build/config.ts b/cli/src/lib/build/config.ts index 00951b67..cc49ad73 100644 --- a/cli/src/lib/build/config.ts +++ b/cli/src/lib/build/config.ts @@ -3,17 +3,46 @@ import { basename, dirname, join, resolve } from 'node:path'; import { assertNoSymlinkEscape, assertSafeRelativePath, isPathInside } from '../path-safety.js'; export const buildTargets = ['web', 'android', 'ios', 'windows', 'macos', 'linux', 'steamos'] as const; -export const supportedBuildTargets = ['web', 'windows', 'macos', 'linux', 'steamos'] as const; +export const supportedBuildTargets = ['web', 'android', 'ios', 'windows', 'macos', 'linux', 'steamos'] as const; export const uploadTargets = ['itch', 'steam'] as const; export type BuildTarget = typeof buildTargets[number]; export type SupportedBuildTarget = typeof supportedBuildTargets[number]; export type UploadTarget = typeof uploadTargets[number]; +export type AndroidBuildTargetConfig = { + productId?: string; + loveAndroidDir?: string; + displayName?: string; + orientation?: string; + recordAudio?: boolean; + versionCode?: number; + versionName?: string; + gradleTask?: string; + artifactPath?: string; +}; + +export type IosBuildTargetConfig = { + productId?: string; + loveIosDir?: string; + bundleIdentifier?: string; + displayName?: string; + scheme?: string; + configuration?: string; + sdk?: string; + derivedDataPath?: string; + teamId?: string; +}; + export type FeatherBuildConfig = { name?: string; version?: string; loveVersion?: string; + productId?: string; + description?: string; + company?: string; + website?: string; + copyright?: string; sourceDir?: string; outDir?: string; include?: string[]; @@ -30,6 +59,8 @@ export type FeatherBuildConfig = { macos?: Record; linux?: Record; steamos?: Record; + android?: AndroidBuildTargetConfig; + ios?: IosBuildTargetConfig; }; upload?: { itch?: { @@ -47,6 +78,11 @@ export type ResolvedBuildConfig = { name: string; version: string; loveVersion?: string; + productId?: string; + description?: string; + company?: string; + website?: string; + copyright?: string; include: string[]; exclude: string[]; icon?: string; @@ -146,6 +182,11 @@ export function loadBuildConfig(options: LoadBuildConfigOptions = {}): ResolvedB name, version, loveVersion: raw.loveVersion, + productId: raw.productId, + description: raw.description, + company: raw.company, + website: raw.website, + copyright: raw.copyright, include: stringArray(raw.include, 'include'), exclude, icon: raw.icon, diff --git a/cli/test/commands.test.mjs b/cli/test/commands.test.mjs index a73c14b7..88a10dfe 100644 --- a/cli/test/commands.test.mjs +++ b/cli/test/commands.test.mjs @@ -146,6 +146,78 @@ function writeFakeLoveJs(dir) { return loveJsDir; } +function writeFakeLoveAndroid(dir, recordPath = join(dir, 'gradle-record.json')) { + const root = join(dir, 'love-android'); + mkdirSync(join(root, 'app', 'src', 'main', 'res', 'values'), { recursive: true }); + mkdirSync(join(root, 'app', 'src', 'main'), { recursive: true }); + writeFileSync( + join(root, 'app', 'build.gradle'), + `android { + namespace "org.love2d.android" + defaultConfig { + applicationId "org.love2d.android" + versionCode 1 + versionName "0.0.0" + } +} +`, + ); + writeFileSync( + join(root, 'app', 'src', 'main', 'AndroidManifest.xml'), + ` + + + + +`, + ); + writeFileSync(join(root, 'app', 'src', 'main', 'res', 'values', 'strings.xml'), 'LÖVE\n'); + const gradlew = join(root, 'gradlew'); + writeFileSync( + gradlew, + `#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +const cwd = process.cwd(); +const apk = path.join(cwd, 'app', 'build', 'outputs', 'apk', 'embed', 'debug', 'app-embed-debug.apk'); +fs.mkdirSync(path.dirname(apk), { recursive: true }); +fs.writeFileSync(apk, 'fake apk'); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ + argv: process.argv.slice(2), + embeddedLoveExists: fs.existsSync(path.join(cwd, 'app', 'src', 'embed', 'assets', 'game.love')), + gradle: fs.readFileSync(path.join(cwd, 'app', 'build.gradle'), 'utf8'), + manifest: fs.readFileSync(path.join(cwd, 'app', 'src', 'main', 'AndroidManifest.xml'), 'utf8'), +}, null, 2)); +process.exit(0); +`, + ); + chmodSync(gradlew, 0o755); + return { root, recordPath }; +} + +function writeFakeLoveIos(dir) { + const root = join(dir, 'love-ios'); + const projectDir = join(root, 'platform', 'xcode', 'love.xcodeproj'); + mkdirSync(projectDir, { recursive: true }); + writeFileSync( + join(projectDir, 'project.pbxproj'), + `// !$*UTF8*$! +{ +/* Begin PBXBuildFile section */ +/* End PBXBuildFile section */ +/* Begin PBXFileReference section */ +/* End PBXFileReference section */ + 1234567890ABCDEF00000001 /* Resources */ = { + isa = PBXResourcesBuildPhase; + files = ( + ); + }; +} +`, + ); + return root; +} + function writeBuildConfig(dir, config) { writeFileSync(join(dir, 'feather.build.json'), `${JSON.stringify(config, null, 2)}\n`); } @@ -609,12 +681,118 @@ process.exit(0); assert.ok(record.argv.includes('Desktop Game')); }); -test('build: unsupported mobile targets fail with planned-support guidance', () => { +test('build android: injects game.love, runs Gradle, copies APK, and writes manifest', () => { const dir = makeTmp(); writeGame(dir); + const { recordPath } = writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Mobile Game', + version: '1.2.3', + productId: 'com.example.mobilegame', + targets: { + android: { + loveAndroidDir: 'love-android', + displayName: 'Mobile Game Dev', + orientation: 'landscape', + recordAudio: true, + versionCode: 7, + versionName: '1.2.3-dev', + }, + }, + }); + const result = run(['build', 'android', '--dir', dir, '--json']); - assert.equal(result.exitCode, 1); - assert.ok(outputOf(result).includes('planned but not supported yet')); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, true); + assert.equal(parsed.target, 'android'); + assert.equal(existsSync(join(dir, 'builds', 'mobile-game-1.2.3.love')), true); + assert.equal(existsSync(join(dir, 'builds', 'mobile-game-1.2.3-android.apk')), true); + assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'apk'), true); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.deepEqual(record.argv, ['assembleEmbedRecordDebug']); + assert.equal(record.embeddedLoveExists, true); + assert.ok(record.gradle.includes('applicationId "com.example.mobilegame"')); + assert.ok(record.gradle.includes('versionCode 7')); + assert.ok(record.gradle.includes('versionName "1.2.3-dev"')); + assert.ok(record.manifest.includes('android:label="Mobile Game Dev"')); + assert.ok(record.manifest.includes('android:screenOrientation="landscape"')); + assert.ok(record.manifest.includes('android.permission.RECORD_AUDIO')); + const manifest = JSON.parse(readFileSync(join(dir, 'builds', 'feather-build-manifest.json'), 'utf8')); + assert.equal(manifest.target, 'android'); + assert.equal(manifest.artifacts.some((artifact) => artifact.type === 'apk'), true); +}); + +test('build ios: injects game.love, runs xcodebuild, copies app, and writes manifest', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'iOS Game', + version: '5.0.0', + targets: { + ios: { + loveIosDir: 'love-ios', + bundleIdentifier: 'com.example.iosgame', + displayName: 'iOS Game Dev', + }, + }, + }); + const recordPath = join(dir, 'xcodebuild-record.json'); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` +if (process.argv.includes('-version')) { + console.log('Xcode 99.0'); + process.exit(0); +} +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +const derivedData = args[args.indexOf('-derivedDataPath') + 1]; +const app = path.join(derivedData, 'Build', 'Products', 'Debug-iphonesimulator', 'love-ios.app'); +fs.mkdirSync(app, { recursive: true }); +fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ + argv: args, + gameLoveExists: fs.existsSync(path.join(process.cwd(), 'platform', 'xcode', 'game.love')), + projectContainsGameLove: fs.readFileSync(path.join(process.cwd(), 'platform', 'xcode', 'love.xcodeproj', 'project.pbxproj'), 'utf8').includes('game.love'), +}, null, 2)); +process.exit(0); +`); + + const result = run(['build', 'ios', '--dir', dir, '--json'], { env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }) }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, true); + assert.equal(parsed.target, 'ios'); + assert.equal(existsSync(join(dir, 'builds', 'ios-game-5.0.0.love')), true); + assert.equal(existsSync(join(dir, 'builds', 'ios-game-5.0.0-ios.app')), true); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.ok(record.argv.includes('-scheme')); + assert.ok(record.argv.includes('love-ios')); + assert.ok(record.argv.includes('-sdk')); + assert.ok(record.argv.includes('iphonesimulator')); + assert.ok(record.argv.includes('PRODUCT_BUNDLE_IDENTIFIER=com.example.iosgame')); + assert.ok(record.argv.includes('INFOPLIST_KEY_CFBundleDisplayName=iOS Game Dev')); + assert.equal(record.gameLoveExists, true); + assert.equal(record.projectContainsGameLove, true); + const manifest = JSON.parse(readFileSync(join(dir, 'builds', 'feather-build-manifest.json'), 'utf8')); + assert.equal(manifest.target, 'ios'); + assert.equal(manifest.artifacts.some((artifact) => artifact.type === 'app'), true); +}); + +test('build mobile: missing native template paths fail with actionable errors', () => { + const dir = makeTmp(); + writeGame(dir); + + const android = run(['build', 'android', '--dir', dir, '--json']); + assert.equal(android.exitCode, 1); + assert.ok(outputOf(android).includes('targets.android.loveAndroidDir')); + + const ios = run(['build', 'ios', '--dir', dir, '--json'], { env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0', FEATHER_TEST_ALLOW_IOS_BUILD: '1' } }); + assert.equal(ios.exitCode, 1); + assert.ok(outputOf(ios).includes('targets.ios.loveIosDir')); }); test('build: production preflight blocks unsafe Feather config unless explicitly allowed', () => { @@ -732,6 +910,71 @@ test('doctor: build and upload target checks report missing and configured depen assert.equal(labels.get('BUTLER_API_KEY')?.severity, 'pass'); }); +test('doctor: android build target reports template and local tool setup', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Android Doctor Game', version: '1.0.0' }); + const { parsed: missing } = parseDoctorJsonResult(dir, ['--build-target', 'android']); + const missingLabels = new Map(missing.checks.map((check) => [check.label, check])); + assert.equal(missingLabels.get('love-android template')?.severity, 'fail'); + assert.equal(missingLabels.get('Android Gradle wrapper')?.severity, 'fail'); + assert.ok(missingLabels.get('love-android template')?.fix.includes('targets.android.loveAndroidDir')); + + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Android Doctor Game', + version: '1.0.0', + productId: 'com.example.androiddoctor', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + const { binDir } = writeFakeCommand(dir, 'java', `console.error('java version "17.0.0"'); process.exit(0);`); + const configured = run(['doctor', dir, '--json', '--build-target', 'android'], { + env: envWithPath(binDir, { ANDROID_HOME: join(dir, 'android-sdk') }), + }); + assert.equal(configured.stdout.trim().startsWith('{'), true, outputOf(configured)); + const parsed = JSON.parse(configured.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('love-android template')?.severity, 'pass'); + assert.equal(labels.get('Android Gradle wrapper')?.severity, 'pass'); + assert.equal(labels.get('JDK')?.severity, 'pass'); + assert.equal(labels.get('Android SDK')?.severity, 'pass'); + assert.equal(labels.get('Android product id')?.severity, 'pass'); + assert.equal(labels.get('Android signing')?.severity, 'warn'); +}); + +test('doctor: ios build target reports platform, template, Xcode, and signing hints', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'iOS Doctor Game', version: '1.0.0' }); + const { parsed: missing } = parseDoctorJsonResult(dir, ['--build-target', 'ios']); + const missingLabels = new Map(missing.checks.map((check) => [check.label, check])); + assert.equal(missingLabels.get('LÖVE iOS template')?.severity, 'fail'); + assert.equal(missingLabels.get('LÖVE iOS Xcode project')?.severity, 'fail'); + + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'iOS Doctor Game', + version: '1.0.0', + targets: { ios: { loveIosDir: 'love-ios', bundleIdentifier: 'com.example.iosdoctor' } }, + }); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', `console.log('Xcode 99.0'); process.exit(0);`); + const configured = run(['doctor', dir, '--json', '--build-target', 'ios'], { env: envWithPath(binDir) }); + assert.equal(configured.stdout.trim().startsWith('{'), true, outputOf(configured)); + const parsed = JSON.parse(configured.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('xcodebuild')?.severity, 'pass'); + assert.equal(labels.get('LÖVE iOS template')?.severity, 'pass'); + assert.equal(labels.get('LÖVE iOS Xcode project')?.severity, 'pass'); + assert.equal(labels.get('iOS bundle id')?.severity, 'pass'); + assert.equal(labels.get('iOS signing team')?.severity, 'warn'); + if (process.platform === 'darwin') { + assert.equal(labels.get('macOS host')?.severity, 'pass'); + } else { + assert.equal(labels.get('macOS host')?.severity, 'fail'); + assert.equal(configured.exitCode, 1, outputOf(configured)); + } +}); + test('command runtime redacts API keys from compact and debug errors', async () => { const { runCliAction } = await import('../dist/lib/command.js'); const originalError = console.error; diff --git a/docs/index.md b/docs/index.md index 2b49b9c4..7aa114bc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,7 +20,7 @@ Like Flipper or React DevTools, but for your game. Inspect logs, variables, perf - 🖼️ **Asset inspector** — Browse loaded textures, fonts, and audio sources with previews, zoom, pan, and pixel grid. - 📁 **Log file viewer** — Open `.featherlog` files for offline inspection. - 🖥️ **CLI-first workflow** — `feather init`, `feather run`, and `feather remove` manage setup and cleanup. -- 🚢 **Build/upload helpers** — `feather build` creates local web/desktop release artifacts and `feather upload itch` pushes them with Butler. +- 🚢 **Build/upload helpers** — `feather build` creates local web, mobile dev, and desktop artifacts and `feather upload itch` pushes them with Butler. - ⚡ **Guarded in-game setup** — Generated imports load only when `USE_DEBUGGER` is enabled. - 📦 **Config file support** — `feather.config.lua` keeps project settings outside game code. diff --git a/docs/installation.md b/docs/installation.md index 4f0fcd8e..0238bf6f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -62,7 +62,7 @@ feather doctor path/to/my-game --build-target web feather doctor path/to/my-game --upload-target itch ``` -`feather build` supports web, Windows, macOS, Linux, and SteamOS artifacts. Web builds need a local love.js player directory configured in `feather.build.json`; desktop builds expect `love-release` on `PATH`. `feather upload itch` expects Butler on `PATH` and either `BUTLER_API_KEY` in CI or a local `butler login` session. +`feather build` supports web, Android, iOS, Windows, macOS, Linux, and SteamOS artifacts. Web builds need a local love.js player directory configured in `feather.build.json`; Android/iOS dev builds need local love-android or LÖVE iOS template paths; desktop builds expect `love-release` on `PATH`. `feather upload itch` expects Butler on `PATH` and either `BUTLER_API_KEY` in CI or a local `butler login` session. For CI or release scripts that need a security-only JSON report: diff --git a/docs/recommendations.md b/docs/recommendations.md index 9cf950a3..fe80c25c 100644 --- a/docs/recommendations.md +++ b/docs/recommendations.md @@ -119,7 +119,7 @@ feather build web --dir path/to/my-game --json feather upload itch web --dir path/to/my-game --dry-run --json ``` -Web builds package a local love.js player; desktop targets delegate packaging to `love-release`; Itch uploads use Butler. Android, iOS, and Steam upload are intentionally planned-support paths until their release flows are hardened. +Web builds package a local love.js player; Android and iOS create dev artifacts from configured local LÖVE native templates; desktop targets delegate packaging to `love-release`; Itch uploads use Butler. Signed mobile releases and Steam upload are intentionally later phases until those release flows are hardened. --- From e73c3a07e30c6835686736647002f09c3c308c7a Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 11:26:45 -0400 Subject: [PATCH 42/68] cli: improve builds --- cli/README.md | 8 + cli/src/commands/doctor/index.ts | 30 ++- cli/src/index.ts | 4 +- cli/src/lib/build/android.ts | 115 ++++++++++ cli/src/lib/build/build.ts | 352 +------------------------------ cli/src/lib/build/desktop.ts | 31 +++ cli/src/lib/build/ios.ts | 107 ++++++++++ cli/src/lib/build/native.ts | 78 +++++++ cli/src/lib/build/validation.ts | 178 ++++++++++++++++ cli/src/lib/build/web.ts | 59 ++++++ cli/test/commands.test.mjs | 166 ++++++++++++++- docs/installation.md | 2 + 12 files changed, 772 insertions(+), 358 deletions(-) create mode 100644 cli/src/lib/build/android.ts create mode 100644 cli/src/lib/build/desktop.ts create mode 100644 cli/src/lib/build/ios.ts create mode 100644 cli/src/lib/build/native.ts create mode 100644 cli/src/lib/build/validation.ts create mode 100644 cli/src/lib/build/web.ts diff --git a/cli/README.md b/cli/README.md index 4bacae73..ef2f9d31 100644 --- a/cli/README.md +++ b/cli/README.md @@ -416,6 +416,14 @@ Build behavior: Run `feather doctor --build-target ` to see missing local dependencies and exact setup guidance before building. +Mobile build notes: + +- Android builds expect `targets.android.loveAndroidDir` to point at a local love-android checkout with `gradlew`. +- iOS builds expect `targets.ios.loveIosDir` to point at a local LÖVE iOS source tree with `platform/xcode/love.xcodeproj`. +- `feather doctor --build-target android` validates product id, Gradle wrapper, JDK, and Android SDK environment. +- `feather doctor --build-target ios` validates bundle id, macOS/Xcode setup, template path, and signing hints. +- These are development builds; signed Android release/AAB and iOS archive/export flows are intentionally separate later work. + --- ### `feather upload ` diff --git a/cli/src/commands/doctor/index.ts b/cli/src/commands/doctor/index.ts index 621df534..c45cacd4 100644 --- a/cli/src/commands/doctor/index.ts +++ b/cli/src/commands/doctor/index.ts @@ -15,6 +15,7 @@ import { outDirWritableDetail, uploadTargets, } from '../../lib/build/config.js'; +import { androidProductId, iosBundleIdentifier, validateBuildConfigForTarget } from '../../lib/build/validation.js'; import { auditLockfile } from '../../lib/package/audit.js'; import { readLockfile } from '../../lib/package/lockfile.js'; import { lockfileEntrySourceSummary, lockfileUrlFindings } from '../../lib/package/provenance.js'; @@ -126,7 +127,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) opts.buildTarget, isSupportedBuildTarget(opts.buildTarget) ? undefined - : `${opts.buildTarget} support is planned; use web, windows, macos, linux, or steamos for now.`, + : `${opts.buildTarget} support is registered but not enabled in this build.`, ); } } @@ -186,6 +187,17 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) ); if (opts.buildTarget && isBuildTarget(opts.buildTarget)) { + const configIssues = validateBuildConfigForTarget(buildConfig, opts.buildTarget); + if (opts.buildTarget === 'android' || opts.buildTarget === 'ios') { + add( + checks, + 'Build', + opts.buildTarget === 'android' ? 'Android config' : 'iOS config', + configIssues.length === 0 ? 'pass' : 'fail', + configIssues.length === 0 ? 'valid' : configIssues.map((issue) => `${issue.field}: ${issue.message}`).join('; '), + configIssues.length === 0 ? undefined : 'Fix feather.build.json before running the build.', + ); + } if (opts.buildTarget === 'web') { const loveJsDir = buildConfig.targets.web?.loveJsDir; add( @@ -235,14 +247,14 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) androidSdk ?? 'ANDROID_HOME/ANDROID_SDK_ROOT missing', 'Install Android SDK command-line tools and set ANDROID_HOME or ANDROID_SDK_ROOT.', ); - const productId = androidConfig.productId ?? buildConfig.productId; + const productId = androidProductId(buildConfig); add( checks, 'Build', 'Android product id', - productId ? 'pass' : 'warn', - productId ?? 'missing', - 'Set productId or targets.android.productId in feather.build.json.', + configIssues.some((issue) => issue.field === 'productId') ? 'fail' : 'pass', + productId, + configIssues.some((issue) => issue.field === 'productId') ? 'Set a valid productId or targets.android.productId in feather.build.json.' : undefined, ); add( checks, @@ -290,14 +302,14 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) xcodeProject ? join(loveIosDir, 'platform', 'xcode', 'love.xcodeproj') : 'not found', 'Use a LÖVE iOS source tree that includes platform/xcode/love.xcodeproj.', ); - const bundleId = iosConfig.bundleIdentifier ?? iosConfig.productId ?? buildConfig.productId; + const bundleId = iosBundleIdentifier(buildConfig); add( checks, 'Build', 'iOS bundle id', - bundleId ? 'pass' : 'warn', - bundleId ?? 'missing', - 'Set productId, targets.ios.productId, or targets.ios.bundleIdentifier in feather.build.json.', + configIssues.some((issue) => issue.field === 'bundleIdentifier') ? 'fail' : 'pass', + bundleId, + configIssues.some((issue) => issue.field === 'bundleIdentifier') ? 'Set a valid productId, targets.ios.productId, or targets.ios.bundleIdentifier in feather.build.json.' : undefined, ); add( checks, diff --git a/cli/src/index.ts b/cli/src/index.ts index b9cf4a19..602a9b9b 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -130,7 +130,7 @@ program program .command('build ') - .description('Build a LÖVE game for web, desktop, or planned mobile targets') + .description('Build a LÖVE game for web, mobile dev, or desktop targets') .option('--dir ', 'Project directory (default: current directory)') .option('--config ', 'Path to feather.build.json') .option('--out-dir ', 'Build output directory') @@ -154,7 +154,7 @@ program program .command('upload [build-target]') - .description('Upload built artifacts to itch.io or planned store targets') + .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') diff --git a/cli/src/lib/build/android.ts b/cli/src/lib/build/android.ts new file mode 100644 index 00000000..06005ad2 --- /dev/null +++ b/cli/src/lib/build/android.ts @@ -0,0 +1,115 @@ +import { spawnSync } from 'node:child_process'; +import { cpSync, existsSync, mkdirSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { artifactBaseName, writeLoveArchive, type BuildArtifact } from './files.js'; +import type { ResolvedBuildConfig } from './config.js'; +import { + assignmentValue, + createNativeWorkspace, + escapeXml, + findFirstPath, + patchTextFile, + resolveWorkspacePath, +} from './native.js'; +import { androidProductId } from './validation.js'; + +export function buildAndroid(config: ResolvedBuildConfig, stageDir: string): BuildArtifact[] { + const androidConfig = config.targets.android ?? {}; + const loveAndroidDir = androidConfig.loveAndroidDir ? resolve(config.projectDir, androidConfig.loveAndroidDir) : ''; + if (!loveAndroidDir || !existsSync(loveAndroidDir)) { + throw new Error('Android build requires targets.android.loveAndroidDir in feather.build.json.'); + } + + const base = artifactBaseName(config); + const lovePath = writeLoveArchive(stageDir, config.outDir, base); + const workspace = createNativeWorkspace('feather-android-', loveAndroidDir, 'love-android'); + + try { + const embeddedLovePath = join(workspace.dir, 'app', 'src', 'embed', 'assets', 'game.love'); + mkdirSync(dirname(embeddedLovePath), { recursive: true }); + cpSync(lovePath, embeddedLovePath, { force: true }); + + patchAndroidProject(config, workspace.dir); + + const gradleCommand = process.platform === 'win32' ? join(workspace.dir, 'gradlew.bat') : join(workspace.dir, 'gradlew'); + if (!existsSync(gradleCommand)) { + throw new Error('Android build requires a Gradle wrapper in targets.android.loveAndroidDir.'); + } + const gradleTask = androidConfig.gradleTask + ?? (androidConfig.recordAudio ? 'assembleEmbedRecordDebug' : 'assembleEmbedNoRecordDebug'); + const result = spawnSync(gradleCommand, [gradleTask], { + cwd: workspace.dir, + encoding: 'utf8', + shell: process.platform === 'win32', + }); + if (result.error) throw new Error(`Gradle wrapper failed to start: ${result.error.message}`); + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || `Gradle task ${gradleTask} failed`).trim()); + } + + const apkSource = androidConfig.artifactPath + ? resolveWorkspacePath(workspace.dir, androidConfig.artifactPath, 'Android artifact path') + : findFirstPath(join(workspace.dir, 'app', 'build', 'outputs'), (_path, entry) => entry.isFile() && entry.name.endsWith('.apk')); + if (!apkSource || !existsSync(apkSource)) { + throw new Error('Android build completed but no APK artifact was found. Set targets.android.artifactPath if your template writes elsewhere.'); + } + const apkPath = join(config.outDir, `${base}-android.apk`); + cpSync(apkSource, apkPath, { force: true }); + + return [ + { target: 'android', type: 'love', path: lovePath }, + { target: 'android', type: 'apk', path: apkPath }, + ]; + } finally { + workspace.cleanup(); + } +} + +export function patchAndroidProject(config: ResolvedBuildConfig, workDir: string): void { + const androidConfig = config.targets.android ?? {}; + const productId = androidProductId(config); + const versionName = androidConfig.versionName ?? config.version; + const versionCode = androidConfig.versionCode ?? 1; + const displayName = androidConfig.displayName ?? config.name; + const orientation = androidConfig.orientation ?? 'landscape'; + + for (const file of ['app/build.gradle', 'app/build.gradle.kts', 'build.gradle', 'build.gradle.kts']) { + patchTextFile(join(workDir, file), (source) => source + .replace(/applicationId\s*(?:=)?\s*["'][^"']+["']/g, (match) => assignmentValue(match, 'applicationId', productId)) + .replace(/namespace\s*(?:=)?\s*["'][^"']+["']/g, (match) => assignmentValue(match, 'namespace', productId)) + .replace(/versionName\s*(?:=)?\s*["'][^"']+["']/g, (match) => assignmentValue(match, 'versionName', versionName)) + .replace(/versionCode\s*(?:=)?\s*\d+/g, (match) => assignmentValue(match, 'versionCode', String(versionCode), false))); + } + + for (const file of ['app/src/main/res/values/strings.xml', 'app/src/embed/res/values/strings.xml']) { + patchTextFile(join(workDir, file), (source) => source.replace( + /[\s\S]*?<\/string>/, + `${escapeXml(displayName)}`, + )); + } + + for (const file of ['app/src/main/AndroidManifest.xml', 'app/src/embed/AndroidManifest.xml']) { + patchTextFile(join(workDir, file), (source) => { + let next = source + .replace(/android:label=["'][^"']*["']/g, `android:label="${escapeXml(displayName)}"`) + .replace(/android:screenOrientation=["'][^"']*["']/g, `android:screenOrientation="${escapeXml(orientation)}"`); + if (!/android:label=/.test(next)) { + next = next.replace(/]*)>/, ``); + } + if (!/android:screenOrientation=/.test(next)) { + next = next.replace(/]*)>/, ``); + } + if (androidConfig.recordAudio) { + if (!/android\.permission\.RECORD_AUDIO/.test(next)) { + next = next.replace(/]*>/, (match) => `${match}\n `); + } + } else { + next = next + .split('\n') + .filter((line) => !line.includes('android.permission.RECORD_AUDIO')) + .join('\n'); + } + return next; + }); + } +} diff --git a/cli/src/lib/build/build.ts b/cli/src/lib/build/build.ts index 449d3588..0273ea40 100644 --- a/cli/src/lib/build/build.ts +++ b/cli/src/lib/build/build.ts @@ -1,19 +1,13 @@ -import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, type Dirent } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join, resolve, sep } from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; import { assertNoSymlinkEscape } from '../path-safety.js'; import { artifactBaseName, - buildSlug, - copyDirectory, fileSize, latestManifestPath, listProjectFiles, stageProject, - writeDirectoryZip, writeJson, - writeLoveArchive, type BuildArtifact, type BuildManifest, } from './files.js'; @@ -25,8 +19,11 @@ import { type ResolvedBuildConfig, type SupportedBuildTarget, } from './config.js'; - -type DesktopBuildTarget = Exclude; +import { buildWeb } from './web.js'; +import { buildAndroid } from './android.js'; +import { buildIos } from './ios.js'; +import { buildDesktop } from './desktop.js'; +import { assertBuildConfigValidForTarget } from './validation.js'; export type BuildOptions = LoadBuildConfigOptions & { target: BuildTarget; @@ -102,6 +99,7 @@ export function runBuild(options: BuildOptions): BuildResult { try { assertBuildTargetSupported(options.target); const config = loadBuildConfig(options); + assertBuildConfigValidForTarget(config, options.target); assertProductionBuildSafe(config, options.allowUnsafe); assertNoSymlinkEscape(config.projectDir, config.outDir, 'Build output directory'); @@ -173,340 +171,6 @@ function plannedArtifacts(config: ResolvedBuildConfig, target: BuildTarget): Bui ]; } -function buildWeb(config: ResolvedBuildConfig, stageDir: string): BuildArtifact[] { - const webConfig = config.targets.web ?? {}; - const loveJsDir = webConfig.loveJsDir ? resolve(config.projectDir, webConfig.loveJsDir) : ''; - if (!loveJsDir || !existsSync(loveJsDir)) { - throw new Error('Web build requires targets.web.loveJsDir in feather.build.json.'); - } - const base = artifactBaseName(config); - const lovePath = writeLoveArchive(stageDir, config.outDir, base); - const htmlDir = join(config.outDir, webConfig.outputName ?? `${base}-html`); - copyDirectory(loveJsDir, htmlDir); - const gameLovePath = join(htmlDir, 'game.love'); - writeFileSync(gameLovePath, readFileSync(lovePath)); - patchLoveJsIndex(join(htmlDir, 'index.html'), webConfig.title ?? config.name); - const zipPath = writeDirectoryZip(htmlDir, join(config.outDir, `${base}-html.zip`)); - return [ - { target: 'web', type: 'love', path: lovePath }, - { target: 'web', type: 'html', path: htmlDir }, - { target: 'web', type: 'zip', path: zipPath }, - ]; -} - -function patchLoveJsIndex(indexPath: string, title: string): void { - const fallback = [ - '', - '', - 'löve.js', - '', - '', - '', - ].join('\n'); - const existing = existsSync(indexPath) ? readFileSync(indexPath, 'utf8') : fallback; - let next = existing.replace(/[\s\S]*?<\/title>/i, `<title>${escapeHtml(title)}`); - if (next === existing && !//i.test(next)) { - next = next.replace(/<head[^>]*>/i, (match) => `${match}<title>${escapeHtml(title)}`); - } - next = next.replace(/player(?:\.min)?\.js(?:\?g=[^"']*)?/g, (match) => { - const script = match.startsWith('player.min') ? 'player.min.js' : 'player.js'; - return `${script}?g=game.love`; - }); - if (!/\?g=game\.love/.test(next)) { - next = next.replace('', ''); - } - writeFileSync(indexPath, next); -} - -function buildAndroid(config: ResolvedBuildConfig, stageDir: string): BuildArtifact[] { - const androidConfig = config.targets.android ?? {}; - const loveAndroidDir = androidConfig.loveAndroidDir ? resolve(config.projectDir, androidConfig.loveAndroidDir) : ''; - if (!loveAndroidDir || !existsSync(loveAndroidDir)) { - throw new Error('Android build requires targets.android.loveAndroidDir in feather.build.json.'); - } - - const base = artifactBaseName(config); - const lovePath = writeLoveArchive(stageDir, config.outDir, base); - const workRoot = mkdtempSync(join(tmpdir(), 'feather-android-')); - const workDir = join(workRoot, 'love-android'); - - try { - copyDirectory(loveAndroidDir, workDir); - const embeddedLovePath = join(workDir, 'app', 'src', 'embed', 'assets', 'game.love'); - mkdirSync(dirname(embeddedLovePath), { recursive: true }); - cpSync(lovePath, embeddedLovePath, { force: true }); - - patchAndroidProject(config, workDir); - - const gradleCommand = process.platform === 'win32' ? join(workDir, 'gradlew.bat') : join(workDir, 'gradlew'); - if (!existsSync(gradleCommand)) { - throw new Error('Android build requires a Gradle wrapper in targets.android.loveAndroidDir.'); - } - const gradleTask = androidConfig.gradleTask - ?? (androidConfig.recordAudio ? 'assembleEmbedRecordDebug' : 'assembleEmbedNoRecordDebug'); - const result = spawnSync(gradleCommand, [gradleTask], { - cwd: workDir, - encoding: 'utf8', - shell: process.platform === 'win32', - }); - if (result.error) throw new Error(`Gradle wrapper failed to start: ${result.error.message}`); - if (result.status !== 0) { - throw new Error((result.stderr || result.stdout || `Gradle task ${gradleTask} failed`).trim()); - } - - const apkSource = androidConfig.artifactPath - ? resolveWorkspacePath(workDir, androidConfig.artifactPath, 'Android artifact path') - : findFirstPath(join(workDir, 'app', 'build', 'outputs'), (_path, entry) => entry.isFile() && entry.name.endsWith('.apk')); - if (!apkSource || !existsSync(apkSource)) { - throw new Error('Android build completed but no APK artifact was found. Set targets.android.artifactPath if your template writes elsewhere.'); - } - const apkPath = join(config.outDir, `${base}-android.apk`); - cpSync(apkSource, apkPath, { force: true }); - - return [ - { target: 'android', type: 'love', path: lovePath }, - { target: 'android', type: 'apk', path: apkPath }, - ]; - } finally { - rmSync(workRoot, { recursive: true, force: true }); - } -} - -function buildIos(config: ResolvedBuildConfig, stageDir: string): BuildArtifact[] { - if (process.platform !== 'darwin' && process.env.FEATHER_TEST_ALLOW_IOS_BUILD !== '1') { - throw new Error('iOS builds require macOS with Xcode. Run `feather doctor --build-target ios` for setup guidance.'); - } - - const iosConfig = config.targets.ios ?? {}; - const loveIosDir = iosConfig.loveIosDir ? resolve(config.projectDir, iosConfig.loveIosDir) : ''; - if (!loveIosDir || !existsSync(loveIosDir)) { - throw new Error('iOS build requires targets.ios.loveIosDir in feather.build.json.'); - } - - const base = artifactBaseName(config); - const lovePath = writeLoveArchive(stageDir, config.outDir, base); - const workRoot = mkdtempSync(join(tmpdir(), 'feather-ios-')); - const workDir = join(workRoot, 'love-ios'); - const derivedDataPath = iosConfig.derivedDataPath - ? resolve(config.projectDir, iosConfig.derivedDataPath) - : join(workRoot, 'DerivedData'); - - try { - copyDirectory(loveIosDir, workDir); - const xcodeProject = join(workDir, 'platform', 'xcode', 'love.xcodeproj'); - if (!existsSync(xcodeProject)) { - throw new Error('iOS build requires platform/xcode/love.xcodeproj in targets.ios.loveIosDir.'); - } - - const gameLovePath = join(workDir, 'platform', 'xcode', 'game.love'); - mkdirSync(dirname(gameLovePath), { recursive: true }); - cpSync(lovePath, gameLovePath, { force: true }); - patchIosProject(join(xcodeProject, 'project.pbxproj')); - - const bundleIdentifier = iosConfig.bundleIdentifier ?? iosConfig.productId ?? config.productId ?? defaultProductId(config, 'ios'); - const args = [ - '-project', - xcodeProject, - '-scheme', - iosConfig.scheme ?? 'love-ios', - '-configuration', - iosConfig.configuration ?? 'Debug', - '-sdk', - iosConfig.sdk ?? 'iphonesimulator', - '-derivedDataPath', - derivedDataPath, - `PRODUCT_BUNDLE_IDENTIFIER=${bundleIdentifier}`, - `INFOPLIST_KEY_CFBundleDisplayName=${iosConfig.displayName ?? config.name}`, - ]; - if (iosConfig.teamId) args.push(`DEVELOPMENT_TEAM=${iosConfig.teamId}`); - args.push('build'); - - const result = spawnSync('xcodebuild', args, { cwd: workDir, encoding: 'utf8' }); - if (result.error) throw new Error('xcodebuild not found. Run `feather doctor --build-target ios`.'); - if (result.status !== 0) { - throw new Error((result.stderr || result.stdout || 'xcodebuild failed').trim()); - } - - const appSource = findFirstPath(derivedDataPath, (_path, entry) => entry.isDirectory() && entry.name.endsWith('.app')); - if (!appSource || !existsSync(appSource)) { - throw new Error('iOS build completed but no .app artifact was found. Check targets.ios.derivedDataPath or Xcode build settings.'); - } - const appPath = join(config.outDir, `${base}-ios.app`); - copyDirectory(appSource, appPath); - - return [ - { target: 'ios', type: 'love', path: lovePath }, - { target: 'ios', type: 'app', path: appPath }, - ]; - } finally { - rmSync(workRoot, { recursive: true, force: true }); - } -} - -function buildDesktop(config: ResolvedBuildConfig, target: DesktopBuildTarget, stageDir: string): BuildArtifact[] { - const normalizedTarget = target === 'steamos' ? 'linux' : target; - const base = artifactBaseName(config); - const lovePath = writeLoveArchive(stageDir, config.outDir, base); - const args = [ - '--output', - config.outDir, - '--name', - config.name, - '--version', - config.version, - '--target', - normalizedTarget, - stageDir, - ]; - const result = spawnSync('love-release', args, { encoding: 'utf8' }); - if (result.error) throw new Error(`love-release not found. Run \`feather doctor --build-target ${target}\`.`); - if (result.status !== 0) { - throw new Error((result.stderr || result.stdout || `love-release failed for ${target}`).trim()); - } - return [ - { target, type: 'love', path: lovePath }, - { target, type: 'external', path: config.outDir }, - ]; -} - -function patchAndroidProject(config: ResolvedBuildConfig, workDir: string): void { - const androidConfig = config.targets.android ?? {}; - const productId = androidConfig.productId ?? config.productId ?? defaultProductId(config, 'android'); - const versionName = androidConfig.versionName ?? config.version; - const versionCode = androidConfig.versionCode ?? 1; - const displayName = androidConfig.displayName ?? config.name; - const orientation = androidConfig.orientation ?? 'landscape'; - - for (const file of ['app/build.gradle', 'app/build.gradle.kts', 'build.gradle', 'build.gradle.kts']) { - patchTextFile(join(workDir, file), (source) => source - .replace(/applicationId\s*(?:=)?\s*["'][^"']+["']/g, (match) => assignmentValue(match, 'applicationId', productId)) - .replace(/namespace\s*(?:=)?\s*["'][^"']+["']/g, (match) => assignmentValue(match, 'namespace', productId)) - .replace(/versionName\s*(?:=)?\s*["'][^"']+["']/g, (match) => assignmentValue(match, 'versionName', versionName)) - .replace(/versionCode\s*(?:=)?\s*\d+/g, (match) => assignmentValue(match, 'versionCode', String(versionCode), false))); - } - - for (const file of ['app/src/main/res/values/strings.xml', 'app/src/embed/res/values/strings.xml']) { - patchTextFile(join(workDir, file), (source) => source.replace( - /[\s\S]*?<\/string>/, - `${escapeXml(displayName)}`, - )); - } - - for (const file of ['app/src/main/AndroidManifest.xml', 'app/src/embed/AndroidManifest.xml']) { - patchTextFile(join(workDir, file), (source) => { - let next = source - .replace(/android:label=["'][^"']*["']/g, `android:label="${escapeXml(displayName)}"`) - .replace(/android:screenOrientation=["'][^"']*["']/g, `android:screenOrientation="${escapeXml(orientation)}"`); - if (!/android:label=/.test(next)) { - next = next.replace(/]*)>/, ``); - } - if (!/android:screenOrientation=/.test(next)) { - next = next.replace(/]*)>/, ``); - } - if (androidConfig.recordAudio) { - if (!/android\.permission\.RECORD_AUDIO/.test(next)) { - next = next.replace(/]*>/, (match) => `${match}\n `); - } - } else { - next = next - .split('\n') - .filter((line) => !line.includes('android.permission.RECORD_AUDIO')) - .join('\n'); - } - return next; - }); - } -} - -function patchIosProject(projectPath: string): void { - if (!existsSync(projectPath)) return; - const source = readFileSync(projectPath, 'utf8'); - if (source.includes('game.love')) return; - const buildFileId = 'FEATHERGAMELOVE000000000001'; - const fileRefId = 'FEATHERGAMELOVE000000000002'; - let next = source; - if (next.includes('/* Begin PBXBuildFile section */')) { - next = next.replace( - /\/\* Begin PBXBuildFile section \*\/\n/, - `/* Begin PBXBuildFile section */\n\t\t${buildFileId} /* game.love in Resources */ = {isa = PBXBuildFile; fileRef = ${fileRefId} /* game.love */; };\n`, - ); - } - if (next.includes('/* Begin PBXFileReference section */')) { - next = next.replace( - /\/\* Begin PBXFileReference section \*\/\n/, - `/* Begin PBXFileReference section */\n\t\t${fileRefId} /* game.love */ = {isa = PBXFileReference; lastKnownFileType = archive.love; name = game.love; path = game.love; sourceTree = ""; };\n`, - ); - } - next = next.replace( - /(isa = PBXResourcesBuildPhase;[\s\S]*?files = \(\n)/, - `$1\t\t\t\t${buildFileId} /* game.love in Resources */,\n`, - ); - if (next === source) next = `${source}\n/* Feather: include game.love in the app resources. */\n`; - writeFileSync(projectPath, next); -} - -function patchTextFile(path: string, update: (source: string) => string): void { - if (!existsSync(path)) return; - const source = readFileSync(path, 'utf8'); - const next = update(source); - if (next !== source) writeFileSync(path, next); -} - -function assignmentValue(match: string, key: string, value: string, quote = true): string { - const separator = match.includes('=') ? ' = ' : ' '; - return `${key}${separator}${quote ? `"${value}"` : value}`; -} - -function defaultProductId(config: ResolvedBuildConfig, target: 'android' | 'ios'): string { - const slug = buildSlug(config.name) - .replace(/[^a-z0-9]+/g, '.') - .replace(/^\.+|\.+$/g, '') - || 'game'; - return `org.feather.${slug}.${target}`; -} - -function resolveWorkspacePath(root: string, path: string, label: string): string { - const absolute = resolve(root, path); - const normalizedRoot = resolve(root); - if (absolute !== normalizedRoot && !absolute.startsWith(`${normalizedRoot}${sep}`)) { - throw new Error(`${label} must stay inside the native build workspace.`); - } - return absolute; -} - -function findFirstPath(root: string, predicate: (path: string, entry: Dirent) => boolean): string | null { - if (!existsSync(root)) return null; - for (const entry of readdirSync(root, { withFileTypes: true })) { - const path = join(root, entry.name); - if (predicate(path, entry)) return path; - if (entry.isDirectory()) { - const found = findFirstPath(path, predicate); - if (found) return found; - } - } - return null; -} - -function escapeXml(value: string): string { - return value.replace(/[<>&"']/g, (char) => ({ - '<': '<', - '>': '>', - '&': '&', - '"': '"', - "'": ''', - }[char]!)); -} - -function escapeHtml(value: string): string { - return value.replace(/[&<>"']/g, (char) => ({ - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - }[char]!)); -} - export function describeArtifact(artifact: BuildArtifact): string { const size = existsSync(artifact.path) && artifact.type !== 'html' && artifact.type !== 'external' ? ` (${fileSize(artifact.path)} bytes)` diff --git a/cli/src/lib/build/desktop.ts b/cli/src/lib/build/desktop.ts new file mode 100644 index 00000000..4557b38d --- /dev/null +++ b/cli/src/lib/build/desktop.ts @@ -0,0 +1,31 @@ +import { spawnSync } from 'node:child_process'; +import { artifactBaseName, writeLoveArchive, type BuildArtifact } from './files.js'; +import type { ResolvedBuildConfig, SupportedBuildTarget } from './config.js'; + +export type DesktopBuildTarget = Exclude; + +export function buildDesktop(config: ResolvedBuildConfig, target: DesktopBuildTarget, stageDir: string): BuildArtifact[] { + const normalizedTarget = target === 'steamos' ? 'linux' : target; + const base = artifactBaseName(config); + const lovePath = writeLoveArchive(stageDir, config.outDir, base); + const args = [ + '--output', + config.outDir, + '--name', + config.name, + '--version', + config.version, + '--target', + normalizedTarget, + stageDir, + ]; + const result = spawnSync('love-release', args, { encoding: 'utf8' }); + if (result.error) throw new Error(`love-release not found. Run \`feather doctor --build-target ${target}\`.`); + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || `love-release failed for ${target}`).trim()); + } + return [ + { target, type: 'love', path: lovePath }, + { target, type: 'external', path: config.outDir }, + ]; +} diff --git a/cli/src/lib/build/ios.ts b/cli/src/lib/build/ios.ts new file mode 100644 index 00000000..05d5a580 --- /dev/null +++ b/cli/src/lib/build/ios.ts @@ -0,0 +1,107 @@ +import { spawnSync } from 'node:child_process'; +import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { artifactBaseName, copyDirectory, writeLoveArchive, type BuildArtifact } from './files.js'; +import type { ResolvedBuildConfig } from './config.js'; +import { createNativeWorkspace, findFirstPath } from './native.js'; +import { iosBundleIdentifier } from './validation.js'; + +export function buildIos(config: ResolvedBuildConfig, stageDir: string): BuildArtifact[] { + if (process.platform !== 'darwin' && process.env.FEATHER_TEST_ALLOW_IOS_BUILD !== '1') { + throw new Error('iOS builds require macOS with Xcode. Run `feather doctor --build-target ios` for setup guidance.'); + } + + const iosConfig = config.targets.ios ?? {}; + const loveIosDir = iosConfig.loveIosDir ? resolve(config.projectDir, iosConfig.loveIosDir) : ''; + if (!loveIosDir || !existsSync(loveIosDir)) { + throw new Error('iOS build requires targets.ios.loveIosDir in feather.build.json.'); + } + + const base = artifactBaseName(config); + const lovePath = writeLoveArchive(stageDir, config.outDir, base); + const workspace = createNativeWorkspace('feather-ios-', loveIosDir, 'love-ios'); + const derivedDataPath = iosConfig.derivedDataPath + ? resolve(config.projectDir, iosConfig.derivedDataPath) + : join(workspace.root, 'DerivedData'); + + try { + const xcodeProject = join(workspace.dir, 'platform', 'xcode', 'love.xcodeproj'); + if (!existsSync(xcodeProject)) { + throw new Error('iOS build requires platform/xcode/love.xcodeproj in targets.ios.loveIosDir.'); + } + + const gameLovePath = join(workspace.dir, 'platform', 'xcode', 'game.love'); + mkdirSync(dirname(gameLovePath), { recursive: true }); + cpSync(lovePath, gameLovePath, { force: true }); + patchIosProject(join(xcodeProject, 'project.pbxproj')); + + const args = xcodebuildArgs(config, xcodeProject, derivedDataPath); + const result = spawnSync('xcodebuild', args, { cwd: workspace.dir, encoding: 'utf8' }); + if (result.error) throw new Error('xcodebuild not found. Run `feather doctor --build-target ios`.'); + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || 'xcodebuild failed').trim()); + } + + const appSource = findFirstPath(derivedDataPath, (_path, entry) => entry.isDirectory() && entry.name.endsWith('.app')); + if (!appSource || !existsSync(appSource)) { + throw new Error('iOS build completed but no .app artifact was found. Check targets.ios.derivedDataPath or Xcode build settings.'); + } + const appPath = join(config.outDir, `${base}-ios.app`); + copyDirectory(appSource, appPath); + + return [ + { target: 'ios', type: 'love', path: lovePath }, + { target: 'ios', type: 'app', path: appPath }, + ]; + } finally { + workspace.cleanup(); + } +} + +export function xcodebuildArgs(config: ResolvedBuildConfig, xcodeProject: string, derivedDataPath: string): string[] { + const iosConfig = config.targets.ios ?? {}; + const args = [ + '-project', + xcodeProject, + '-scheme', + iosConfig.scheme ?? 'love-ios', + '-configuration', + iosConfig.configuration ?? 'Debug', + '-sdk', + iosConfig.sdk ?? 'iphonesimulator', + '-derivedDataPath', + derivedDataPath, + `PRODUCT_BUNDLE_IDENTIFIER=${iosBundleIdentifier(config)}`, + `INFOPLIST_KEY_CFBundleDisplayName=${iosConfig.displayName ?? config.name}`, + ]; + if (iosConfig.teamId) args.push(`DEVELOPMENT_TEAM=${iosConfig.teamId}`); + args.push('build'); + return args; +} + +export function patchIosProject(projectPath: string): void { + if (!existsSync(projectPath)) return; + const source = readFileSync(projectPath, 'utf8'); + if (source.includes('game.love')) return; + const buildFileId = 'FEATHERGAMELOVE000000000001'; + const fileRefId = 'FEATHERGAMELOVE000000000002'; + let next = source; + if (next.includes('/* Begin PBXBuildFile section */')) { + next = next.replace( + /\/\* Begin PBXBuildFile section \*\/\n/, + `/* Begin PBXBuildFile section */\n\t\t${buildFileId} /* game.love in Resources */ = {isa = PBXBuildFile; fileRef = ${fileRefId} /* game.love */; };\n`, + ); + } + if (next.includes('/* Begin PBXFileReference section */')) { + next = next.replace( + /\/\* Begin PBXFileReference section \*\/\n/, + `/* Begin PBXFileReference section */\n\t\t${fileRefId} /* game.love */ = {isa = PBXFileReference; lastKnownFileType = archive.love; name = game.love; path = game.love; sourceTree = ""; };\n`, + ); + } + next = next.replace( + /(isa = PBXResourcesBuildPhase;[\s\S]*?files = \(\n)/, + `$1\t\t\t\t${buildFileId} /* game.love in Resources */,\n`, + ); + if (next === source) next = `${source}\n/* Feather: include game.love in the app resources. */\n`; + writeFileSync(projectPath, next); +} diff --git a/cli/src/lib/build/native.ts b/cli/src/lib/build/native.ts new file mode 100644 index 00000000..292535d9 --- /dev/null +++ b/cli/src/lib/build/native.ts @@ -0,0 +1,78 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, + type Dirent, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve, sep } from 'node:path'; +import { copyDirectory } from './files.js'; + +export type NativeWorkspace = { + root: string; + dir: string; + cleanup: () => void; +}; + +export function createNativeWorkspace(prefix: string, templateDir: string, dirname: string): NativeWorkspace { + const root = mkdtempSync(join(tmpdir(), prefix)); + const dir = join(root, dirname); + copyDirectory(templateDir, dir); + return { + root, + dir, + cleanup: () => rmSync(root, { recursive: true, force: true }), + }; +} + +export function resolveWorkspacePath(root: string, path: string, label: string): string { + const absolute = resolve(root, path); + const normalizedRoot = resolve(root); + if (absolute !== normalizedRoot && !absolute.startsWith(`${normalizedRoot}${sep}`)) { + throw new Error(`${label} must stay inside the native build workspace.`); + } + return absolute; +} + +export function findFirstPath(root: string, predicate: (path: string, entry: Dirent) => boolean): string | null { + if (!existsSync(root)) return null; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (predicate(path, entry)) return path; + if (entry.isDirectory()) { + const found = findFirstPath(path, predicate); + if (found) return found; + } + } + return null; +} + +export function patchTextFile(path: string, update: (source: string) => string): void { + if (!existsSync(path)) return; + const source = readFileSync(path, 'utf8'); + const next = update(source); + if (next !== source) writeFileSync(path, next); +} + +export function assignmentValue(match: string, key: string, value: string, quote = true): string { + const separator = match.includes('=') ? ' = ' : ' '; + return `${key}${separator}${quote ? `"${value}"` : value}`; +} + +export function ensureParentDir(path: string): void { + mkdirSync(path, { recursive: true }); +} + +export function escapeXml(value: string): string { + return value.replace(/[<>&"']/g, (char) => ({ + '<': '<', + '>': '>', + '&': '&', + '"': '"', + "'": ''', + }[char]!)); +} diff --git a/cli/src/lib/build/validation.ts b/cli/src/lib/build/validation.ts new file mode 100644 index 00000000..c1f40088 --- /dev/null +++ b/cli/src/lib/build/validation.ts @@ -0,0 +1,178 @@ +import { resolve } from 'node:path'; +import { assertSafeRelativePath } from '../path-safety.js'; +import { buildSlug } from './files.js'; +import type { BuildTarget, ResolvedBuildConfig } from './config.js'; + +const ANDROID_PRODUCT_ID_RE = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/; +const IOS_BUNDLE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9-]*(\.[A-Za-z0-9][A-Za-z0-9-]*)+$/; +const GRADLE_TASK_RE = /^[A-Za-z0-9:_-]+$/; +const XCODE_VALUE_RE = /^[A-Za-z0-9_. -]+$/; +const TEAM_ID_RE = /^[A-Za-z0-9]+$/; +const ANDROID_ORIENTATIONS = new Set([ + 'unspecified', + 'landscape', + 'portrait', + 'user', + 'behind', + 'sensor', + 'nosensor', + 'sensorLandscape', + 'sensorPortrait', + 'reverseLandscape', + 'reversePortrait', + 'fullSensor', + 'userLandscape', + 'userPortrait', + 'fullUser', + 'locked', +]); + +export type BuildValidationIssue = { + target: 'android' | 'ios'; + field: string; + message: string; +}; + +export function validateBuildConfigForTarget(config: ResolvedBuildConfig, target: BuildTarget): BuildValidationIssue[] { + if (target === 'android') return validateAndroidBuildConfig(config); + if (target === 'ios') return validateIosBuildConfig(config); + return []; +} + +export function assertBuildConfigValidForTarget(config: ResolvedBuildConfig, target: BuildTarget): void { + const issues = validateBuildConfigForTarget(config, target); + if (issues.length === 0) return; + throw new Error([ + `Invalid ${target} build config.`, + ...issues.map((issue) => `${issue.field}: ${issue.message}`), + ].join('\n')); +} + +export function validateAndroidBuildConfig(config: ResolvedBuildConfig): BuildValidationIssue[] { + const android = config.targets.android ?? {}; + const issues: BuildValidationIssue[] = []; + const productId = android.productId ?? config.productId ?? defaultProductId(config, 'android'); + if (!ANDROID_PRODUCT_ID_RE.test(productId)) { + issues.push({ + target: 'android', + field: 'productId', + message: 'Use a reverse-DNS Android application id, for example com.example.game.', + }); + } + if (android.versionCode !== undefined && (!Number.isInteger(android.versionCode) || android.versionCode < 1)) { + issues.push({ + target: 'android', + field: 'targets.android.versionCode', + message: 'Use a positive integer.', + }); + } + if (android.versionName !== undefined && !isNonEmptySingleLine(android.versionName)) { + issues.push({ + target: 'android', + field: 'targets.android.versionName', + message: 'Use a non-empty single-line version string.', + }); + } + if (android.orientation !== undefined && !ANDROID_ORIENTATIONS.has(android.orientation)) { + issues.push({ + target: 'android', + field: 'targets.android.orientation', + message: `Use one of: ${[...ANDROID_ORIENTATIONS].join(', ')}.`, + }); + } + if (android.gradleTask !== undefined && (!isNonEmptySingleLine(android.gradleTask) || !GRADLE_TASK_RE.test(android.gradleTask))) { + issues.push({ + target: 'android', + field: 'targets.android.gradleTask', + message: 'Use a Gradle task name containing only letters, numbers, colon, underscore, or dash.', + }); + } + if (android.artifactPath !== undefined) { + validateRelativePathish(android.artifactPath, 'targets.android.artifactPath', 'android', issues); + } + return issues; +} + +export function validateIosBuildConfig(config: ResolvedBuildConfig): BuildValidationIssue[] { + const ios = config.targets.ios ?? {}; + const issues: BuildValidationIssue[] = []; + const bundleId = ios.bundleIdentifier ?? ios.productId ?? config.productId ?? defaultProductId(config, 'ios'); + if (!IOS_BUNDLE_ID_RE.test(bundleId)) { + issues.push({ + target: 'ios', + field: 'bundleIdentifier', + message: 'Use a reverse-DNS iOS bundle id, for example com.example.game.', + }); + } + for (const [field, value] of [ + ['targets.ios.scheme', ios.scheme], + ['targets.ios.configuration', ios.configuration], + ['targets.ios.sdk', ios.sdk], + ] as const) { + if (value !== undefined && (!isNonEmptySingleLine(value) || !XCODE_VALUE_RE.test(value))) { + issues.push({ + target: 'ios', + field, + message: 'Use a non-empty Xcode value without shell metacharacters.', + }); + } + } + if (ios.teamId !== undefined && (!isNonEmptySingleLine(ios.teamId) || !TEAM_ID_RE.test(ios.teamId))) { + issues.push({ + target: 'ios', + field: 'targets.ios.teamId', + message: 'Use an Apple team id containing only letters and numbers.', + }); + } + if (ios.derivedDataPath !== undefined) { + try { + assertSafeRelativePath(ios.derivedDataPath, 'targets.ios.derivedDataPath'); + } catch (err) { + issues.push({ + target: 'ios', + field: 'targets.ios.derivedDataPath', + message: (err as Error).message, + }); + } + } + return issues; +} + +export function defaultProductId(config: ResolvedBuildConfig, target: 'android' | 'ios'): string { + const slug = buildSlug(config.name) + .replace(/[^a-z0-9]+/g, '.') + .replace(/^\.+|\.+$/g, '') + || 'game'; + return `org.feather.${slug}.${target}`; +} + +export function androidProductId(config: ResolvedBuildConfig): string { + return config.targets.android?.productId ?? config.productId ?? defaultProductId(config, 'android'); +} + +export function iosBundleIdentifier(config: ResolvedBuildConfig): string { + return config.targets.ios?.bundleIdentifier + ?? config.targets.ios?.productId + ?? config.productId + ?? defaultProductId(config, 'ios'); +} + +function isNonEmptySingleLine(value: string): boolean { + return value.trim().length > 0 && !/[\r\n\0]/.test(value); +} + +function validateRelativePathish( + value: string, + field: string, + target: 'android' | 'ios', + issues: BuildValidationIssue[], +): void { + if (!isNonEmptySingleLine(value)) { + issues.push({ target, field, message: 'Use a non-empty path.' }); + return; + } + const resolved = resolve('/', value); + if (resolved === '/') { + issues.push({ target, field, message: 'Use a file path, not a directory root.' }); + } +} diff --git a/cli/src/lib/build/web.ts b/cli/src/lib/build/web.ts new file mode 100644 index 00000000..49c80d08 --- /dev/null +++ b/cli/src/lib/build/web.ts @@ -0,0 +1,59 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { artifactBaseName, copyDirectory, writeDirectoryZip, writeLoveArchive, type BuildArtifact } from './files.js'; +import type { ResolvedBuildConfig } from './config.js'; + +export function buildWeb(config: ResolvedBuildConfig, stageDir: string): BuildArtifact[] { + const webConfig = config.targets.web ?? {}; + const loveJsDir = webConfig.loveJsDir ? resolve(config.projectDir, webConfig.loveJsDir) : ''; + if (!loveJsDir || !existsSync(loveJsDir)) { + throw new Error('Web build requires targets.web.loveJsDir in feather.build.json.'); + } + const base = artifactBaseName(config); + const lovePath = writeLoveArchive(stageDir, config.outDir, base); + const htmlDir = join(config.outDir, webConfig.outputName ?? `${base}-html`); + copyDirectory(loveJsDir, htmlDir); + const gameLovePath = join(htmlDir, 'game.love'); + writeFileSync(gameLovePath, readFileSync(lovePath)); + patchLoveJsIndex(join(htmlDir, 'index.html'), webConfig.title ?? config.name); + const zipPath = writeDirectoryZip(htmlDir, join(config.outDir, `${base}-html.zip`)); + return [ + { target: 'web', type: 'love', path: lovePath }, + { target: 'web', type: 'html', path: htmlDir }, + { target: 'web', type: 'zip', path: zipPath }, + ]; +} + +function patchLoveJsIndex(indexPath: string, title: string): void { + const fallback = [ + '', + '', + 'löve.js', + '', + '', + '', + ].join('\n'); + const existing = existsSync(indexPath) ? readFileSync(indexPath, 'utf8') : fallback; + let next = existing.replace(/[\s\S]*?<\/title>/i, `<title>${escapeHtml(title)}`); + if (next === existing && !//i.test(next)) { + next = next.replace(/<head[^>]*>/i, (match) => `${match}<title>${escapeHtml(title)}`); + } + next = next.replace(/player(?:\.min)?\.js(?:\?g=[^"']*)?/g, (match) => { + const script = match.startsWith('player.min') ? 'player.min.js' : 'player.js'; + return `${script}?g=game.love`; + }); + if (!/\?g=game\.love/.test(next)) { + next = next.replace('', ''); + } + writeFileSync(indexPath, next); +} + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (char) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[char]!)); +} diff --git a/cli/test/commands.test.mjs b/cli/test/commands.test.mjs index 88a10dfe..e208060a 100644 --- a/cli/test/commands.test.mjs +++ b/cli/test/commands.test.mjs @@ -146,7 +146,12 @@ function writeFakeLoveJs(dir) { return loveJsDir; } -function writeFakeLoveAndroid(dir, recordPath = join(dir, 'gradle-record.json')) { +function writeFakeLoveAndroid(dir, options = {}) { + const recordPath = options.recordPath ?? join(dir, 'gradle-record.json'); + const apkRel = options.apkRel ?? 'app/build/outputs/apk/embed/debug/app-embed-debug.apk'; + const manifestPermission = options.recordAudioPermission + ? ' \n' + : ''; const root = join(dir, 'love-android'); mkdirSync(join(root, 'app', 'src', 'main', 'res', 'values'), { recursive: true }); mkdirSync(join(root, 'app', 'src', 'main'), { recursive: true }); @@ -165,6 +170,7 @@ function writeFakeLoveAndroid(dir, recordPath = join(dir, 'gradle-record.json')) writeFileSync( join(root, 'app', 'src', 'main', 'AndroidManifest.xml'), ` +${manifestPermission} @@ -179,7 +185,7 @@ function writeFakeLoveAndroid(dir, recordPath = join(dir, 'gradle-record.json')) const fs = require('node:fs'); const path = require('node:path'); const cwd = process.cwd(); -const apk = path.join(cwd, 'app', 'build', 'outputs', 'apk', 'embed', 'debug', 'app-embed-debug.apk'); +const apk = path.join(cwd, ${JSON.stringify(apkRel)}); fs.mkdirSync(path.dirname(apk), { recursive: true }); fs.writeFileSync(apk, 'fake apk'); fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ @@ -681,6 +687,65 @@ process.exit(0); assert.ok(record.argv.includes('Desktop Game')); }); +test('build validation: rejects bad mobile config values and unsafe native paths', async () => { + const { validateAndroidBuildConfig, validateIosBuildConfig } = await import('../dist/lib/build/validation.js'); + const { resolveWorkspacePath } = await import('../dist/lib/build/native.js'); + const baseConfig = { + configPath: '/tmp/feather.build.json', + projectDir: '/tmp/game', + sourceDir: '/tmp/game', + outDir: '/tmp/game/builds', + name: 'Validation Game', + version: '1.0.0', + include: [], + exclude: [], + includeRuntime: false, + targets: {}, + upload: {}, + }; + + const androidIssues = validateAndroidBuildConfig({ + ...baseConfig, + productId: 'not a product id', + targets: { android: { versionCode: 0, orientation: 'sideways', gradleTask: 'assemble debug' } }, + }); + assert.deepEqual(androidIssues.map((issue) => issue.field), [ + 'productId', + 'targets.android.versionCode', + 'targets.android.orientation', + 'targets.android.gradleTask', + ]); + + const iosIssues = validateIosBuildConfig({ + ...baseConfig, + targets: { ios: { bundleIdentifier: 'bad id', scheme: 'bad;', derivedDataPath: '../escape' } }, + }); + assert.deepEqual(iosIssues.map((issue) => issue.field), [ + 'bundleIdentifier', + 'targets.ios.scheme', + 'targets.ios.derivedDataPath', + ]); + assert.throws(() => resolveWorkspacePath('/tmp/native-work', '../escape.apk', 'Artifact'), /native build workspace/); +}); + +test('build android: invalid config fails before staging or writing artifacts', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Bad Mobile Game', + version: '1.0.0', + productId: 'bad product id', + targets: { android: { loveAndroidDir: 'love-android', versionCode: 0 } }, + }); + + const result = run(['build', 'android', '--dir', dir, '--json']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Invalid android build config')); + assert.ok(outputOf(result).includes('productId')); + assert.equal(existsSync(join(dir, 'builds', 'bad-mobile-game-1.0.0.love')), false); +}); + test('build android: injects game.love, runs Gradle, copies APK, and writes manifest', () => { const dir = makeTmp(); writeGame(dir); @@ -724,6 +789,58 @@ test('build android: injects game.love, runs Gradle, copies APK, and writes mani assert.equal(manifest.artifacts.some((artifact) => artifact.type === 'apk'), true); }); +test('build android: honors gradleTask, custom artifactPath, and removes microphone permission', () => { + const dir = makeTmp(); + writeGame(dir); + const { recordPath } = writeFakeLoveAndroid(dir, { + recordAudioPermission: true, + apkRel: 'app/build/outputs/custom/custom-debug.apk', + }); + writeBuildConfig(dir, { + name: 'Custom Android', + version: '2.0.0', + productId: 'com.example.customandroid', + targets: { + android: { + loveAndroidDir: 'love-android', + gradleTask: ':app:assembleCustomDebug', + artifactPath: 'app/build/outputs/custom/custom-debug.apk', + recordAudio: false, + }, + }, + }); + + const result = run(['build', 'android', '--dir', dir, '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'builds', 'custom-android-2.0.0-android.apk')), true); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.deepEqual(record.argv, [':app:assembleCustomDebug']); + assert.equal(record.manifest.includes('android.permission.RECORD_AUDIO'), false); + assert.equal(record.manifest.includes('android.permission.INTERNET'), true); +}); + +test('build ios: invalid bundle id fails before xcodebuild', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Bad iOS Game', + version: '1.0.0', + targets: { ios: { loveIosDir: 'love-ios', bundleIdentifier: 'bad id' } }, + }); + const recordPath = join(dir, 'xcodebuild-record.json'); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` +const fs = require('node:fs'); +fs.writeFileSync(${JSON.stringify(recordPath)}, 'ran'); +process.exit(0); +`); + + const result = run(['build', 'ios', '--dir', dir, '--json'], { env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }) }); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Invalid ios build config')); + assert.equal(existsSync(recordPath), false); +}); + test('build ios: injects game.love, runs xcodebuild, copies app, and writes manifest', () => { const dir = makeTmp(); writeGame(dir); @@ -736,6 +853,11 @@ test('build ios: injects game.love, runs xcodebuild, copies app, and writes mani loveIosDir: 'love-ios', bundleIdentifier: 'com.example.iosgame', displayName: 'iOS Game Dev', + scheme: 'FeatherGame', + configuration: 'Release', + sdk: 'iphonesimulator', + derivedDataPath: 'builds/ios-derived-data', + teamId: 'ABC123XYZ', }, }, }); @@ -770,11 +892,15 @@ process.exit(0); assert.equal(existsSync(join(dir, 'builds', 'ios-game-5.0.0-ios.app')), true); const record = JSON.parse(readFileSync(recordPath, 'utf8')); assert.ok(record.argv.includes('-scheme')); - assert.ok(record.argv.includes('love-ios')); + assert.ok(record.argv.includes('FeatherGame')); + assert.ok(record.argv.includes('-configuration')); + assert.ok(record.argv.includes('Release')); assert.ok(record.argv.includes('-sdk')); assert.ok(record.argv.includes('iphonesimulator')); + assert.ok(record.argv.includes(join(dir, 'builds', 'ios-derived-data'))); assert.ok(record.argv.includes('PRODUCT_BUNDLE_IDENTIFIER=com.example.iosgame')); assert.ok(record.argv.includes('INFOPLIST_KEY_CFBundleDisplayName=iOS Game Dev')); + assert.ok(record.argv.includes('DEVELOPMENT_TEAM=ABC123XYZ')); assert.equal(record.gameLoveExists, true); assert.equal(record.projectContainsGameLove, true); const manifest = JSON.parse(readFileSync(join(dir, 'builds', 'feather-build-manifest.json'), 'utf8')); @@ -795,6 +921,21 @@ test('build mobile: missing native template paths fail with actionable errors', assert.ok(outputOf(ios).includes('targets.ios.loveIosDir')); }); +test('build ios: non-macOS hosts fail with setup guidance', { skip: process.platform === 'darwin' }, () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Non Mac iOS', + version: '1.0.0', + targets: { ios: { loveIosDir: 'love-ios', bundleIdentifier: 'com.example.nonmacios' } }, + }); + + const result = run(['build', 'ios', '--dir', dir, '--json']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('iOS builds require macOS with Xcode')); +}); + test('build: production preflight blocks unsafe Feather config unless explicitly allowed', () => { const dir = makeTmp(); writeGame(dir); @@ -942,6 +1083,25 @@ test('doctor: android build target reports template and local tool setup', () => assert.equal(labels.get('Android signing')?.severity, 'warn'); }); +test('doctor: mobile build target reports config validation failures', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Doctor Bad Android', + version: '1.0.0', + productId: 'bad product id', + targets: { android: { loveAndroidDir: 'love-android', versionCode: 0 } }, + }); + + const { result, parsed } = parseDoctorJsonResult(dir, ['--build-target', 'android']); + assert.equal(result.exitCode, 1); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('Android config')?.severity, 'fail'); + assert.ok(labels.get('Android config')?.detail.includes('versionCode')); + assert.equal(labels.get('Android product id')?.severity, 'fail'); +}); + test('doctor: ios build target reports platform, template, Xcode, and signing hints', () => { const dir = makeTmp(); writeGame(dir); diff --git a/docs/installation.md b/docs/installation.md index 0238bf6f..7b6f6d15 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -64,6 +64,8 @@ feather doctor path/to/my-game --upload-target itch `feather build` supports web, Android, iOS, Windows, macOS, Linux, and SteamOS artifacts. Web builds need a local love.js player directory configured in `feather.build.json`; Android/iOS dev builds need local love-android or LÖVE iOS template paths; desktop builds expect `love-release` on `PATH`. `feather upload itch` expects Butler on `PATH` and either `BUTLER_API_KEY` in CI or a local `butler login` session. +For mobile setup, start with `feather doctor path/to/my-game --build-target android` or `--build-target ios`. Doctor checks the native template path, product/bundle id, local build tools, and the common environment variables before the build command stages or writes artifacts. + For CI or release scripts that need a security-only JSON report: ```bash From 7d7970fae7a09743a33d06d6d8589beb091ba714 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 11:46:10 -0400 Subject: [PATCH 43/68] cli: add mobile release builds --- cli/README.md | 32 +++- cli/src/commands/build.ts | 2 + cli/src/commands/doctor/checks.ts | 1 + cli/src/commands/doctor/index.ts | 79 +++++++++- cli/src/index.ts | 4 + cli/src/lib/build/android.ts | 124 ++++++++++++--- cli/src/lib/build/build.ts | 34 ++++- cli/src/lib/build/config.ts | 21 +++ cli/src/lib/build/ios.ts | 143 +++++++++++++++++- cli/src/lib/build/validation.ts | 115 +++++++++++++- cli/test/commands.test.mjs | 242 +++++++++++++++++++++++++++++- docs/installation.md | 2 + docs/recommendations.md | 2 +- 13 files changed, 745 insertions(+), 56 deletions(-) diff --git a/cli/README.md b/cli/README.md index ef2f9d31..76ceb630 100644 --- a/cli/README.md +++ b/cli/README.md @@ -333,12 +333,14 @@ Doctor passed with 1 warning. ### `feather build ` -Build a LÖVE game into local release artifacts. V1 supports `web`, `android`, `ios`, `windows`, `macos`, `linux`, and `steamos`. Android and iOS support creates development artifacts from local native template checkouts; signed release APK/AAB, IPA export, notarization, and store upload are later phases. +Build a LÖVE game into local artifacts. V1 supports `web`, `android`, `ios`, `windows`, `macos`, `linux`, and `steamos`. Android and iOS default to development builds from local native template checkouts; `--release` produces signed/store-oriented mobile artifacts. Notarization, Play Console/App Store upload, and Steam upload are later phases. ```bash feather build web --dir path/to/my-game feather build android --dir path/to/my-game +feather build android --dir path/to/my-game --release feather build ios --dir path/to/my-game +feather build ios --dir path/to/my-game --release feather build linux --dir path/to/my-game feather build steamos --dir path/to/my-game --json feather build web --dry-run @@ -367,14 +369,29 @@ Builds read `feather.build.json` from the project root. Missing config is allowe "orientation": "landscape", "recordAudio": false, "versionCode": 1, - "versionName": "1.0.0" + "versionName": "1.0.0", + "release": { + "bundleTask": "bundleEmbedNoRecordRelease", + "apkTask": "assembleEmbedNoRecordRelease", + "keystorePath": "signing/release.keystore", + "keyAlias": "release", + "storePasswordEnv": "ANDROID_STORE_PASSWORD", + "keyPasswordEnv": "ANDROID_KEY_PASSWORD" + } }, "ios": { "loveIosDir": "vendor/love-ios", "bundleIdentifier": "com.example.mygame", "displayName": "My Game", "scheme": "love-ios", - "sdk": "iphonesimulator" + "sdk": "iphonesimulator", + "teamId": "ABCDE12345", + "release": { + "exportMethod": "app-store-connect", + "signingStyle": "manual", + "provisioningProfileSpecifier": "My Game App Store", + "teamId": "ABCDE12345" + } } }, "upload": { @@ -398,6 +415,8 @@ Build behavior: - packages `web` by copying the configured love.js player, adding `game.love`, patching the page title/game URL, and creating an HTML zip - packages `android` by copying a configured love-android checkout, embedding `game.love`, patching obvious app metadata, running Gradle, and copying the APK - packages `ios` on macOS by copying a configured LÖVE iOS source tree, embedding `game.love`, running `xcodebuild`, and copying the `.app` +- `--release` on Android produces `.aab` and `.apk` artifacts; signing passwords are read from environment variables named in config +- `--release` on iOS produces `.xcarchive` and `.ipa` artifacts through `xcodebuild archive` and `-exportArchive` - delegates desktop targets to `love-release`; `steamos` uses the Linux packaging path with Steam-friendly target naming **Options:** @@ -413,6 +432,7 @@ Build behavior: | `--dry-run` | Show planned files/artifacts without writing them. | | `--json` | Print machine-readable output only. | | `--allow-unsafe` | Skip the production safety preflight for intentional dev builds. | +| `--release` | Build Android/iOS release artifacts instead of dev artifacts. | Run `feather doctor --build-target ` to see missing local dependencies and exact setup guidance before building. @@ -420,9 +440,9 @@ Mobile build notes: - Android builds expect `targets.android.loveAndroidDir` to point at a local love-android checkout with `gradlew`. - iOS builds expect `targets.ios.loveIosDir` to point at a local LÖVE iOS source tree with `platform/xcode/love.xcodeproj`. -- `feather doctor --build-target android` validates product id, Gradle wrapper, JDK, and Android SDK environment. -- `feather doctor --build-target ios` validates bundle id, macOS/Xcode setup, template path, and signing hints. -- These are development builds; signed Android release/AAB and iOS archive/export flows are intentionally separate later work. +- `feather doctor --build-target android --release` validates product id, Gradle wrapper, JDK, Android SDK, and signing env setup. +- `feather doctor --build-target ios --release` validates bundle id, macOS/Xcode setup, template path, export options, and signing hints. +- Play Console and App Store upload are not included in this pass. --- diff --git a/cli/src/commands/build.ts b/cli/src/commands/build.ts index 490f4f0f..a90dd858 100644 --- a/cli/src/commands/build.ts +++ b/cli/src/commands/build.ts @@ -26,6 +26,7 @@ export type BuildCommandOptions = { dryRun?: boolean; json?: boolean; allowUnsafe?: boolean; + release?: boolean; }; export async function buildCommand(targetValue: string, opts: BuildCommandOptions = {}): Promise { @@ -44,6 +45,7 @@ export async function buildCommand(targetValue: string, opts: BuildCommandOption clean: opts.clean, dryRun: opts.dryRun, allowUnsafe: opts.allowUnsafe, + release: opts.release, }); if (!result.ok) { diff --git a/cli/src/commands/doctor/checks.ts b/cli/src/commands/doctor/checks.ts index 0da43b92..f2a9f635 100644 --- a/cli/src/commands/doctor/checks.ts +++ b/cli/src/commands/doctor/checks.ts @@ -23,6 +23,7 @@ export type DoctorOptions = { security?: boolean; buildTarget?: string; uploadTarget?: string; + release?: boolean; }; export const severityOrder: Record = { diff --git a/cli/src/commands/doctor/index.ts b/cli/src/commands/doctor/index.ts index c45cacd4..d4eb5c96 100644 --- a/cli/src/commands/doctor/index.ts +++ b/cli/src/commands/doctor/index.ts @@ -187,7 +187,17 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) ); if (opts.buildTarget && isBuildTarget(opts.buildTarget)) { - const configIssues = validateBuildConfigForTarget(buildConfig, opts.buildTarget); + const configIssues = validateBuildConfigForTarget(buildConfig, opts.buildTarget, Boolean(opts.release)); + if (opts.release && opts.buildTarget !== 'android' && opts.buildTarget !== 'ios') { + add( + checks, + 'Build', + 'Release mode', + 'fail', + opts.buildTarget, + 'Release mode is currently supported only for android and ios builds.', + ); + } if (opts.buildTarget === 'android' || opts.buildTarget === 'ios') { add( checks, @@ -260,9 +270,11 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) checks, 'Build', 'Android signing', - 'warn', - 'debug/dev build', - 'Signed release APK/AAB support is a later phase; use native Android signing for store releases.', + opts.release ? androidReleaseSigningSeverity(buildConfig) : 'warn', + opts.release ? androidReleaseSigningDetail(buildConfig) : 'debug/dev build', + opts.release + ? androidReleaseSigningFix(buildConfig) + : 'Use --release to check signed AAB/APK setup.', ); } else if (opts.buildTarget === 'ios') { const iosConfig = buildConfig.targets.ios ?? {}; @@ -315,10 +327,24 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) checks, 'Build', 'iOS signing team', - iosConfig.teamId ? 'pass' : 'warn', - iosConfig.teamId ?? 'missing', - 'Set targets.ios.teamId for device builds; simulator debug builds can usually omit it.', + opts.release + ? ((iosConfig.release?.teamId ?? iosConfig.teamId) ? 'pass' : 'warn') + : (iosConfig.teamId ? 'pass' : 'warn'), + opts.release ? (iosConfig.release?.teamId ?? iosConfig.teamId ?? 'missing') : (iosConfig.teamId ?? 'missing'), + opts.release + ? 'Set targets.ios.release.teamId or targets.ios.teamId if your export requires a team.' + : 'Set targets.ios.teamId for device builds; simulator debug builds can usually omit it.', ); + if (opts.release) { + add( + checks, + 'Build', + 'iOS export options', + iosConfig.release?.exportOptionsPlist || iosConfig.release?.exportMethod ? 'pass' : 'warn', + iosConfig.release?.exportOptionsPlist ?? iosConfig.release?.exportMethod ?? 'generated development export options', + 'Set targets.ios.release.exportOptionsPlist or exportMethod for App Store/TestFlight exports.', + ); + } } else if (isSupportedBuildTarget(opts.buildTarget)) { const loveRelease = commandVersion('love-release', ['--version']); add( @@ -897,3 +923,42 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) fail('', { silent: true }); } } + +function androidReleaseSigningSeverity(buildConfig: ReturnType): DoctorCheck['severity'] { + const release = buildConfig.targets.android?.release; + if (!release) return 'warn'; + const signingConfigured = Boolean(release.keystorePath || release.keyAlias || release.storePasswordEnv || release.keyPasswordEnv); + if (!signingConfigured) return 'warn'; + const keystorePath = release.keystorePath ? resolve(buildConfig.projectDir, release.keystorePath) : ''; + const missingEnv = [ + release.storePasswordEnv && process.env[release.storePasswordEnv] ? '' : release.storePasswordEnv, + release.keyPasswordEnv && process.env[release.keyPasswordEnv] ? '' : release.keyPasswordEnv, + ].filter(Boolean); + return missingEnv.length === 0 && release.keystorePath && existsSync(keystorePath) && release.keyAlias ? 'pass' : 'fail'; +} + +function androidReleaseSigningDetail(buildConfig: ReturnType): string { + const release = buildConfig.targets.android?.release; + if (!release) return 'not configured'; + const signingConfigured = Boolean(release.keystorePath || release.keyAlias || release.storePasswordEnv || release.keyPasswordEnv); + if (!signingConfigured) return 'not configured'; + const keystorePath = release.keystorePath ? resolve(buildConfig.projectDir, release.keystorePath) : ''; + const missing = [ + release.keystorePath ? '' : 'keystorePath', + release.keystorePath && !existsSync(keystorePath) ? 'keystore file' : '', + release.keyAlias ? '' : 'keyAlias', + release.storePasswordEnv ? '' : 'storePasswordEnv', + release.keyPasswordEnv ? '' : 'keyPasswordEnv', + release.storePasswordEnv && process.env[release.storePasswordEnv] ? '' : release.storePasswordEnv, + release.keyPasswordEnv && process.env[release.keyPasswordEnv] ? '' : release.keyPasswordEnv, + ].filter(Boolean); + return missing.length === 0 ? 'configured' : `missing ${missing.join(', ')}`; +} + +function androidReleaseSigningFix(buildConfig: ReturnType): string { + const release = buildConfig.targets.android?.release; + if (!release || !(release.keystorePath || release.keyAlias || release.storePasswordEnv || release.keyPasswordEnv)) { + return 'Set targets.android.release keystore fields to create signed release artifacts.'; + } + return 'Set Android signing config paths in feather.build.json and provide password environment variables in your shell or CI.'; +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 602a9b9b..66e596fd 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -117,6 +117,7 @@ program .option('--security', 'Print security-focused diagnostics; use with --json for automation') .option('--build-target ', 'Check dependencies for a build target') .option('--upload-target ', 'Check dependencies for an upload target') + .option('--release', 'Include mobile release build checks with --build-target') .action((dir: string | undefined, opts) => runCliAction(() => doctorCommand(dir, { installDir: opts.installDir as string | undefined, host: opts.host as string | undefined, @@ -126,6 +127,7 @@ program security: opts.security as boolean | undefined, buildTarget: opts.buildTarget as string | undefined, uploadTarget: opts.uploadTarget as string | undefined, + release: opts.release as boolean | undefined, }))); program @@ -140,6 +142,7 @@ program .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') .action((target: string, opts) => runCliAction(() => buildCommand(target, { dir: opts.dir as string | undefined, config: opts.config as string | undefined, @@ -150,6 +153,7 @@ program dryRun: opts.dryRun as boolean | undefined, json: opts.json as boolean | undefined, allowUnsafe: opts.allowUnsafe as boolean | undefined, + release: opts.release as boolean | undefined, }))); program diff --git a/cli/src/lib/build/android.ts b/cli/src/lib/build/android.ts index 06005ad2..1e5c2eb2 100644 --- a/cli/src/lib/build/android.ts +++ b/cli/src/lib/build/android.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process'; -import { cpSync, existsSync, mkdirSync } from 'node:fs'; +import { cpSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { artifactBaseName, writeLoveArchive, type BuildArtifact } from './files.js'; import type { ResolvedBuildConfig } from './config.js'; @@ -13,7 +13,11 @@ import { } from './native.js'; import { androidProductId } from './validation.js'; -export function buildAndroid(config: ResolvedBuildConfig, stageDir: string): BuildArtifact[] { +export type AndroidBuildModeOptions = { + release?: boolean; +}; + +export function buildAndroid(config: ResolvedBuildConfig, stageDir: string, options: AndroidBuildModeOptions = {}): BuildArtifact[] { const androidConfig = config.targets.android ?? {}; const loveAndroidDir = androidConfig.loveAndroidDir ? resolve(config.projectDir, androidConfig.loveAndroidDir) : ''; if (!loveAndroidDir || !existsSync(loveAndroidDir)) { @@ -35,27 +39,42 @@ export function buildAndroid(config: ResolvedBuildConfig, stageDir: string): Bui if (!existsSync(gradleCommand)) { throw new Error('Android build requires a Gradle wrapper in targets.android.loveAndroidDir.'); } - const gradleTask = androidConfig.gradleTask - ?? (androidConfig.recordAudio ? 'assembleEmbedRecordDebug' : 'assembleEmbedNoRecordDebug'); - const result = spawnSync(gradleCommand, [gradleTask], { - cwd: workspace.dir, - encoding: 'utf8', - shell: process.platform === 'win32', - }); - if (result.error) throw new Error(`Gradle wrapper failed to start: ${result.error.message}`); - if (result.status !== 0) { - throw new Error((result.stderr || result.stdout || `Gradle task ${gradleTask} failed`).trim()); - } + if (options.release) { + writeAndroidSigningProperties(config, workspace.dir); + const bundleTask = androidConfig.release?.bundleTask + ?? (androidConfig.recordAudio ? 'bundleEmbedRecordRelease' : 'bundleEmbedNoRecordRelease'); + const apkTask = androidConfig.release?.apkTask + ?? (androidConfig.recordAudio ? 'assembleEmbedRecordRelease' : 'assembleEmbedNoRecordRelease'); + runGradleTask(gradleCommand, workspace.dir, bundleTask); + runGradleTask(gradleCommand, workspace.dir, apkTask); - const apkSource = androidConfig.artifactPath - ? resolveWorkspacePath(workspace.dir, androidConfig.artifactPath, 'Android artifact path') - : findFirstPath(join(workspace.dir, 'app', 'build', 'outputs'), (_path, entry) => entry.isFile() && entry.name.endsWith('.apk')); - if (!apkSource || !existsSync(apkSource)) { - throw new Error('Android build completed but no APK artifact was found. Set targets.android.artifactPath if your template writes elsewhere.'); + const aabSource = androidConfig.release?.bundleArtifactPath + ? resolveWorkspacePath(workspace.dir, androidConfig.release.bundleArtifactPath, 'Android bundle artifact path') + : findFirstPath(join(workspace.dir, 'app', 'build', 'outputs'), (_path, entry) => entry.isFile() && entry.name.endsWith('.aab')); + const apkSource = androidConfig.release?.apkArtifactPath + ? resolveWorkspacePath(workspace.dir, androidConfig.release.apkArtifactPath, 'Android APK artifact path') + : findFirstPath(join(workspace.dir, 'app', 'build', 'outputs'), (_path, entry) => entry.isFile() && entry.name.endsWith('.apk')); + if (!aabSource || !existsSync(aabSource)) { + throw new Error('Android release completed but no AAB artifact was found. Set targets.android.release.bundleArtifactPath if your template writes elsewhere.'); + } + if (!apkSource || !existsSync(apkSource)) { + throw new Error('Android release completed but no APK artifact was found. Set targets.android.release.apkArtifactPath if your template writes elsewhere.'); + } + const aabPath = join(config.outDir, `${base}-android.aab`); + const apkPath = join(config.outDir, `${base}-android.apk`); + cpSync(aabSource, aabPath, { force: true }); + cpSync(apkSource, apkPath, { force: true }); + return [ + { target: 'android', type: 'love', path: lovePath }, + { target: 'android', type: 'aab', path: aabPath }, + { target: 'android', type: 'apk', path: apkPath }, + ]; } - const apkPath = join(config.outDir, `${base}-android.apk`); - cpSync(apkSource, apkPath, { force: true }); + const gradleTask = androidConfig.gradleTask + ?? (androidConfig.recordAudio ? 'assembleEmbedRecordDebug' : 'assembleEmbedNoRecordDebug'); + runGradleTask(gradleCommand, workspace.dir, gradleTask); + const apkPath = copyAndroidApkArtifact(config, workspace.dir, base); return [ { target: 'android', type: 'love', path: lovePath }, { target: 'android', type: 'apk', path: apkPath }, @@ -65,6 +84,71 @@ export function buildAndroid(config: ResolvedBuildConfig, stageDir: string): Bui } } +function runGradleTask(gradleCommand: string, workDir: string, task: string): void { + const result = spawnSync(gradleCommand, [task], { + cwd: workDir, + encoding: 'utf8', + shell: process.platform === 'win32', + }); + if (result.error) throw new Error(`Gradle wrapper failed to start: ${result.error.message}`); + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || `Gradle task ${task} failed`).trim()); + } +} + +function copyAndroidApkArtifact(config: ResolvedBuildConfig, workDir: string, base: string): string { + const androidConfig = config.targets.android ?? {}; + const apkSource = androidConfig.artifactPath + ? resolveWorkspacePath(workDir, androidConfig.artifactPath, 'Android artifact path') + : findFirstPath(join(workDir, 'app', 'build', 'outputs'), (_path, entry) => entry.isFile() && entry.name.endsWith('.apk')); + if (!apkSource || !existsSync(apkSource)) { + throw new Error('Android build completed but no APK artifact was found. Set targets.android.artifactPath if your template writes elsewhere.'); + } + const apkPath = join(config.outDir, `${base}-android.apk`); + cpSync(apkSource, apkPath, { force: true }); + return apkPath; +} + +function writeAndroidSigningProperties(config: ResolvedBuildConfig, workDir: string): void { + const release = config.targets.android?.release; + if (!release) return; + const signingConfigured = Boolean(release.keystorePath || release.keyAlias || release.storePasswordEnv || release.keyPasswordEnv); + if (!signingConfigured) return; + const required = [ + ['targets.android.release.keystorePath', release.keystorePath], + ['targets.android.release.keyAlias', release.keyAlias], + ['targets.android.release.storePasswordEnv', release.storePasswordEnv], + ['targets.android.release.keyPasswordEnv', release.keyPasswordEnv], + ] as const; + const missing = required.filter(([, value]) => !value).map(([field]) => field); + if (missing.length > 0) { + throw new Error(`Android release signing is incomplete. Missing: ${missing.join(', ')}`); + } + const storePassword = process.env[release.storePasswordEnv!]; + const keyPassword = process.env[release.keyPasswordEnv!]; + const missingEnv = [ + storePassword ? '' : release.storePasswordEnv!, + keyPassword ? '' : release.keyPasswordEnv!, + ].filter(Boolean); + if (missingEnv.length > 0) { + throw new Error(`Android release signing environment is incomplete. Missing: ${missingEnv.join(', ')}`); + } + const keystorePath = resolve(config.projectDir, release.keystorePath!); + if (!existsSync(keystorePath)) { + throw new Error(`Android release keystore was not found: ${keystorePath}`); + } + writeFileSync( + join(workDir, 'feather-signing.properties'), + [ + `storeFile=${keystorePath}`, + `keyAlias=${release.keyAlias}`, + `storePassword=${storePassword}`, + `keyPassword=${keyPassword}`, + '', + ].join('\n'), + ); +} + export function patchAndroidProject(config: ResolvedBuildConfig, workDir: string): void { const androidConfig = config.targets.android ?? {}; const productId = androidProductId(config); diff --git a/cli/src/lib/build/build.ts b/cli/src/lib/build/build.ts index 0273ea40..57e5fdf7 100644 --- a/cli/src/lib/build/build.ts +++ b/cli/src/lib/build/build.ts @@ -30,6 +30,7 @@ export type BuildOptions = LoadBuildConfigOptions & { clean?: boolean; dryRun?: boolean; allowUnsafe?: boolean; + release?: boolean; }; export type BuildResult = { @@ -81,6 +82,8 @@ export function assertProductionBuildSafe(config: ResolvedBuildConfig, allowUnsa export function planBuild(options: BuildOptions): Omit, 'artifacts'> & { artifacts: BuildArtifact[] } { const config = loadBuildConfig(options); + assertReleaseTargetSupported(options.target, Boolean(options.release)); + assertBuildConfigValidForTarget(config, options.target, Boolean(options.release)); return { ok: true, dryRun: true, @@ -90,7 +93,7 @@ export function planBuild(options: BuildOptions): Omit entry.isDirectory() && entry.name.endsWith('.app')); if (!appSource || !existsSync(appSource)) { throw new Error('iOS build completed but no .app artifact was found. Check targets.ios.derivedDataPath or Xcode build settings.'); @@ -58,6 +63,45 @@ export function buildIos(config: ResolvedBuildConfig, stageDir: string): BuildAr } } +function buildIosRelease( + config: ResolvedBuildConfig, + workDir: string, + xcodeProject: string, + base: string, + lovePath: string, +): BuildArtifact[] { + const release = config.targets.ios?.release ?? {}; + const archiveSource = release.archivePath + ? resolve(config.projectDir, release.archivePath) + : join(workDir, '..', `${base}.xcarchive`); + const exportPath = release.exportPath + ? resolve(config.projectDir, release.exportPath) + : join(workDir, '..', 'Export'); + const exportOptionsPlist = release.exportOptionsPlist + ? resolve(config.projectDir, release.exportOptionsPlist) + : writeGeneratedExportOptions(config, join(workDir, '..', 'ExportOptions.plist')); + + runXcodebuild(xcodeArchiveArgs(config, xcodeProject, archiveSource), workDir); + if (!existsSync(archiveSource)) { + throw new Error('iOS archive completed but no .xcarchive was found. Check targets.ios.release.archivePath or Xcode archive settings.'); + } + runXcodebuild(xcodeExportArchiveArgs(archiveSource, exportPath, exportOptionsPlist), workDir); + const ipaSource = findFirstPath(exportPath, (_path, entry) => entry.isFile() && entry.name.endsWith('.ipa')); + if (!ipaSource || !existsSync(ipaSource)) { + throw new Error('iOS export completed but no .ipa artifact was found. Check targets.ios.release.exportPath or export options.'); + } + + const archivePath = join(config.outDir, `${base}-ios.xcarchive`); + const ipaPath = join(config.outDir, `${base}-ios.ipa`); + copyDirectory(archiveSource, archivePath); + cpSync(ipaSource, ipaPath, { force: true }); + return [ + { target: 'ios', type: 'love', path: lovePath }, + { target: 'ios', type: 'xcarchive', path: archivePath }, + { target: 'ios', type: 'ipa', path: ipaPath }, + ]; +} + export function xcodebuildArgs(config: ResolvedBuildConfig, xcodeProject: string, derivedDataPath: string): string[] { const iosConfig = config.targets.ios ?? {}; const args = [ @@ -79,6 +123,93 @@ export function xcodebuildArgs(config: ResolvedBuildConfig, xcodeProject: string return args; } +export function xcodeArchiveArgs(config: ResolvedBuildConfig, xcodeProject: string, archivePath: string): string[] { + const iosConfig = config.targets.ios ?? {}; + const release = iosConfig.release ?? {}; + const teamId = release.teamId ?? iosConfig.teamId; + const args = [ + '-project', + xcodeProject, + '-scheme', + iosConfig.scheme ?? 'love-ios', + '-configuration', + release.configuration ?? 'Release', + '-sdk', + release.sdk ?? 'iphoneos', + '-archivePath', + archivePath, + `PRODUCT_BUNDLE_IDENTIFIER=${iosBundleIdentifier(config)}`, + `INFOPLIST_KEY_CFBundleDisplayName=${iosConfig.displayName ?? config.name}`, + ]; + if (teamId) args.push(`DEVELOPMENT_TEAM=${teamId}`); + args.push('archive'); + return args; +} + +export function xcodeExportArchiveArgs(archivePath: string, exportPath: string, exportOptionsPlist: string): string[] { + return [ + '-exportArchive', + '-archivePath', + archivePath, + '-exportPath', + exportPath, + '-exportOptionsPlist', + exportOptionsPlist, + ]; +} + +function runXcodebuild(args: string[], workDir: string): void { + const result = spawnSync('xcodebuild', args, { cwd: workDir, encoding: 'utf8' }); + if (result.error) throw new Error('xcodebuild not found. Run `feather doctor --build-target ios`.'); + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || 'xcodebuild failed').trim()); + } +} + +function writeGeneratedExportOptions(config: ResolvedBuildConfig, path: string): string { + const release = config.targets.ios?.release ?? {}; + const teamId = release.teamId ?? config.targets.ios?.teamId; + const entries: string[] = [ + plistEntry('method', release.exportMethod ?? 'development'), + plistEntry('signingStyle', release.signingStyle ?? 'automatic'), + ]; + if (teamId) entries.push(plistEntry('teamID', teamId)); + if (release.provisioningProfileSpecifier) { + entries.push( + 'provisioningProfiles', + '', + plistEntry(iosBundleIdentifier(config), release.provisioningProfileSpecifier), + '', + ); + } + const plist = [ + '', + '', + '', + '', + ...entries, + '', + '', + '', + ].join('\n'); + writeFileSync(path, plist); + return path; +} + +function plistEntry(key: string, value: string): string { + return `${escapeXml(key)}\n${escapeXml(value)}`; +} + +function escapeXml(value: string): string { + return value.replace(/[<>&"']/g, (char) => ({ + '<': '<', + '>': '>', + '&': '&', + '"': '"', + "'": ''', + }[char]!)); +} + export function patchIosProject(projectPath: string): void { if (!existsSync(projectPath)) return; const source = readFileSync(projectPath, 'utf8'); diff --git a/cli/src/lib/build/validation.ts b/cli/src/lib/build/validation.ts index c1f40088..6fed36e2 100644 --- a/cli/src/lib/build/validation.ts +++ b/cli/src/lib/build/validation.ts @@ -8,6 +8,19 @@ const IOS_BUNDLE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9-]*(\.[A-Za-z0-9][A-Za-z0-9-]*)+ const GRADLE_TASK_RE = /^[A-Za-z0-9:_-]+$/; const XCODE_VALUE_RE = /^[A-Za-z0-9_. -]+$/; const TEAM_ID_RE = /^[A-Za-z0-9]+$/; +const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; +const IOS_EXPORT_METHODS = new Set([ + 'app-store', + 'app-store-connect', + 'ad-hoc', + 'enterprise', + 'development', + 'developer-id', + 'mac-application', + 'release-testing', + 'debugging', +]); +const IOS_SIGNING_STYLES = new Set(['automatic', 'manual']); const ANDROID_ORIENTATIONS = new Set([ 'unspecified', 'landscape', @@ -33,14 +46,14 @@ export type BuildValidationIssue = { message: string; }; -export function validateBuildConfigForTarget(config: ResolvedBuildConfig, target: BuildTarget): BuildValidationIssue[] { - if (target === 'android') return validateAndroidBuildConfig(config); - if (target === 'ios') return validateIosBuildConfig(config); +export function validateBuildConfigForTarget(config: ResolvedBuildConfig, target: BuildTarget, release = false): BuildValidationIssue[] { + if (target === 'android') return validateAndroidBuildConfig(config, release); + if (target === 'ios') return validateIosBuildConfig(config, release); return []; } -export function assertBuildConfigValidForTarget(config: ResolvedBuildConfig, target: BuildTarget): void { - const issues = validateBuildConfigForTarget(config, target); +export function assertBuildConfigValidForTarget(config: ResolvedBuildConfig, target: BuildTarget, release = false): void { + const issues = validateBuildConfigForTarget(config, target, release); if (issues.length === 0) return; throw new Error([ `Invalid ${target} build config.`, @@ -48,8 +61,9 @@ export function assertBuildConfigValidForTarget(config: ResolvedBuildConfig, tar ].join('\n')); } -export function validateAndroidBuildConfig(config: ResolvedBuildConfig): BuildValidationIssue[] { +export function validateAndroidBuildConfig(config: ResolvedBuildConfig, release = false): BuildValidationIssue[] { const android = config.targets.android ?? {}; + const releaseConfig = android.release ?? {}; const issues: BuildValidationIssue[] = []; const productId = android.productId ?? config.productId ?? defaultProductId(config, 'android'); if (!ANDROID_PRODUCT_ID_RE.test(productId)) { @@ -90,11 +104,59 @@ export function validateAndroidBuildConfig(config: ResolvedBuildConfig): BuildVa if (android.artifactPath !== undefined) { validateRelativePathish(android.artifactPath, 'targets.android.artifactPath', 'android', issues); } + if (release) { + for (const [field, value] of [ + ['targets.android.release.bundleTask', releaseConfig.bundleTask], + ['targets.android.release.apkTask', releaseConfig.apkTask], + ] as const) { + if (value !== undefined && (!isNonEmptySingleLine(value) || !GRADLE_TASK_RE.test(value))) { + issues.push({ + target: 'android', + field, + message: 'Use a Gradle task name containing only letters, numbers, colon, underscore, or dash.', + }); + } + } + for (const [field, value] of [ + ['targets.android.release.bundleArtifactPath', releaseConfig.bundleArtifactPath], + ['targets.android.release.apkArtifactPath', releaseConfig.apkArtifactPath], + ['targets.android.release.keystorePath', releaseConfig.keystorePath], + ] as const) { + if (value !== undefined) validateRelativePathish(value, field, 'android', issues); + } + const signingFields = [ + releaseConfig.keystorePath, + releaseConfig.keyAlias, + releaseConfig.storePasswordEnv, + releaseConfig.keyPasswordEnv, + ]; + if (signingFields.some((value) => value !== undefined)) { + for (const [field, value] of [ + ['targets.android.release.keystorePath', releaseConfig.keystorePath], + ['targets.android.release.keyAlias', releaseConfig.keyAlias], + ['targets.android.release.storePasswordEnv', releaseConfig.storePasswordEnv], + ['targets.android.release.keyPasswordEnv', releaseConfig.keyPasswordEnv], + ] as const) { + if (value === undefined || !isNonEmptySingleLine(value)) { + issues.push({ target: 'android', field, message: 'Required when Android release signing is configured.' }); + } + } + for (const [field, value] of [ + ['targets.android.release.storePasswordEnv', releaseConfig.storePasswordEnv], + ['targets.android.release.keyPasswordEnv', releaseConfig.keyPasswordEnv], + ] as const) { + if (value !== undefined && !ENV_NAME_RE.test(value)) { + issues.push({ target: 'android', field, message: 'Use a valid environment variable name.' }); + } + } + } + } return issues; } -export function validateIosBuildConfig(config: ResolvedBuildConfig): BuildValidationIssue[] { +export function validateIosBuildConfig(config: ResolvedBuildConfig, release = false): BuildValidationIssue[] { const ios = config.targets.ios ?? {}; + const releaseConfig = ios.release ?? {}; const issues: BuildValidationIssue[] = []; const bundleId = ios.bundleIdentifier ?? ios.productId ?? config.productId ?? defaultProductId(config, 'ios'); if (!IOS_BUNDLE_ID_RE.test(bundleId)) { @@ -135,6 +197,45 @@ export function validateIosBuildConfig(config: ResolvedBuildConfig): BuildValida }); } } + if (release) { + for (const [field, value] of [ + ['targets.ios.release.archivePath', releaseConfig.archivePath], + ['targets.ios.release.exportPath', releaseConfig.exportPath], + ['targets.ios.release.exportOptionsPlist', releaseConfig.exportOptionsPlist], + ] as const) { + if (value !== undefined) validateRelativePathish(value, field, 'ios', issues); + } + for (const [field, value] of [ + ['targets.ios.release.configuration', releaseConfig.configuration], + ['targets.ios.release.sdk', releaseConfig.sdk], + ['targets.ios.release.provisioningProfileSpecifier', releaseConfig.provisioningProfileSpecifier], + ] as const) { + if (value !== undefined && (!isNonEmptySingleLine(value) || !XCODE_VALUE_RE.test(value))) { + issues.push({ target: 'ios', field, message: 'Use a non-empty Xcode value without shell metacharacters.' }); + } + } + if (releaseConfig.teamId !== undefined && (!isNonEmptySingleLine(releaseConfig.teamId) || !TEAM_ID_RE.test(releaseConfig.teamId))) { + issues.push({ + target: 'ios', + field: 'targets.ios.release.teamId', + message: 'Use an Apple team id containing only letters and numbers.', + }); + } + if (releaseConfig.exportMethod !== undefined && !IOS_EXPORT_METHODS.has(releaseConfig.exportMethod)) { + issues.push({ + target: 'ios', + field: 'targets.ios.release.exportMethod', + message: `Use one of: ${[...IOS_EXPORT_METHODS].join(', ')}.`, + }); + } + if (releaseConfig.signingStyle !== undefined && !IOS_SIGNING_STYLES.has(releaseConfig.signingStyle)) { + issues.push({ + target: 'ios', + field: 'targets.ios.release.signingStyle', + message: 'Use automatic or manual.', + }); + } + } return issues; } diff --git a/cli/test/commands.test.mjs b/cli/test/commands.test.mjs index e208060a..37666acf 100644 --- a/cli/test/commands.test.mjs +++ b/cli/test/commands.test.mjs @@ -149,6 +149,7 @@ function writeFakeLoveJs(dir) { function writeFakeLoveAndroid(dir, options = {}) { const recordPath = options.recordPath ?? join(dir, 'gradle-record.json'); const apkRel = options.apkRel ?? 'app/build/outputs/apk/embed/debug/app-embed-debug.apk'; + const aabRel = options.aabRel ?? 'app/build/outputs/bundle/embedRelease/app-embed-release.aab'; const manifestPermission = options.recordAudioPermission ? ' \n' : ''; @@ -188,12 +189,23 @@ const cwd = process.cwd(); const apk = path.join(cwd, ${JSON.stringify(apkRel)}); fs.mkdirSync(path.dirname(apk), { recursive: true }); fs.writeFileSync(apk, 'fake apk'); -fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ +const aab = path.join(cwd, ${JSON.stringify(aabRel)}); +fs.mkdirSync(path.dirname(aab), { recursive: true }); +fs.writeFileSync(aab, 'fake aab'); +const previous = fs.existsSync(${JSON.stringify(recordPath)}) + ? JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)}, 'utf8')) + : { records: [] }; +const entry = { argv: process.argv.slice(2), embeddedLoveExists: fs.existsSync(path.join(cwd, 'app', 'src', 'embed', 'assets', 'game.love')), gradle: fs.readFileSync(path.join(cwd, 'app', 'build.gradle'), 'utf8'), manifest: fs.readFileSync(path.join(cwd, 'app', 'src', 'main', 'AndroidManifest.xml'), 'utf8'), -}, null, 2)); + signingProperties: fs.existsSync(path.join(cwd, 'feather-signing.properties')) + ? fs.readFileSync(path.join(cwd, 'feather-signing.properties'), 'utf8') + : '', +}; +previous.records.push(entry); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ ...entry, records: previous.records }, null, 2)); process.exit(0); `, ); @@ -715,6 +727,22 @@ test('build validation: rejects bad mobile config values and unsafe native paths 'targets.android.orientation', 'targets.android.gradleTask', ]); + const androidReleaseIssues = validateAndroidBuildConfig({ + ...baseConfig, + targets: { + android: { + release: { + bundleTask: 'bundle release', + apkArtifactPath: '', + storePasswordEnv: '1BAD_ENV', + keyPasswordEnv: 'GOOD_ENV', + }, + }, + }, + }, true); + assert.ok(androidReleaseIssues.some((issue) => issue.field === 'targets.android.release.bundleTask')); + assert.ok(androidReleaseIssues.some((issue) => issue.field === 'targets.android.release.apkArtifactPath')); + assert.ok(androidReleaseIssues.some((issue) => issue.field === 'targets.android.release.storePasswordEnv')); const iosIssues = validateIosBuildConfig({ ...baseConfig, @@ -725,9 +753,27 @@ test('build validation: rejects bad mobile config values and unsafe native paths 'targets.ios.scheme', 'targets.ios.derivedDataPath', ]); + const iosReleaseIssues = validateIosBuildConfig({ + ...baseConfig, + targets: { ios: { release: { exportMethod: 'side-load', signingStyle: 'sometimes', teamId: 'BAD TEAM' } } }, + }, true); + assert.ok(iosReleaseIssues.some((issue) => issue.field === 'targets.ios.release.exportMethod')); + assert.ok(iosReleaseIssues.some((issue) => issue.field === 'targets.ios.release.signingStyle')); + assert.ok(iosReleaseIssues.some((issue) => issue.field === 'targets.ios.release.teamId')); assert.throws(() => resolveWorkspacePath('/tmp/native-work', '../escape.apk', 'Artifact'), /native build workspace/); }); +test('build release: non-mobile targets fail cleanly', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeBuildConfig(dir, { name: 'Web Release', version: '1.0.0', targets: { web: { loveJsDir: 'love.js' } } }); + + const result = run(['build', 'web', '--dir', dir, '--release', '--json']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Release mode is currently supported only for android and ios')); +}); + test('build android: invalid config fails before staging or writing artifacts', () => { const dir = makeTmp(); writeGame(dir); @@ -819,6 +865,61 @@ test('build android: honors gradleTask, custom artifactPath, and removes microph assert.equal(record.manifest.includes('android.permission.INTERNET'), true); }); +test('build android --release: runs bundle/apk tasks, copies artifacts, and keeps signing secrets out of output', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'release.keystore'), 'fake keystore'); + const { recordPath } = writeFakeLoveAndroid(dir, { + recordAudioPermission: true, + aabRel: 'app/build/outputs/custom/store-release.aab', + apkRel: 'app/build/outputs/custom/store-release.apk', + }); + writeBuildConfig(dir, { + name: 'Store Android', + version: '3.0.0', + productId: 'com.example.storeandroid', + targets: { + android: { + loveAndroidDir: 'love-android', + recordAudio: true, + release: { + bundleTask: ':app:bundleStoreRelease', + apkTask: ':app:assembleStoreRelease', + bundleArtifactPath: 'app/build/outputs/custom/store-release.aab', + apkArtifactPath: 'app/build/outputs/custom/store-release.apk', + keystorePath: 'release.keystore', + keyAlias: 'release-key', + storePasswordEnv: 'FEATHER_TEST_STORE_PASSWORD', + keyPasswordEnv: 'FEATHER_TEST_KEY_PASSWORD', + }, + }, + }, + }); + + const result = run(['build', 'android', '--dir', dir, '--release', '--json'], { + env: { + ...process.env, + NO_COLOR: '1', + FORCE_COLOR: '0', + FEATHER_TEST_STORE_PASSWORD: 'store-secret', + FEATHER_TEST_KEY_PASSWORD: 'key-secret', + }, + }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + assert.equal(outputOf(result).includes('store-secret'), false); + assert.equal(outputOf(result).includes('key-secret'), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'aab'), true); + assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'apk'), true); + assert.equal(existsSync(join(dir, 'builds', 'store-android-3.0.0-android.aab')), true); + assert.equal(existsSync(join(dir, 'builds', 'store-android-3.0.0-android.apk')), true); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.deepEqual(record.records.map((entry) => entry.argv[0]), [':app:bundleStoreRelease', ':app:assembleStoreRelease']); + assert.ok(record.signingProperties.includes('keyAlias=release-key')); + assert.ok(record.signingProperties.includes('storePassword=store-secret')); +}); + test('build ios: invalid bundle id fails before xcodebuild', () => { const dir = makeTmp(); writeGame(dir); @@ -908,15 +1009,89 @@ process.exit(0); assert.equal(manifest.artifacts.some((artifact) => artifact.type === 'app'), true); }); +test('build ios --release: archives, exports IPA, and writes release manifest artifacts', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Release iOS', + version: '6.0.0', + targets: { + ios: { + loveIosDir: 'love-ios', + bundleIdentifier: 'com.example.releaseios', + displayName: 'Release iOS', + scheme: 'ReleaseGame', + release: { + archivePath: 'builds/native-release.xcarchive', + exportPath: 'builds/native-export', + exportMethod: 'app-store-connect', + signingStyle: 'manual', + provisioningProfileSpecifier: 'Release Profile', + teamId: 'TEAM12345', + configuration: 'Release', + sdk: 'iphoneos', + }, + }, + }, + }); + const recordPath = join(dir, 'xcodebuild-release-record.json'); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` +if (process.argv.includes('-version')) { + console.log('Xcode 99.0'); + process.exit(0); +} +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +const previous = fs.existsSync(${JSON.stringify(recordPath)}) + ? JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)}, 'utf8')) + : { records: [] }; +if (args.includes('archive')) { + const archivePath = args[args.indexOf('-archivePath') + 1]; + fs.mkdirSync(archivePath, { recursive: true }); + fs.writeFileSync(path.join(archivePath, 'Info.plist'), 'fake archive'); +} +if (args.includes('-exportArchive')) { + const exportPath = args[args.indexOf('-exportPath') + 1]; + const exportOptions = args[args.indexOf('-exportOptionsPlist') + 1]; + fs.mkdirSync(exportPath, { recursive: true }); + fs.writeFileSync(path.join(exportPath, 'Release iOS.ipa'), 'fake ipa'); + previous.exportOptions = fs.readFileSync(exportOptions, 'utf8'); +} +previous.records.push({ argv: args }); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify(previous, null, 2)); +process.exit(0); +`); + + const result = run(['build', 'ios', '--dir', dir, '--release', '--json'], { + env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }), + }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'xcarchive'), true); + assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'ipa'), true); + assert.equal(existsSync(join(dir, 'builds', 'release-ios-6.0.0-ios.xcarchive')), true); + assert.equal(existsSync(join(dir, 'builds', 'release-ios-6.0.0-ios.ipa')), true); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.ok(record.records[0].argv.includes('archive')); + assert.ok(record.records[0].argv.includes('DEVELOPMENT_TEAM=TEAM12345')); + assert.ok(record.records[1].argv.includes('-exportArchive')); + assert.ok(record.exportOptions.includes('app-store-connect')); + assert.ok(record.exportOptions.includes('manual')); + assert.ok(record.exportOptions.includes('Release Profile')); +}); + test('build mobile: missing native template paths fail with actionable errors', () => { const dir = makeTmp(); writeGame(dir); - const android = run(['build', 'android', '--dir', dir, '--json']); + const android = run(['build', 'android', '--dir', dir, '--allow-unsafe', '--json']); assert.equal(android.exitCode, 1); assert.ok(outputOf(android).includes('targets.android.loveAndroidDir')); - const ios = run(['build', 'ios', '--dir', dir, '--json'], { env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0', FEATHER_TEST_ALLOW_IOS_BUILD: '1' } }); + const ios = run(['build', 'ios', '--dir', dir, '--allow-unsafe', '--json'], { env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0', FEATHER_TEST_ALLOW_IOS_BUILD: '1' } }); assert.equal(ios.exitCode, 1); assert.ok(outputOf(ios).includes('targets.ios.loveIosDir')); }); @@ -1102,6 +1277,36 @@ test('doctor: mobile build target reports config validation failures', () => { assert.equal(labels.get('Android product id')?.severity, 'fail'); }); +test('doctor: android release reports missing signing environment', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeFileSync(join(dir, 'release.keystore'), 'fake keystore'); + writeBuildConfig(dir, { + name: 'Doctor Android Release', + version: '1.0.0', + productId: 'com.example.doctorandroidrelease', + targets: { + android: { + loveAndroidDir: 'love-android', + release: { + keystorePath: 'release.keystore', + keyAlias: 'release-key', + storePasswordEnv: 'FEATHER_MISSING_STORE_PASSWORD', + keyPasswordEnv: 'FEATHER_MISSING_KEY_PASSWORD', + }, + }, + }, + }); + + const { result, parsed } = parseDoctorJsonResult(dir, ['--build-target', 'android', '--release']); + assert.equal(result.exitCode, 1); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('Android config')?.severity, 'pass'); + assert.equal(labels.get('Android signing')?.severity, 'fail'); + assert.ok(labels.get('Android signing')?.detail.includes('FEATHER_MISSING_STORE_PASSWORD')); +}); + test('doctor: ios build target reports platform, template, Xcode, and signing hints', () => { const dir = makeTmp(); writeGame(dir); @@ -1135,6 +1340,35 @@ test('doctor: ios build target reports platform, template, Xcode, and signing hi } }); +test('doctor: ios release reports export options and release signing hints', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Doctor iOS Release', + version: '1.0.0', + targets: { + ios: { + loveIosDir: 'love-ios', + bundleIdentifier: 'com.example.doctoriosrelease', + release: { + exportMethod: 'app-store-connect', + signingStyle: 'manual', + teamId: 'TEAM12345', + }, + }, + }, + }); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', `console.log('Xcode 99.0'); process.exit(0);`); + const result = run(['doctor', dir, '--json', '--build-target', 'ios', '--release'], { env: envWithPath(binDir) }); + assert.equal(result.stdout.trim().startsWith('{'), true, outputOf(result)); + const parsed = JSON.parse(result.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('iOS config')?.severity, 'pass'); + assert.equal(labels.get('iOS signing team')?.severity, 'pass'); + assert.equal(labels.get('iOS export options')?.severity, 'pass'); +}); + test('command runtime redacts API keys from compact and debug errors', async () => { const { runCliAction } = await import('../dist/lib/command.js'); const originalError = console.error; diff --git a/docs/installation.md b/docs/installation.md index 7b6f6d15..e44c0322 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -66,6 +66,8 @@ feather doctor path/to/my-game --upload-target itch For mobile setup, start with `feather doctor path/to/my-game --build-target android` or `--build-target ios`. Doctor checks the native template path, product/bundle id, local build tools, and the common environment variables before the build command stages or writes artifacts. +Use `feather build android --release` for Android AAB/APK release artifacts and `feather build ios --release` for iOS archive/IPA artifacts. Keep signing file paths in `feather.build.json`, but put passwords in environment variables referenced by the config so secrets are not committed or printed. + For CI or release scripts that need a security-only JSON report: ```bash diff --git a/docs/recommendations.md b/docs/recommendations.md index fe80c25c..f22d4e5b 100644 --- a/docs/recommendations.md +++ b/docs/recommendations.md @@ -119,7 +119,7 @@ feather build web --dir path/to/my-game --json feather upload itch web --dir path/to/my-game --dry-run --json ``` -Web builds package a local love.js player; Android and iOS create dev artifacts from configured local LÖVE native templates; desktop targets delegate packaging to `love-release`; Itch uploads use Butler. Signed mobile releases and Steam upload are intentionally later phases until those release flows are hardened. +Web builds package a local love.js player; Android and iOS use configured local LÖVE native templates; desktop targets delegate packaging to `love-release`; Itch uploads use Butler. Mobile release mode is opt-in with `--release` and produces Android AAB/APK or iOS archive/IPA artifacts. Play Console, App Store, and Steam upload are intentionally later phases until those release flows are hardened. --- From 327c836db52867cb3918572da80699173d109da9 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 14:45:27 -0400 Subject: [PATCH 44/68] cli: add vender command --- cli/README.md | 13 ++ cli/src/commands/build-vendor.ts | 124 ++++++++++ cli/src/commands/doctor/index.ts | 4 +- cli/src/index.ts | 84 +++++-- cli/src/lib/build/vendor.ts | 381 +++++++++++++++++++++++++++++++ cli/test/commands.test.mjs | 168 ++++++++++++++ docs/installation.md | 4 +- docs/recommendations.md | 3 +- 8 files changed, 762 insertions(+), 19 deletions(-) create mode 100644 cli/src/commands/build-vendor.ts create mode 100644 cli/src/lib/build/vendor.ts diff --git a/cli/README.md b/cli/README.md index 76ceb630..fc7fa3cd 100644 --- a/cli/README.md +++ b/cli/README.md @@ -337,6 +337,7 @@ Build a LÖVE game into local artifacts. V1 supports `web`, `android`, `ios`, `w ```bash feather build web --dir path/to/my-game +feather build vendor add mobile --dir path/to/my-game feather build android --dir path/to/my-game feather build android --dir path/to/my-game --release feather build ios --dir path/to/my-game @@ -349,6 +350,17 @@ feather build web --allow-unsafe Builds read `feather.build.json` from the project root. Missing config is allowed for simple desktop builds, but web builds need a local love.js player directory, mobile builds need local LÖVE native template paths, and uploads need store metadata. +To fetch the mobile native templates locally: + +```bash +feather build vendor add mobile +feather build vendor add android --ref 11.5 +feather build vendor add ios --ref 11.5 --json +feather build vendor list +``` + +`build vendor add` clones official LÖVE vendor sources into `vendor/` and updates `feather.build.json` by default. Android fetches `love2d/love-android` with submodules. iOS fetches `love2d/love` and installs the matching `love--apple-libraries.zip` into the Xcode tree. The version comes from `loveVersion` or `--ref`, falling back to `11.5`. + ```json { "name": "My Game", @@ -440,6 +452,7 @@ Mobile build notes: - Android builds expect `targets.android.loveAndroidDir` to point at a local love-android checkout with `gradlew`. - iOS builds expect `targets.ios.loveIosDir` to point at a local LÖVE iOS source tree with `platform/xcode/love.xcodeproj`. +- `feather build vendor add mobile` fetches those template checkouts, but it does not install Android SDK, JDK, Xcode, or signing assets. - `feather doctor --build-target android --release` validates product id, Gradle wrapper, JDK, Android SDK, and signing env setup. - `feather doctor --build-target ios --release` validates bundle id, macOS/Xcode setup, template path, export options, and signing hints. - Play Console and App Store upload are not included in this pass. diff --git a/cli/src/commands/build-vendor.ts b/cli/src/commands/build-vendor.ts new file mode 100644 index 00000000..58de5dcd --- /dev/null +++ b/cli/src/commands/build-vendor.ts @@ -0,0 +1,124 @@ +import { fail } from '../lib/command.js'; +import { + createSpinner, + printBlank, + printJson, + printKeyValues, + printStatus, + printTable, + style, +} from '../lib/output.js'; +import { + addBuildVendors, + buildVendorTargets, + isBuildVendorTarget, + listBuildVendors, + type BuildVendorTargetInput, +} from '../lib/build/vendor.js'; + +export type BuildVendorCommandOptions = { + dir?: string; + config?: string; + vendorDir?: string; + ref?: string; + androidRef?: string; + iosRef?: string; + force?: boolean; + dryRun?: boolean; + json?: boolean; + configUpdate?: boolean; +}; + +export type BuildVendorListCommandOptions = { + dir?: string; + config?: string; + vendorDir?: string; + json?: boolean; +}; + +export async function buildVendorAddCommand(targetValues: string[], opts: BuildVendorCommandOptions = {}): Promise { + const invalid = targetValues.find((target) => !isBuildVendorTarget(target)); + if (invalid) { + fail(`Unknown build vendor target: ${invalid}`, { details: [`Available: ${buildVendorTargets.join(', ')}`] }); + } + const targets = targetValues as BuildVendorTargetInput[]; + const spinner = opts.json || opts.dryRun ? null : createSpinner('Fetching build vendors…').start(); + try { + const result = await addBuildVendors(targets, { + projectDir: opts.dir, + configPath: opts.config, + vendorDir: opts.vendorDir, + ref: opts.ref, + androidRef: opts.androidRef, + iosRef: opts.iosRef, + force: opts.force, + dryRun: opts.dryRun, + updateConfig: opts.configUpdate, + }); + + if (opts.json) { + printJson(result); + return; + } + + if (opts.dryRun) { + printStatus('info', 'Build vendor plan'); + } else { + spinner?.succeed('Build vendors ready'); + } + printBlank(); + printKeyValues([ + ['Project', result.projectDir], + ['Config', result.configPath], + ['LÖVE', result.loveVersion], + ]); + printBlank(); + printTable({ + columns: [ + { key: 'target', label: 'Target' }, + { key: 'ref', label: 'Ref' }, + { key: 'path', label: 'Path' }, + { key: 'config', label: 'Config' }, + ], + rows: result.vendors.map((vendor) => ({ + target: vendor.target, + ref: vendor.ref, + path: vendor.relativePath, + config: vendor.configUpdated ? 'updated' : opts.dryRun && opts.configUpdate !== false ? 'planned' : 'unchanged', + })), + }); + } catch (err) { + spinner?.fail((err as Error).message); + fail((err as Error).message, { silent: Boolean(spinner) }); + } +} + +export function buildVendorListCommand(opts: BuildVendorListCommandOptions = {}): void { + const result = listBuildVendors({ + projectDir: opts.dir, + configPath: opts.config, + vendorDir: opts.vendorDir, + }); + + if (opts.json) { + printJson(result); + return; + } + + printStatus('info', `Build vendors for ${style.heading(result.projectDir)}`); + printBlank(); + printTable({ + columns: [ + { key: 'target', label: 'Target' }, + { key: 'configured', label: 'Configured' }, + { key: 'status', label: 'Status' }, + { key: 'path', label: 'Path' }, + ], + rows: result.vendors.map((vendor) => ({ + target: vendor.target, + configured: vendor.configured ? 'yes' : 'no', + status: vendor.valid ? 'ready' : vendor.exists ? 'incomplete' : 'missing', + path: vendor.configuredPath ?? vendor.relativePath, + })), + }); +} diff --git a/cli/src/commands/doctor/index.ts b/cli/src/commands/doctor/index.ts index d4eb5c96..3cd41245 100644 --- a/cli/src/commands/doctor/index.ts +++ b/cli/src/commands/doctor/index.ts @@ -228,7 +228,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) 'love-android template', hasLoveAndroidDir ? 'pass' : 'fail', androidConfig.loveAndroidDir ?? 'not configured', - 'Set targets.android.loveAndroidDir in feather.build.json to a local love-android checkout.', + hasLoveAndroidDir ? undefined : `Run \`feather build vendor add android --dir ${projectDir}\` or set targets.android.loveAndroidDir in feather.build.json.`, ); const gradleWrapper = hasLoveAndroidDir && (existsSync(join(loveAndroidDir, 'gradlew')) || existsSync(join(loveAndroidDir, 'gradlew.bat'))); add( @@ -303,7 +303,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) 'LÖVE iOS template', hasLoveIosDir ? 'pass' : 'fail', iosConfig.loveIosDir ?? 'not configured', - 'Set targets.ios.loveIosDir in feather.build.json to a local LÖVE iOS source checkout.', + hasLoveIosDir ? undefined : `Run \`feather build vendor add ios --dir ${projectDir}\` or set targets.ios.loveIosDir in feather.build.json.`, ); const xcodeProject = hasLoveIosDir && existsSync(join(loveIosDir, 'platform', 'xcode', 'love.xcodeproj')); add( diff --git a/cli/src/index.ts b/cli/src/index.ts index 66e596fd..4d5c4e84 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -7,6 +7,7 @@ import { removeCommand } from './commands/remove.js'; import { doctorCommand } from './commands/doctor.js'; import { updateCommand } from './commands/update.js'; import { buildCommand } from './commands/build.js'; +import { buildVendorAddCommand, buildVendorListCommand } from './commands/build-vendor.js'; import { uploadCommand } from './commands/upload.js'; import { pluginListCommand, @@ -130,20 +131,25 @@ program release: opts.release as boolean | undefined, }))); -program - .command('build ') - .description('Build a LÖVE game for web, mobile dev, or desktop targets') - .option('--dir ', 'Project directory (default: current directory)') - .option('--config ', 'Path to feather.build.json') - .option('--out-dir ', 'Build output directory') - .option('--name ', 'Build product name') - .option('--version ', 'Build product version') - .option('--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') - .action((target: string, opts) => runCliAction(() => buildCommand(target, { +const build = program + .command('build') + .description('Build a LÖVE game for web, mobile dev, or desktop targets'); + +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('--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') + .action((opts) => runCliAction(() => buildCommand(target, { dir: opts.dir as string | undefined, config: opts.config as string | undefined, outDir: opts.outDir as string | undefined, @@ -155,6 +161,56 @@ program allowUnsafe: opts.allowUnsafe as boolean | undefined, release: opts.release as boolean | undefined, }))); +} + +for (const target of ['web', 'android', 'ios', 'windows', 'macos', 'linux', 'steamos']) { + addBuildTargetCommand(target); +} + +const buildVendor = build + .command('vendor') + .description('Fetch and inspect local build vendor templates'); + +buildVendor + .command('add [targets...]') + .description('Fetch build vendors: android, ios, mobile, or all') + .allowUnknownOption() + .option('--dir ', 'Project directory (default: current directory)') + .option('--config ', 'Path to feather.build.json') + .option('--vendor-dir ', 'Vendor directory inside the project', 'vendor') + .option('--ref ', 'LÖVE version/tag/ref for all vendors') + .option('--android-ref ', 'Android vendor branch/tag/ref override') + .option('--ios-ref ', 'iOS vendor branch/tag/ref override') + .option('--force', 'Replace existing vendor directories or conflicting config paths') + .option('--dry-run', 'Show planned vendor changes without writing files') + .option('--json', 'Output machine-readable JSON') + .addHelpText('after', '\n --no-config Do not update feather.build.json') + .action((targets: string[], opts) => runCliAction(() => buildVendorAddCommand(targets.filter((target) => target !== '--no-config'), { + dir: opts.dir as string | undefined, + config: opts.config as string | undefined, + vendorDir: opts.vendorDir as string | undefined, + ref: opts.ref as string | undefined, + 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 [build-target]') diff --git a/cli/src/lib/build/vendor.ts b/cli/src/lib/build/vendor.ts new file mode 100644 index 00000000..42caaa09 --- /dev/null +++ b/cli/src/lib/build/vendor.ts @@ -0,0 +1,381 @@ +import { spawnSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { inflateRawSync } from 'node:zlib'; +import { dirname, join, relative, resolve } from 'node:path'; +import { + buildConfigPath, + loadBuildConfig, + readBuildConfig, + type FeatherBuildConfig, +} from './config.js'; +import { assertNoSymlinkEscape, assertSafeRelativePath, isPathInside } from '../path-safety.js'; + +export const buildVendorTargets = ['android', 'ios', 'mobile', 'all'] as const; +export type BuildVendorTargetInput = typeof buildVendorTargets[number]; +export type ConcreteBuildVendorTarget = 'android' | 'ios'; + +export type BuildVendorAddOptions = { + projectDir?: string; + configPath?: string; + vendorDir?: string; + ref?: string; + androidRef?: string; + iosRef?: string; + force?: boolean; + dryRun?: boolean; + updateConfig?: boolean; +}; + +export type BuildVendorListOptions = { + projectDir?: string; + configPath?: string; + vendorDir?: string; +}; + +export type BuildVendorResult = { + target: ConcreteBuildVendorTarget; + path: string; + relativePath: string; + ref: string; + repo: string; + installed: boolean; + skipped: boolean; + configUpdated: boolean; + actions: string[]; +}; + +export type BuildVendorAddResult = { + ok: true; + projectDir: string; + configPath: string; + loveVersion: string; + vendors: BuildVendorResult[]; +}; + +export type BuildVendorListEntry = { + target: ConcreteBuildVendorTarget; + path: string; + relativePath: string; + configuredPath?: string; + configured: boolean; + exists: boolean; + valid: boolean; + detail: string; +}; + +export type BuildVendorListResult = { + ok: true; + projectDir: string; + configPath: string; + vendors: BuildVendorListEntry[]; +}; + +const DEFAULT_LOVE_VERSION = '11.5'; +const LOVE_ANDROID_REPO = 'https://github.com/love2d/love-android'; +const LOVE_REPO = 'https://github.com/love2d/love'; + +export function isBuildVendorTarget(value: string): value is BuildVendorTargetInput { + return (buildVendorTargets as readonly string[]).includes(value); +} + +export async function addBuildVendors(targets: BuildVendorTargetInput[], options: BuildVendorAddOptions = {}): Promise { + const projectDir = resolve(options.projectDir ?? process.cwd()); + const raw = readBuildConfig(projectDir, options.configPath); + const configPath = buildConfigPath(projectDir, options.configPath); + const config = loadBuildConfig({ projectDir, configPath: options.configPath }); + const loveVersion = sanitizeRef(options.ref ?? config.loveVersion ?? DEFAULT_LOVE_VERSION, 'LÖVE version'); + const vendorDir = resolveVendorDir(projectDir, options.vendorDir ?? 'vendor'); + const expanded = expandVendorTargets(targets); + const results: BuildVendorResult[] = []; + + for (const target of expanded) { + const result = await addSingleVendor({ + target, + projectDir, + raw, + configPath, + vendorDir, + loveVersion, + ref: target === 'android' + ? sanitizeRef(options.androidRef ?? options.ref ?? config.loveVersion ?? DEFAULT_LOVE_VERSION, 'Android vendor ref') + : sanitizeRef(options.iosRef ?? options.ref ?? config.loveVersion ?? DEFAULT_LOVE_VERSION, 'iOS vendor ref'), + force: Boolean(options.force), + dryRun: Boolean(options.dryRun), + updateConfig: options.updateConfig !== false, + }); + results.push(result); + } + + return { + ok: true, + projectDir, + configPath, + loveVersion, + vendors: results, + }; +} + +export function listBuildVendors(options: BuildVendorListOptions = {}): BuildVendorListResult { + const projectDir = resolve(options.projectDir ?? process.cwd()); + const raw = readBuildConfig(projectDir, options.configPath); + const configPath = buildConfigPath(projectDir, options.configPath); + const vendorDir = resolveVendorDir(projectDir, options.vendorDir ?? 'vendor'); + const vendors = (['android', 'ios'] as const).map((target) => vendorStatus(projectDir, raw, vendorDir, target)); + return { ok: true, projectDir, configPath, vendors }; +} + +function expandVendorTargets(targets: BuildVendorTargetInput[]): ConcreteBuildVendorTarget[] { + const requested: BuildVendorTargetInput[] = targets.length > 0 ? targets : ['mobile']; + const expanded = new Set(); + for (const target of requested) { + if (target === 'mobile' || target === 'all') { + expanded.add('android'); + expanded.add('ios'); + } else { + expanded.add(target); + } + } + return [...expanded]; +} + +type AddSingleVendorInput = { + target: ConcreteBuildVendorTarget; + projectDir: string; + raw: FeatherBuildConfig; + configPath: string; + vendorDir: string; + loveVersion: string; + ref: string; + force: boolean; + dryRun: boolean; + updateConfig: boolean; +}; + +async function addSingleVendor(input: AddSingleVendorInput): Promise { + const defaultRelativePath = input.target === 'android' + ? relativeProjectPath(input.projectDir, join(input.vendorDir, 'love-android')) + : relativeProjectPath(input.projectDir, join(input.vendorDir, 'love-ios')); + const configuredPath = input.target === 'android' + ? input.raw.targets?.android?.loveAndroidDir + : input.raw.targets?.ios?.loveIosDir; + if (configuredPath && configuredPath !== defaultRelativePath && !input.force) { + throw new Error(`${input.target} vendor is already configured at ${configuredPath}. Use --force to replace it with ${defaultRelativePath}.`); + } + + const targetPath = resolveProjectVendorPath(input.projectDir, configuredPath && !input.force ? configuredPath : defaultRelativePath, `${input.target} vendor directory`); + const relativePath = relativeProjectPath(input.projectDir, targetPath); + const repo = input.target === 'android' ? LOVE_ANDROID_REPO : LOVE_REPO; + const actions: string[] = []; + + if (existsSync(targetPath) && !input.force) { + throw new Error(`${input.target} vendor directory already exists: ${targetPath}. Use --force to replace it.`); + } + + actions.push(`clone ${repo}#${input.ref} -> ${relativePath}`); + if (input.target === 'ios') { + actions.push(`install love-${input.loveVersion}-apple-libraries.zip`); + } + if (input.updateConfig) { + actions.push(`update ${relative(input.projectDir, input.configPath) || 'feather.build.json'}`); + } + + if (!input.dryRun) { + assertGitAvailable(); + if (existsSync(targetPath) && input.force) rmSync(targetPath, { recursive: true, force: true }); + mkdirSync(dirname(targetPath), { recursive: true }); + cloneVendor(repo, input.ref, targetPath, input.target === 'android'); + if (input.target === 'ios') { + await installAppleLibraries(input.loveVersion, targetPath); + } + if (input.updateConfig) { + updateVendorConfig(input.projectDir, input.configPath, input.raw, input.target, relativePath); + } + } + + return { + target: input.target, + path: targetPath, + relativePath, + ref: input.ref, + repo, + installed: !input.dryRun, + skipped: false, + configUpdated: input.updateConfig && !input.dryRun, + actions, + }; +} + +function vendorStatus(projectDir: string, raw: FeatherBuildConfig, vendorDir: string, target: ConcreteBuildVendorTarget): BuildVendorListEntry { + const configuredPath = target === 'android' + ? raw.targets?.android?.loveAndroidDir + : raw.targets?.ios?.loveIosDir; + const fallback = target === 'android' ? join(vendorDir, 'love-android') : join(vendorDir, 'love-ios'); + const path = configuredPath + ? resolveProjectVendorPath(projectDir, configuredPath, `${target} vendor directory`) + : fallback; + const exists = existsSync(path); + const valid = target === 'android' + ? existsSync(join(path, process.platform === 'win32' ? 'gradlew.bat' : 'gradlew')) || existsSync(join(path, 'gradlew')) || existsSync(join(path, 'gradlew.bat')) + : existsSync(join(path, 'platform', 'xcode', 'love.xcodeproj')); + return { + target, + path, + relativePath: relativeProjectPath(projectDir, path), + configuredPath, + configured: Boolean(configuredPath), + exists, + valid, + detail: valid ? 'ready' : exists ? 'present but missing expected build files' : 'missing', + }; +} + +function resolveVendorDir(projectDir: string, vendorDir: string): string { + assertSafeRelativePath(vendorDir, 'Vendor directory'); + const absolute = resolve(projectDir, vendorDir); + assertNoSymlinkEscape(projectDir, absolute, 'Vendor directory'); + return absolute; +} + +function resolveProjectVendorPath(projectDir: string, path: string, label: string): string { + assertSafeRelativePath(path, label); + const absolute = resolve(projectDir, path); + assertNoSymlinkEscape(projectDir, absolute, label); + return absolute; +} + +function relativeProjectPath(projectDir: string, path: string): string { + const relativePath = relative(projectDir, path).replace(/\\/g, '/'); + if (!relativePath || relativePath.startsWith('..') || !isPathInside(projectDir, path)) { + throw new Error(`Vendor path must stay inside project root: ${path}`); + } + return relativePath; +} + +function sanitizeRef(value: string, label: string): string { + const trimmed = value.trim(); + if (!trimmed || /[\0\r\n]/.test(trimmed) || trimmed.startsWith('-')) { + throw new Error(`${label} must be a non-empty branch, tag, or version.`); + } + return trimmed; +} + +function assertGitAvailable(): void { + const result = spawnSync('git', ['--version'], { encoding: 'utf8' }); + if (result.error || result.status !== 0) { + throw new Error('git is required to fetch build vendors. Install git and make sure it is on PATH.'); + } +} + +function cloneVendor(repo: string, ref: string, targetPath: string, recurseSubmodules: boolean): void { + const args = ['clone']; + if (recurseSubmodules) args.push('--recurse-submodules'); + args.push('--depth', '1', '--branch', ref, repo, targetPath); + const result = spawnSync('git', args, { encoding: 'utf8' }); + if (result.error) throw new Error(`git clone failed to start: ${result.error.message}`); + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || `git clone failed for ${repo}`).trim()); + } +} + +async function installAppleLibraries(loveVersion: string, loveIosDir: string): Promise { + const zip = await appleLibrariesZip(loveVersion); + const entries = unzip(zip); + let installed = 0; + for (const entry of entries) { + const normalized = entry.name.replace(/\\/g, '/').replace(/^\/+/, ''); + const librariesPrefix = 'iOS/libraries/'; + const sharedPrefix = 'shared/'; + let destination: string | null = null; + if (normalized.startsWith(librariesPrefix)) { + destination = join(loveIosDir, 'platform', 'xcode', 'ios', 'libraries', normalized.slice(librariesPrefix.length)); + } else if (normalized.startsWith(sharedPrefix)) { + destination = join(loveIosDir, 'platform', 'xcode', 'shared', normalized.slice(sharedPrefix.length)); + } + if (!destination || destination.endsWith('/') || entry.data.length === 0) continue; + mkdirSync(dirname(destination), { recursive: true }); + writeFileSync(destination, entry.data); + installed += 1; + } + if (installed === 0) { + throw new Error(`love-${loveVersion}-apple-libraries.zip did not contain iOS/libraries or shared files.`); + } +} + +async function appleLibrariesZip(loveVersion: string): Promise { + const fixture = process.env.FEATHER_TEST_LOVE_APPLE_LIBRARIES_ZIP; + if (fixture) return readFileSync(fixture); + const url = `https://github.com/love2d/love/releases/download/${encodeURIComponent(loveVersion)}/love-${encodeURIComponent(loveVersion)}-apple-libraries.zip`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download ${url}: HTTP ${response.status}`); + } + return Buffer.from(await response.arrayBuffer()); +} + +type UnzippedEntry = { + name: string; + data: Buffer; +}; + +function unzip(zip: Buffer): UnzippedEntry[] { + const entries: UnzippedEntry[] = []; + let offset = 0; + while (offset + 30 <= zip.length) { + const signature = zip.readUInt32LE(offset); + if (signature !== 0x04034b50) break; + const flags = zip.readUInt16LE(offset + 6); + const method = zip.readUInt16LE(offset + 8); + const compressedSize = zip.readUInt32LE(offset + 18); + const uncompressedSize = zip.readUInt32LE(offset + 22); + const nameLength = zip.readUInt16LE(offset + 26); + const extraLength = zip.readUInt16LE(offset + 28); + if (flags & 0x08) { + throw new Error('ZIP entries with data descriptors are not supported.'); + } + const nameStart = offset + 30; + const dataStart = nameStart + nameLength + extraLength; + const dataEnd = dataStart + compressedSize; + if (dataEnd > zip.length) throw new Error('Invalid ZIP archive.'); + const name = zip.subarray(nameStart, nameStart + nameLength).toString('utf8'); + const compressed = zip.subarray(dataStart, dataEnd); + let data: Buffer; + if (method === 0) { + data = Buffer.from(compressed); + } else if (method === 8) { + data = inflateRawSync(compressed); + } else { + throw new Error(`Unsupported ZIP compression method: ${method}`); + } + if (data.length !== uncompressedSize) throw new Error(`Invalid ZIP entry size for ${name}.`); + entries.push({ name, data }); + offset = dataEnd; + } + return entries; +} + +function updateVendorConfig( + projectDir: string, + configPath: string, + raw: FeatherBuildConfig, + target: ConcreteBuildVendorTarget, + relativePath: string, +): void { + const next: FeatherBuildConfig = { + ...raw, + targets: { + ...(raw.targets ?? {}), + [target]: { + ...((raw.targets ?? {})[target] ?? {}), + [target === 'android' ? 'loveAndroidDir' : 'loveIosDir']: relativePath, + }, + }, + }; + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, `${JSON.stringify(next, null, 2)}\n`); +} diff --git a/cli/test/commands.test.mjs b/cli/test/commands.test.mjs index 37666acf..5f727459 100644 --- a/cli/test/commands.test.mjs +++ b/cli/test/commands.test.mjs @@ -125,6 +125,39 @@ function writeFakeCommand(dir, name, script) { return { binDir, commandPath }; } +function writeFakeVendorGit(dir) { + const recordPath = join(dir, 'git-record.json'); + const { binDir } = writeFakeCommand(dir, 'git', ` +const fs = require('fs'); +const path = require('path'); +const args = process.argv.slice(2); +const recordPath = ${JSON.stringify(recordPath)}; +const records = fs.existsSync(recordPath) ? JSON.parse(fs.readFileSync(recordPath, 'utf8')) : []; +records.push({ args }); +fs.writeFileSync(recordPath, JSON.stringify(records, null, 2)); +if (args[0] === '--version') { + console.log('git version test'); + process.exit(0); +} +if (args[0] !== 'clone') { + console.error('unexpected git args ' + args.join(' ')); + process.exit(1); +} +const target = args[args.length - 1]; +const repo = args[args.length - 2]; +fs.mkdirSync(target, { recursive: true }); +if (repo.includes('love-android')) { + fs.writeFileSync(path.join(target, 'gradlew'), '#!/bin/sh\\n'); + fs.mkdirSync(path.join(target, 'app', 'src', 'embed', 'assets'), { recursive: true }); +} else if (repo.includes('love')) { + fs.mkdirSync(path.join(target, 'platform', 'xcode', 'love.xcodeproj'), { recursive: true }); + fs.writeFileSync(path.join(target, 'platform', 'xcode', 'love.xcodeproj', 'project.pbxproj'), ''); +} +process.exit(0); +`); + return { binDir, recordPath }; +} + function envWithPath(binDir, extra = {}) { return { ...process.env, @@ -240,6 +273,16 @@ function writeBuildConfig(dir, config) { writeFileSync(join(dir, 'feather.build.json'), `${JSON.stringify(config, null, 2)}\n`); } +async function writeFakeAppleLibrariesZip(dir) { + const { createZipBuffer } = await import('../dist/lib/build/archive.js'); + const zipPath = join(dir, 'love-apple-libraries.zip'); + writeFileSync(zipPath, createZipBuffer([ + { name: 'iOS/libraries/liblove-test.a', data: Buffer.from('ios lib') }, + { name: 'shared/test-shared.txt', data: Buffer.from('shared lib') }, + ])); + return zipPath; +} + function parseDoctorJson(dir, extra = []) { const result = run(['doctor', dir, '--json', ...extra]); assert.equal(result.exitCode, 0, outputOf(result)); @@ -774,6 +817,129 @@ test('build release: non-mobile targets fail cleanly', () => { assert.ok(outputOf(result).includes('Release mode is currently supported only for android and ios')); }); +test('build vendor add android --json: clones vendor and updates config', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Vendor Android', version: '1.0.0', loveVersion: '11.5' }); + const { binDir, recordPath } = writeFakeVendorGit(dir); + + const result = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--json'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.vendors[0].target, 'android'); + assert.equal(parsed.vendors[0].relativePath, 'vendor/love-android'); + assert.equal(parsed.vendors[0].configUpdated, true); + assert.equal(existsSync(join(dir, 'vendor', 'love-android', 'gradlew')), true); + const config = JSON.parse(readFileSync(join(dir, 'feather.build.json'), 'utf8')); + assert.equal(config.targets.android.loveAndroidDir, 'vendor/love-android'); + const records = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.ok(records.some((record) => record.args.includes('--recurse-submodules'))); + assert.ok(records.some((record) => record.args.includes('https://github.com/love2d/love-android'))); +}); + +test('build vendor add ios --json: clones vendor, installs Apple libraries, and updates config', async () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Vendor iOS', version: '1.0.0', loveVersion: '11.5' }); + const { binDir, recordPath } = writeFakeVendorGit(dir); + const zipPath = await writeFakeAppleLibrariesZip(dir); + + const result = run(['build', 'vendor', 'add', 'ios', '--dir', dir, '--json'], { + env: envWithPath(binDir, { FEATHER_TEST_LOVE_APPLE_LIBRARIES_ZIP: zipPath }), + }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.vendors[0].target, 'ios'); + assert.equal(parsed.vendors[0].relativePath, 'vendor/love-ios'); + assert.equal(existsSync(join(dir, 'vendor', 'love-ios', 'platform', 'xcode', 'ios', 'libraries', 'liblove-test.a')), true); + assert.equal(existsSync(join(dir, 'vendor', 'love-ios', 'platform', 'xcode', 'shared', 'test-shared.txt')), true); + const config = JSON.parse(readFileSync(join(dir, 'feather.build.json'), 'utf8')); + assert.equal(config.targets.ios.loveIosDir, 'vendor/love-ios'); + const records = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.ok(records.some((record) => record.args.includes('https://github.com/love2d/love'))); +}); + +test('build vendor add mobile --dry-run --json: reports planned vendors without writing', () => { + const dir = makeTmp(); + writeGame(dir); + + const result = run(['build', 'vendor', 'add', 'mobile', '--dir', dir, '--dry-run', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.deepEqual(parsed.vendors.map((vendor) => vendor.target), ['android', 'ios']); + assert.equal(existsSync(join(dir, 'vendor')), false); + assert.equal(existsSync(join(dir, 'feather.build.json')), false); +}); + +test('build vendor add --no-config: fetches vendor without writing build config', () => { + const dir = makeTmp(); + writeGame(dir); + const { binDir } = writeFakeVendorGit(dir); + + const result = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--no-config', '--json'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'vendor', 'love-android', 'gradlew')), true); + assert.equal(existsSync(join(dir, 'feather.build.json')), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.vendors[0].configUpdated, false); +}); + +test('build vendor add: existing directories and conflicting config require --force', () => { + const dir = makeTmp(); + writeGame(dir); + mkdirSync(join(dir, 'vendor', 'love-android'), { recursive: true }); + + const existing = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--json']); + assert.equal(existing.exitCode, 1); + assert.ok(outputOf(existing).includes('--force')); + + writeBuildConfig(dir, { targets: { android: { loveAndroidDir: 'native/love-android' } } }); + const conflict = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--dry-run', '--json']); + assert.equal(conflict.exitCode, 1); + assert.ok(outputOf(conflict).includes('already configured')); + + const { binDir } = writeFakeVendorGit(dir); + const forced = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--force', '--json'], { env: envWithPath(binDir) }); + assert.equal(forced.exitCode, 0, outputOf(forced)); + const config = JSON.parse(readFileSync(join(dir, 'feather.build.json'), 'utf8')); + assert.equal(config.targets.android.loveAndroidDir, 'vendor/love-android'); +}); + +test('build vendor list --json: reports configured, missing, and valid vendors', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + targets: { + android: { loveAndroidDir: 'love-android' }, + ios: { loveIosDir: 'vendor/love-ios' }, + }, + }); + + const result = run(['build', 'vendor', 'list', '--dir', dir, '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + const labels = new Map(parsed.vendors.map((vendor) => [vendor.target, vendor])); + assert.equal(labels.get('android').valid, true); + assert.equal(labels.get('ios').exists, false); + assert.equal(labels.get('ios').detail, 'missing'); +}); + +test('build vendor add: missing git produces compact actionable error', () => { + const dir = makeTmp(); + writeGame(dir); + + const result = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--json'], { + env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0', PATH: '' }, + }); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('git is required')); +}); + test('build android: invalid config fails before staging or writing artifacts', () => { const dir = makeTmp(); writeGame(dir); @@ -1235,6 +1401,7 @@ test('doctor: android build target reports template and local tool setup', () => assert.equal(missingLabels.get('love-android template')?.severity, 'fail'); assert.equal(missingLabels.get('Android Gradle wrapper')?.severity, 'fail'); assert.ok(missingLabels.get('love-android template')?.fix.includes('targets.android.loveAndroidDir')); + assert.ok(missingLabels.get('love-android template')?.fix.includes('feather build vendor add android')); writeFakeLoveAndroid(dir); writeBuildConfig(dir, { @@ -1315,6 +1482,7 @@ test('doctor: ios build target reports platform, template, Xcode, and signing hi const missingLabels = new Map(missing.checks.map((check) => [check.label, check])); assert.equal(missingLabels.get('LÖVE iOS template')?.severity, 'fail'); assert.equal(missingLabels.get('LÖVE iOS Xcode project')?.severity, 'fail'); + assert.ok(missingLabels.get('LÖVE iOS template')?.fix.includes('feather build vendor add ios')); writeFakeLoveIos(dir); writeBuildConfig(dir, { diff --git a/docs/installation.md b/docs/installation.md index e44c0322..e3e1060e 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -62,9 +62,9 @@ feather doctor path/to/my-game --build-target web feather doctor path/to/my-game --upload-target itch ``` -`feather build` supports web, Android, iOS, Windows, macOS, Linux, and SteamOS artifacts. Web builds need a local love.js player directory configured in `feather.build.json`; Android/iOS dev builds need local love-android or LÖVE iOS template paths; desktop builds expect `love-release` on `PATH`. `feather upload itch` expects Butler on `PATH` and either `BUTLER_API_KEY` in CI or a local `butler login` session. +`feather build` supports web, Android, iOS, Windows, macOS, Linux, and SteamOS artifacts. Web builds need a local love.js player directory configured in `feather.build.json`; Android/iOS builds need local love-android or LÖVE iOS template paths; desktop builds expect `love-release` on `PATH`. `feather upload itch` expects Butler on `PATH` and either `BUTLER_API_KEY` in CI or a local `butler login` session. -For mobile setup, start with `feather doctor path/to/my-game --build-target android` or `--build-target ios`. Doctor checks the native template path, product/bundle id, local build tools, and the common environment variables before the build command stages or writes artifacts. +For mobile setup, run `feather build vendor add mobile --dir path/to/my-game` to fetch official LÖVE template checkouts into `vendor/` and update `feather.build.json`. Then run `feather doctor path/to/my-game --build-target android` or `--build-target ios`. Doctor checks the native template path, product/bundle id, local build tools, and the common environment variables before the build command stages or writes artifacts. Vendor fetching does not install Android SDK, JDK, Xcode, or signing assets. Use `feather build android --release` for Android AAB/APK release artifacts and `feather build ios --release` for iOS archive/IPA artifacts. Keep signing file paths in `feather.build.json`, but put passwords in environment variables referenced by the config so secrets are not committed or printed. diff --git a/docs/recommendations.md b/docs/recommendations.md index f22d4e5b..d13441ec 100644 --- a/docs/recommendations.md +++ b/docs/recommendations.md @@ -116,10 +116,11 @@ If you use Feather's local release helpers, keep release metadata in `feather.bu ```bash feather build web --dir path/to/my-game --json +feather build vendor add mobile --dir path/to/my-game --json feather upload itch web --dir path/to/my-game --dry-run --json ``` -Web builds package a local love.js player; Android and iOS use configured local LÖVE native templates; desktop targets delegate packaging to `love-release`; Itch uploads use Butler. Mobile release mode is opt-in with `--release` and produces Android AAB/APK or iOS archive/IPA artifacts. Play Console, App Store, and Steam upload are intentionally later phases until those release flows are hardened. +Web builds package a local love.js player; `build vendor add mobile` fetches official Android/iOS LÖVE templates into `vendor/`; desktop targets delegate packaging to `love-release`; Itch uploads use Butler. Mobile release mode is opt-in with `--release` and produces Android AAB/APK or iOS archive/IPA artifacts. Play Console, App Store, and Steam upload are intentionally later phases until those release flows are hardened. --- From c1436f2aaffb5aaf052a9de0674720d29faccd5b Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 15:07:11 -0400 Subject: [PATCH 45/68] cli: add mobile to feather run --- cli/README.md | 17 +++- cli/src/commands/run.ts | 51 +++++++++- cli/src/index.ts | 14 +++ cli/src/lib/run/mobile.ts | 133 ++++++++++++++++++++++++ cli/test/commands.test.mjs | 204 ++++++++++++++++++++++++++++++++++++- docs/index.md | 7 +- docs/installation.md | 21 ++-- 7 files changed, 434 insertions(+), 13 deletions(-) create mode 100644 cli/src/lib/run/mobile.ts diff --git a/cli/README.md b/cli/README.md index fc7fa3cd..40723c81 100644 --- a/cli/README.md +++ b/cli/README.md @@ -13,10 +13,12 @@ The Feather CLI lets you run and debug LÖVE games **without modifying your game ```bash feather run feather run path/to/my-game +feather run path/to/my-game --target android +feather run path/to/my-game --target ios ``` > [!IMPORTANT] -> `feather run` is best for local desktop development. For mobile, handhelds, Steam Deck, or a second computer where the CLI is not launching the game process, use `feather init --mode auto` so the game carries the embedded Feather library. +> `feather run` launches desktop games directly. For Android and iOS dev loops it builds the configured native template, installs the artifact, and launches it on a connected device or simulator. --- @@ -77,6 +79,10 @@ feather run . --no-plugins # feather core only, no plugins feather run . --love /usr/bin/love # override love2d binary feather run . --plugins-dir ./my-plugins # use a custom plugins directory feather run . -- --level dev # pass args through to the game +feather run . --target android # build, install, adb reverse, and launch Android +feather run . --target android --device emulator-5554 +feather run . --target ios # build, install, and launch on the booted simulator +feather run . --target ios --device ``` When `game-path` is omitted in an interactive terminal, Feather opens an Ink workflow that asks for the game path, session name, config path, whether plugins should be disabled, and optional advanced paths/arguments. Scripts should pass `game-path` explicitly. @@ -91,9 +97,18 @@ When `game-path` is omitted in an interactive terminal, Feather opens an Ink wor | `--config ` | Explicit path to a `feather.config.lua` file. | | `--feather-path ` | Use a local feather install instead of the CLI's bundled copy. | | `--plugins-dir ` | Use a custom plugins directory instead of the CLI's bundled plugins. | +| `--target ` | Run target: `desktop`, `android`, or `ios`. Defaults to `desktop`. | +| `--device ` | Android device serial or iOS simulator UDID. iOS defaults to `booted`. | +| `--build-config ` | Path to `feather.build.json` for mobile run. | +| `--out-dir ` | Build output directory for mobile run. | +| `--clean` | Remove the output directory before the mobile build. | +| `--no-adb-reverse` | Skip Android `adb reverse` setup. | +| `--port ` | Port used for Android `adb reverse`; defaults to `feather.config.lua` `port` or `4004`. | Use `--` to separate Feather CLI options from arguments intended for the LÖVE game. Everything after `--` is passed to `love` after the generated shim path. +Mobile run is dev-only in V1 and does not forward game arguments. Android requires `adb`, a configured `targets.android.loveAndroidDir`, and USB debugging or an emulator. iOS requires macOS, Xcode, a configured `targets.ios.loveIosDir`, and a booted simulator. + **Project config file:** If a `feather.config.lua` exists in the game directory, it is read automatically and merged into the feather setup. See [feather.config.lua](#featherconfiglua). diff --git a/cli/src/commands/run.ts b/cli/src/commands/run.ts index 1e1322ca..e63c1d41 100644 --- a/cli/src/commands/run.ts +++ b/cli/src/commands/run.ts @@ -6,7 +6,8 @@ import { createShim, shimEnv } from "../lib/shim.js"; import { loadConfig } from "../lib/config.js"; import { chooseRunWorkflow } from "../ui/run-workflow.js"; import { fail } from "../lib/command.js"; -import { printInfo, printKeyValues, printMuted } from "../lib/output.js"; +import { printInfo, printKeyValues, printMuted, printStatus } from "../lib/output.js"; +import { runMobile, type MobileRunTarget } from "../lib/run/mobile.js"; export interface RunOptions { love?: string; @@ -16,9 +17,24 @@ export interface RunOptions { featherPath?: string; pluginsDir?: string; gameArgs?: string[]; + target?: "desktop" | MobileRunTarget; + device?: string; + buildConfig?: string; + outDir?: string; + clean?: boolean; + adbReverse?: boolean; + port?: number; } export async function runCommand(gamePath: string | undefined, opts: RunOptions): Promise { + const target = opts.target ?? "desktop"; + if (!["desktop", "android", "ios"].includes(target)) { + fail("Run target must be one of: desktop, android, ios."); + } + if (opts.port !== undefined && (!Number.isInteger(opts.port) || opts.port < 1 || opts.port > 65535)) { + fail("Port must be a number between 1 and 65535."); + } + if (!gamePath) { if (!process.stdin.isTTY) { fail("Game path is required. Use `feather run `."); @@ -53,6 +69,38 @@ export async function runCommand(gamePath: string | undefined, opts: RunOptions) fail(`No main.lua found in: ${absGame}`); } + const userConfig = loadConfig(absGame, opts.config) ?? undefined; + + if (target === "android" || target === "ios") { + if ((opts.gameArgs?.length ?? 0) > 0) { + fail("Mobile run does not support forwarded game arguments yet."); + } + try { + printInfo(`Feather run ${target}`); + const result = runMobile({ + target, + projectDir: absGame, + configPath: opts.buildConfig, + outDir: opts.outDir, + clean: opts.clean, + device: opts.device, + adbReverse: opts.adbReverse, + port: opts.port ?? (typeof userConfig?.port === "number" ? userConfig.port : undefined), + }); + printStatus("success", `Launched ${target}`); + printKeyValues([ + ["Game", result.projectDir], + ["Artifact", result.artifact], + ["App ID", result.appId], + ["Device", result.device], + ["ADB reverse", result.adbReverse === undefined ? undefined : result.adbReverse ? `tcp:${result.port}` : "disabled"], + ]); + return; + } catch (err) { + fail((err as Error).message, { cause: err }); + } + } + let loveBin: string; try { loveBin = findLoveBinary(opts.love); @@ -60,7 +108,6 @@ export async function runCommand(gamePath: string | undefined, opts: RunOptions) fail((err as Error).message, { cause: err }); } - const userConfig = loadConfig(absGame, opts.config) ?? undefined; const sessionName = opts.sessionName ?? (userConfig?.sessionName as string | undefined); const shim = createShim({ diff --git a/cli/src/index.ts b/cli/src/index.ts index 4d5c4e84..b0d6a796 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -46,6 +46,13 @@ program program .command('run [game-path] [game-args...]') .description('Inject Feather into a Love2D game and run it') + .option('--target ', 'Run target: desktop, android, or ios', 'desktop') + .option('--device ', 'Android device serial or iOS simulator UDID') + .option('--build-config ', 'Path to feather.build.json for mobile run') + .option('--out-dir ', 'Build output directory for mobile run') + .option('--clean', 'Remove the output directory before mobile build') + .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)') @@ -54,6 +61,13 @@ program .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' | 'android' | 'ios' | undefined, + device: opts.device as string | undefined, + buildConfig: opts.buildConfig as string | undefined, + outDir: opts.outDir as string | undefined, + clean: opts.clean as boolean | undefined, + adbReverse: opts.adbReverse as boolean | undefined, + port: opts.port as number | undefined, sessionName: opts.sessionName as string | undefined, noPlugins: opts.plugins === false, config: opts.config as string | undefined, diff --git a/cli/src/lib/run/mobile.ts b/cli/src/lib/run/mobile.ts new file mode 100644 index 00000000..2c2e9d09 --- /dev/null +++ b/cli/src/lib/run/mobile.ts @@ -0,0 +1,133 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import type { BuildArtifact } from '../build/files.js'; +import { loadBuildConfig, type BuildTarget, type LoadBuildConfigOptions } from '../build/config.js'; +import { runBuild } from '../build/build.js'; +import { androidProductId, iosBundleIdentifier } from '../build/validation.js'; + +export type MobileRunTarget = Extract; + +export type MobileRunOptions = LoadBuildConfigOptions & { + target: MobileRunTarget; + device?: string; + clean?: boolean; + adbReverse?: boolean; + port?: number; +}; + +export type MobileRunResult = { + target: MobileRunTarget; + projectDir: string; + artifact: string; + appId: string; + device: string; + adbReverse?: boolean; + port?: number; +}; + +export function runMobile(options: MobileRunOptions): MobileRunResult { + const buildResult = runBuild({ + target: options.target, + projectDir: options.projectDir, + configPath: options.configPath, + outDir: options.outDir, + clean: options.clean, + allowUnsafe: true, + }); + + if (!buildResult.ok) { + throw new Error(buildResult.error); + } + + const config = loadBuildConfig({ + projectDir: buildResult.projectDir, + configPath: options.configPath, + outDir: options.outDir, + }); + + if (options.target === 'android') { + const apk = requireArtifact(buildResult.artifacts, 'apk', 'Android APK'); + const appId = androidProductId(config); + installAndLaunchAndroid({ + apk, + appId, + device: options.device, + adbReverse: options.adbReverse !== false, + port: options.port ?? 4004, + }); + return { + target: 'android', + projectDir: buildResult.projectDir, + artifact: apk, + appId, + device: options.device ?? 'default', + adbReverse: options.adbReverse !== false, + port: options.port ?? 4004, + }; + } + + const app = requireArtifact(buildResult.artifacts, 'app', 'iOS app'); + const appId = iosBundleIdentifier(config); + const device = options.device ?? 'booted'; + installAndLaunchIos({ app, appId, device }); + return { + target: 'ios', + projectDir: buildResult.projectDir, + artifact: app, + appId, + device, + }; +} + +function requireArtifact(artifacts: BuildArtifact[], type: string, label: string): string { + const artifact = artifacts.find((item) => item.type === type); + if (!artifact || !existsSync(artifact.path)) { + throw new Error(`${label} artifact was not found after build.`); + } + return artifact.path; +} + +function installAndLaunchAndroid(input: { + apk: string; + appId: string; + device?: string; + adbReverse: boolean; + port: number; +}): void { + runAdb(input.device, ['version'], 'adb not found. Run `feather doctor --build-target android` for setup guidance.'); + runAdb(input.device, ['install', '-r', input.apk], 'Android install failed.'); + if (input.adbReverse) { + runAdb( + input.device, + ['reverse', `tcp:${input.port}`, `tcp:${input.port}`], + 'Android adb reverse failed. Check USB debugging or pass --no-adb-reverse.', + ); + } + runAdb( + input.device, + ['shell', 'monkey', '-p', input.appId, '-c', 'android.intent.category.LAUNCHER', '1'], + 'Android launch failed.', + ); +} + +function runAdb(device: string | undefined, args: string[], message: string): void { + const fullArgs = device ? ['-s', device, ...args] : args; + const result = spawnSync('adb', fullArgs, { encoding: 'utf8' }); + if (result.error) throw new Error(`${message} ${(result.error as Error).message}`.trim()); + if (result.status !== 0) { + throw new Error(`${message} ${(result.stderr || result.stdout || '').trim()}`.trim()); + } +} + +function installAndLaunchIos(input: { app: string; appId: string; device: string }): void { + runXcrun(['simctl', 'install', input.device, input.app], 'iOS simulator install failed.'); + runXcrun(['simctl', 'launch', input.device, input.appId], 'iOS simulator launch failed.'); +} + +function runXcrun(args: string[], message: string): void { + const result = spawnSync('xcrun', args, { encoding: 'utf8' }); + if (result.error) throw new Error(`${message} xcrun not found. Run \`feather doctor --build-target ios\` for setup guidance.`); + if (result.status !== 0) { + throw new Error(`${message} ${(result.stderr || result.stdout || '').trim()}`.trim()); + } +} diff --git a/cli/test/commands.test.mjs b/cli/test/commands.test.mjs index 5f727459..313e71cc 100644 --- a/cli/test/commands.test.mjs +++ b/cli/test/commands.test.mjs @@ -17,7 +17,7 @@ import { writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; -import { delimiter, join, resolve } from 'node:path'; +import { delimiter, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const CLI = fileURLToPath(new URL('../dist/index.js', import.meta.url)); @@ -116,6 +116,40 @@ process.exit(${JSON.stringify(exitCode)}); return { fakePath, recordPath }; } +function writeFakeAdb(dir, options = {}) { + const recordPath = options.recordPath ?? join(dir, 'adb-record.json'); + const { binDir } = writeFakeCommand(dir, 'adb', ` +const fs = require('node:fs'); +const args = process.argv.slice(2); +const previous = fs.existsSync(${JSON.stringify(recordPath)}) + ? JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)}, 'utf8')) + : []; +previous.push({ args }); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify(previous, null, 2)); +if (${JSON.stringify(options.failInstall ?? false)} && args.includes('install')) { + console.error('install failed'); + process.exit(42); +} +process.exit(0); +`); + return { binDir, recordPath }; +} + +function writeFakeXcrun(dir) { + const recordPath = join(dir, 'xcrun-record.json'); + const { binDir } = writeFakeCommand(dir, 'xcrun', ` +const fs = require('node:fs'); +const args = process.argv.slice(2); +const previous = fs.existsSync(${JSON.stringify(recordPath)}) + ? JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)}, 'utf8')) + : []; +previous.push({ args }); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify(previous, null, 2)); +process.exit(0); +`); + return { binDir, recordPath }; +} + function writeFakeCommand(dir, name, script) { const binDir = join(dir, 'bin'); mkdirSync(binDir, { recursive: true }); @@ -353,6 +387,174 @@ test('run: fake love receives shim, args, env, and exit code is propagated', () assert.deepEqual(record.argv.slice(1), ['--level', '2']); }); +test('run --target android: builds, installs, sets adb reverse, launches, and supports device selection', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Run Android', + version: '1.0.0', + productId: 'com.example.runandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + writeFileSync(join(dir, 'feather.config.lua'), 'return { port = 4010 }\n'); + const { binDir, recordPath } = writeFakeAdb(dir); + + const result = run(['run', dir, '--target', 'android', '--device', 'emulator-5554'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('Launched android')); + assert.ok(outputOf(result).includes('com.example.runandroid')); + const records = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.deepEqual(records.map((entry) => entry.args), [ + ['-s', 'emulator-5554', 'version'], + ['-s', 'emulator-5554', 'install', '-r', join(dir, 'builds', 'run-android-1.0.0-android.apk')], + ['-s', 'emulator-5554', 'reverse', 'tcp:4010', 'tcp:4010'], + ['-s', 'emulator-5554', 'shell', 'monkey', '-p', 'com.example.runandroid', '-c', 'android.intent.category.LAUNCHER', '1'], + ]); +}); + +test('run --target android --no-adb-reverse skips reverse setup', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'No Reverse', + version: '1.0.0', + productId: 'com.example.noreverse', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + const { binDir, recordPath } = writeFakeAdb(dir); + + const result = run(['run', dir, '--target', 'android', '--no-adb-reverse'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + const records = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.equal(records.some((entry) => entry.args.includes('reverse')), false); +}); + +test('run --target android: missing adb produces doctor guidance', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Missing Adb', + version: '1.0.0', + productId: 'com.example.missingadb', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const result = run(['run', dir, '--target', 'android'], { + env: { + ...process.env, + NO_COLOR: '1', + FORCE_COLOR: '0', + PATH: dirname(process.execPath), + }, + }); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('adb not found')); + assert.ok(outputOf(result).includes('feather doctor --build-target android')); +}); + +test('run --target android: failed install exits with compact error', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Install Fail', + version: '1.0.0', + productId: 'com.example.installfail', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + const { binDir } = writeFakeAdb(dir, { failInstall: true }); + + const result = run(['run', dir, '--target', 'android'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Android install failed')); + assert.ok(outputOf(result).includes('install failed')); +}); + +test('run --target ios: builds app, installs simulator app, and launches bundle id', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Run iOS', + version: '1.0.0', + targets: { + ios: { + loveIosDir: 'love-ios', + bundleIdentifier: 'com.example.runios', + derivedDataPath: 'builds/ios-run-derived-data', + }, + }, + }); + const recordPath = join(dir, 'xcodebuild-run-record.json'); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` +if (process.argv.includes('-version')) { + console.log('Xcode 99.0'); + process.exit(0); +} +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +const derivedData = args[args.indexOf('-derivedDataPath') + 1]; +const app = path.join(derivedData, 'Build', 'Products', 'Debug-iphonesimulator', 'love-ios.app'); +fs.mkdirSync(app, { recursive: true }); +fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ argv: args }, null, 2)); +process.exit(0); +`); + const xcrun = writeFakeXcrun(dir); + + const result = run(['run', dir, '--target', 'ios', '--device', 'SIM-123'], { + env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }), + }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('Launched ios')); + const records = JSON.parse(readFileSync(xcrun.recordPath, 'utf8')); + assert.deepEqual(records.map((entry) => entry.args), [ + ['simctl', 'install', 'SIM-123', join(dir, 'builds', 'run-ios-1.0.0-ios.app')], + ['simctl', 'launch', 'SIM-123', 'com.example.runios'], + ]); +}); + +test('run --target ios: missing xcrun produces doctor guidance', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Missing Xcrun', + version: '1.0.0', + targets: { ios: { loveIosDir: 'love-ios', bundleIdentifier: 'com.example.missingxcrun' } }, + }); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +const derivedData = args[args.indexOf('-derivedDataPath') + 1]; +const app = path.join(derivedData, 'Build', 'Products', 'Debug-iphonesimulator', 'love-ios.app'); +fs.mkdirSync(app, { recursive: true }); +fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); +process.exit(0); +`); + + const result = run(['run', dir, '--target', 'ios'], { + env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1', PATH: `${binDir}${delimiter}${dirname(process.execPath)}` }), + }); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('xcrun not found')); + assert.ok(outputOf(result).includes('feather doctor --build-target ios')); +}); + +test('run mobile: forwarded game arguments are rejected', () => { + const dir = makeTmp(); + writeGame(dir); + + const result = run(['run', dir, '--target', 'android', '--', '--level', 'dev']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Mobile run does not support forwarded game arguments yet')); +}); + test('plugin list: missing plugin directory is a clean empty state', () => { const dir = makeTmp(); const result = run(['plugin', 'list', dir]); diff --git a/docs/index.md b/docs/index.md index 7aa114bc..0528ed60 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,7 +29,7 @@ Like Flipper or React DevTools, but for your game. Inspect logs, variables, perf ## Quick Start > [!IMPORTANT] -> For quick local desktop iteration, you can also use `feather run path/to/my-game` without changing game code. For mobile, handhelds, and remote devices like Android, iOS, or Steam Deck, use the embedded library from `feather init --mode auto` so the game carries Feather with it on the device. +> For quick local desktop iteration, use `feather run path/to/my-game` without changing game code. For Android and iOS dev loops, `feather run path/to/my-game --target android|ios` builds the configured native template, installs it, and launches it on a device or simulator. ### Option A — CLI injection (no game-side changes) @@ -37,12 +37,13 @@ Like Flipper or React DevTools, but for your game. Inspect logs, variables, perf npm install -g @kyonru/feather feather init path/to/my-game feather run path/to/my-game +feather run path/to/my-game --target android ``` Feather is injected automatically. No `require` needed in the game. See [CLI](cli.md). > [!NOTE] -> This is best for local desktop development where the CLI launches LÖVE directly. +> This is best for desktop development where the CLI launches LÖVE directly. Android/iOS mobile run requires the build template setup from `feather build vendor add mobile`. ### Option B — Managed in-game setup @@ -53,7 +54,7 @@ USE_DEBUGGER=1 love path/to/my-game ``` > [!IMPORTANT] -> Use this for mobile, handheld, and remote devices such as Android, iOS, Steam Deck, or a second computer. Those builds need the embedded Feather library because the CLI is not launching the game process on that device. +> Use this for handhelds, Steam Deck, or a second computer. Android/iOS can also use `feather run --target android|ios` once mobile build templates are configured. `feather init` creates `feather.config.lua`: diff --git a/docs/installation.md b/docs/installation.md index e3e1060e..78ac48d0 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -2,7 +2,7 @@ ## Option 1: CLI (Recommended — no game changes needed) -Install the `@kyonru/feather` npm package globally, then use `feather run` to inject Feather into any love2d game without touching its source: +Install the `@kyonru/feather` npm package globally, then use `feather run` to inject Feather into any desktop love2d game without touching its source: ```bash npm install -g @kyonru/feather @@ -12,14 +12,14 @@ feather run path/to/my-game A new session tab appears in the Feather desktop app automatically. No `require` calls, no `DEBUGGER:update(dt)` — the CLI handles everything. > [!NOTE] -> Use `feather run` for local desktop iteration where the CLI launches LÖVE directly. +> Use plain `feather run` for local desktop iteration where the CLI launches LÖVE directly. > [!IMPORTANT] -> For mobile, handheld, and remote devices such as Android, iOS, Steam Deck, or a second computer, embed Feather into the game instead. Those devices run the game themselves, so the CLI cannot inject Feather at launch time. +> For Android and iOS dev loops, `feather run path/to/my-game --target android|ios` can build the configured native template, install it, and launch it. For handhelds, Steam Deck, or another computer, embed Feather into the game instead. ### Embedded library for devices -Use auto mode for device builds: +Use auto mode for handheld or remote device builds: ```bash cd path/to/my-game @@ -45,7 +45,7 @@ USE_DEBUGGER=1 love . ``` > [!TIP] -> For Android over USB with ADB reverse, the default `host = "127.0.0.1"` can still work because ADB routes the device port back to your computer. For Wi-Fi devices, Steam Deck, or another computer, use the LAN IP shown in Feather Settings. +> `feather run --target android` runs ADB reverse by default, so `host = "127.0.0.1"` can still work over USB. For Wi-Fi devices, Steam Deck, or another computer, use the LAN IP shown in Feather Settings. See [CLI](cli.md) for all commands, flags, and `feather.config.lua` options. @@ -64,7 +64,16 @@ feather doctor path/to/my-game --upload-target itch `feather build` supports web, Android, iOS, Windows, macOS, Linux, and SteamOS artifacts. Web builds need a local love.js player directory configured in `feather.build.json`; Android/iOS builds need local love-android or LÖVE iOS template paths; desktop builds expect `love-release` on `PATH`. `feather upload itch` expects Butler on `PATH` and either `BUTLER_API_KEY` in CI or a local `butler login` session. -For mobile setup, run `feather build vendor add mobile --dir path/to/my-game` to fetch official LÖVE template checkouts into `vendor/` and update `feather.build.json`. Then run `feather doctor path/to/my-game --build-target android` or `--build-target ios`. Doctor checks the native template path, product/bundle id, local build tools, and the common environment variables before the build command stages or writes artifacts. Vendor fetching does not install Android SDK, JDK, Xcode, or signing assets. +For mobile setup, run `feather build vendor add mobile --dir path/to/my-game` to fetch official LÖVE template checkouts into `vendor/` and update `feather.build.json`. Then run `feather doctor path/to/my-game --build-target android` or `--build-target ios`. Doctor checks the native template path, product/bundle id, local build tools, and the common environment variables before the build or run command stages or writes artifacts. Vendor fetching does not install Android SDK, JDK, Xcode, or signing assets. + +Mobile dev run examples: + +```bash +feather run path/to/my-game --target android +feather run path/to/my-game --target android --device emulator-5554 +feather run path/to/my-game --target ios +feather run path/to/my-game --target ios --device +``` Use `feather build android --release` for Android AAB/APK release artifacts and `feather build ios --release` for iOS archive/IPA artifacts. Keep signing file paths in `feather.build.json`, but put passwords in environment variables referenced by the config so secrets are not committed or printed. From 514215652edd2cc330d80dae87fd3215d3f7ef51 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 17:39:25 -0400 Subject: [PATCH 46/68] cli: improve mobile run with cache --- .gitignore | 2 + cli/README.md | 35 +- cli/src/commands/build.ts | 15 +- cli/src/commands/run.ts | 105 ++++- cli/src/index.ts | 46 +- cli/src/lib/build/android.ts | 119 ++++- cli/src/lib/build/build.ts | 52 ++- cli/src/lib/build/config.ts | 3 +- cli/src/lib/build/debug-stage.ts | 152 +++++++ cli/src/lib/build/ios.ts | 80 +++- cli/src/lib/build/native.ts | 112 ++++- cli/src/lib/run/mobile.ts | 60 ++- cli/src/lib/shim.ts | 26 +- cli/test/commands.test.mjs | 574 ++++++++++++++++++++++++- docs/configuration.md | 1 + docs/installation.md | 3 +- src-lua/feather/auto.lua | 2 +- src-lua/feather/core/debug_overlay.lua | 139 ++++++ src-lua/feather/init.lua | 3 + src-lua/feather/plugin_manager.lua | 10 + src-lua/manifest.txt | 1 + 21 files changed, 1477 insertions(+), 63 deletions(-) create mode 100644 cli/src/lib/build/debug-stage.ts create mode 100644 src-lua/feather/core/debug_overlay.lua diff --git a/.gitignore b/.gitignore index 1621a575..9aa7c03a 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,5 @@ docs/_site/ playwright-report/ test-results/ .env +builds/ +vendor/ diff --git a/cli/README.md b/cli/README.md index 40723c81..f6ce90f1 100644 --- a/cli/README.md +++ b/cli/README.md @@ -76,11 +76,15 @@ feather run . # run game in current directory feather run path/to/my-game # run from an explicit path feather run . --session-name "RPG" # custom name in the desktop session tab feather run . --no-plugins # feather core only, no plugins +feather run . --no-debugger # launch without Feather injection feather run . --love /usr/bin/love # override love2d binary feather run . --plugins-dir ./my-plugins # use a custom plugins directory feather run . -- --level dev # pass args through to the game feather run . --target android # build, install, adb reverse, and launch Android feather run . --target android --device emulator-5554 +feather run . --target android --verbose # show Gradle/ADB commands and output +feather run . --target android --no-cache # force a fresh native workspace +feather run . --target android --no-debugger feather run . --target ios # build, install, and launch on the booted simulator feather run . --target ios --device ``` @@ -94,6 +98,8 @@ When `game-path` is omitted in an interactive terminal, Feather opens an Ink wor | `--love ` | Path to the love2d binary. Defaults to auto-detect (see [Binary detection](#binary-detection)). | | `--session-name ` | Custom session name shown in the Feather desktop app. | | `--no-plugins` | Load feather core only — no plugins registered. | +| `--no-debugger` | Run without Feather debugger injection. Desktop runs the game directly; mobile skips connection setup and builds raw source. | +| `--disable-debugger` | Alias for `--no-debugger`. | | `--config ` | Explicit path to a `feather.config.lua` file. | | `--feather-path ` | Use a local feather install instead of the CLI's bundled copy. | | `--plugins-dir ` | Use a custom plugins directory instead of the CLI's bundled plugins. | @@ -102,12 +108,14 @@ When `game-path` is omitted in an interactive terminal, Feather opens an Ink wor | `--build-config ` | Path to `feather.build.json` for mobile run. | | `--out-dir ` | Build output directory for mobile run. | | `--clean` | Remove the output directory before the mobile build. | +| `--no-cache` | Disable Android/iOS dev native build cache for this run. | +| `--verbose` | Show Android/iOS build, install, and launch commands plus native tool output. | | `--no-adb-reverse` | Skip Android `adb reverse` setup. | | `--port ` | Port used for Android `adb reverse`; defaults to `feather.config.lua` `port` or `4004`. | Use `--` to separate Feather CLI options from arguments intended for the LÖVE game. Everything after `--` is passed to `love` after the generated shim path. -Mobile run is dev-only in V1 and does not forward game arguments. Android requires `adb`, a configured `targets.android.loveAndroidDir`, and USB debugging or an emulator. iOS requires macOS, Xcode, a configured `targets.ios.loveIosDir`, and a booted simulator. +Mobile run is dev-only in V1 and does not forward game arguments. By default it embeds the bundled Feather runtime, bundled plugins, and the selected `feather.config.lua` into the temporary `.love` archive before installing. Android requires `adb`, a configured `targets.android.loveAndroidDir`, and USB debugging or an emulator. iOS requires macOS, Xcode, a configured `targets.ios.loveIosDir`, and a booted simulator. **Project config file:** @@ -354,8 +362,13 @@ Build a LÖVE game into local artifacts. V1 supports `web`, `android`, `ios`, `w feather build web --dir path/to/my-game feather build vendor add mobile --dir path/to/my-game feather build android --dir path/to/my-game +feather build android --dir path/to/my-game --verbose +feather build android --dir path/to/my-game --no-cache +feather build android --dir path/to/my-game --runtime-config path/to/feather.config.lua +feather build android --dir path/to/my-game --no-debugger feather build android --dir path/to/my-game --release feather build ios --dir path/to/my-game +feather build ios --dir path/to/my-game --verbose feather build ios --dir path/to/my-game --release feather build linux --dir path/to/my-game feather build steamos --dir path/to/my-game --json @@ -442,8 +455,11 @@ Build behavior: - packages `web` by copying the configured love.js player, adding `game.love`, patching the page title/game URL, and creating an HTML zip - packages `android` by copying a configured love-android checkout, embedding `game.love`, patching obvious app metadata, running Gradle, and copying the APK - packages `ios` on macOS by copying a configured LÖVE iOS source tree, embedding `game.love`, running `xcodebuild`, and copying the `.app` +- embeds Feather runtime/config into Android/iOS dev builds by default; use `--no-debugger` to build raw source, and note that `--release` never auto-embeds Feather +- caches Android/iOS dev native workspaces under `/.feather-cache` so Gradle/Xcode incremental state survives between builds - `--release` on Android produces `.aab` and `.apk` artifacts; signing passwords are read from environment variables named in config - `--release` on iOS produces `.xcarchive` and `.ipa` artifacts through `xcodebuild archive` and `-exportArchive` +- `--verbose` on Android/iOS shows staging steps, native workspace paths, Gradle/Xcode commands, and captured native tool output; JSON output stays decoration-free - delegates desktop targets to `love-release`; `steamos` uses the Linux packaging path with Steam-friendly target naming **Options:** @@ -460,6 +476,10 @@ Build behavior: | `--json` | Print machine-readable output only. | | `--allow-unsafe` | Skip the production safety preflight for intentional dev builds. | | `--release` | Build Android/iOS release artifacts instead of dev artifacts. | +| `--no-cache` | Disable Android/iOS dev native build cache for this build. | +| `--no-debugger` | Build Android/iOS dev artifacts without embedding Feather. | +| `--runtime-config` | Path to `feather.config.lua` for Android/iOS dev embedding. | +| `--verbose` | Show Android/iOS native build commands and tool output. | Run `feather doctor --build-target ` to see missing local dependencies and exact setup guidance before building. @@ -468,6 +488,8 @@ Mobile build notes: - Android builds expect `targets.android.loveAndroidDir` to point at a local love-android checkout with `gradlew`. - iOS builds expect `targets.ios.loveIosDir` to point at a local LÖVE iOS source tree with `platform/xcode/love.xcodeproj`. - `feather build vendor add mobile` fetches those template checkouts, but it does not install Android SDK, JDK, Xcode, or signing assets. +- Dev Android/iOS builds reuse cached copied native templates by default. Use `--no-cache` for a fresh temporary workspace, or `--clean` to remove both artifacts and cached state in the output directory. +- Release Android/iOS builds use fresh native workspaces by default for reproducibility. - `feather doctor --build-target android --release` validates product id, Gradle wrapper, JDK, Android SDK, and signing env setup. - `feather doctor --build-target ios --release` validates bundle id, macOS/Xcode setup, template path, export options, and signing hints. - Play Console and App Store upload are not included in this pass. @@ -625,10 +647,19 @@ return { -- Connect to a remote desktop app (e.g. on another machine) -- host = "192.168.1.42", + + -- Small in-game badge shown while Feather is loaded. + debugOverlay = { + enabled = true, + visible = true, + hideKey = "f12", + touchToggle = true, + corner = "top-right", + }, } ``` -All `feather.auto.setup()` options are supported. Command-line flags (`--session-name`, etc.) take precedence over the config file. +All `feather.auto.setup()` options are supported. Command-line flags (`--session-name`, etc.) take precedence over the config file. The debug overlay is visible by default when Feather is active; press `F12` or double-tap the top-right corner to temporarily hide/show it. --- diff --git a/cli/src/commands/build.ts b/cli/src/commands/build.ts index a90dd858..cab23324 100644 --- a/cli/src/commands/build.ts +++ b/cli/src/commands/build.ts @@ -27,6 +27,10 @@ export type BuildCommandOptions = { json?: boolean; allowUnsafe?: boolean; release?: boolean; + noCache?: boolean; + debugger?: boolean; + runtimeConfig?: string; + verbose?: boolean; }; export async function buildCommand(targetValue: string, opts: BuildCommandOptions = {}): Promise { @@ -34,7 +38,11 @@ export async function buildCommand(targetValue: string, opts: BuildCommandOption fail(`Unknown build target: ${targetValue}`, { details: [`Available: ${buildTargets.join(', ')}`] }); } const target: BuildTarget = targetValue; - const spinner = opts.json || opts.dryRun ? null : createSpinner(`Building ${target}…`).start(); + const verbose = Boolean(opts.verbose && !opts.json && !opts.dryRun); + const spinner = opts.json || opts.dryRun || verbose ? null : createSpinner(`Building ${target}…`).start(); + if (verbose) { + printStatus('info', `Building ${style.heading(target)} in verbose mode`); + } const result = runBuild({ target, projectDir: opts.dir, @@ -46,6 +54,11 @@ export async function buildCommand(targetValue: string, opts: BuildCommandOption dryRun: opts.dryRun, allowUnsafe: opts.allowUnsafe, release: opts.release, + noCache: opts.noCache, + debugger: opts.debugger, + runtimeConfigPath: opts.runtimeConfig, + verbose, + log: verbose ? printMuted : undefined, }); if (!result.ok) { diff --git a/cli/src/commands/run.ts b/cli/src/commands/run.ts index e63c1d41..e4b6ea0a 100644 --- a/cli/src/commands/run.ts +++ b/cli/src/commands/run.ts @@ -1,6 +1,6 @@ import { spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; -import { resolve } from "node:path"; +import { existsSync, realpathSync } from "node:fs"; +import { basename, dirname, join, relative, resolve } from "node:path"; import { findLoveBinary } from "../lib/love.js"; import { createShim, shimEnv } from "../lib/shim.js"; import { loadConfig } from "../lib/config.js"; @@ -8,11 +8,13 @@ import { chooseRunWorkflow } from "../ui/run-workflow.js"; import { fail } from "../lib/command.js"; import { printInfo, printKeyValues, printMuted, printStatus } from "../lib/output.js"; import { runMobile, type MobileRunTarget } from "../lib/run/mobile.js"; +import { isPathInside } from "../lib/path-safety.js"; export interface RunOptions { love?: string; sessionName?: string; noPlugins?: boolean; + debugger?: boolean; config?: string; featherPath?: string; pluginsDir?: string; @@ -22,12 +24,15 @@ export interface RunOptions { buildConfig?: string; outDir?: string; clean?: boolean; + noCache?: boolean; + verbose?: boolean; adbReverse?: boolean; port?: number; } export async function runCommand(gamePath: string | undefined, opts: RunOptions): Promise { const target = opts.target ?? "desktop"; + const debuggerEnabled = opts.debugger !== false; if (!["desktop", "android", "ios"].includes(target)) { fail("Run target must be one of: desktop, android, ios."); } @@ -69,27 +74,45 @@ export async function runCommand(gamePath: string | undefined, opts: RunOptions) fail(`No main.lua found in: ${absGame}`); } + const inferredConfig = inferConfigArg(opts.config, opts.gameArgs); + if (inferredConfig) { + opts = { + ...opts, + config: inferredConfig.config, + gameArgs: inferredConfig.gameArgs, + }; + } + const userConfig = loadConfig(absGame, opts.config) ?? undefined; if (target === "android" || target === "ios") { if ((opts.gameArgs?.length ?? 0) > 0) { fail("Mobile run does not support forwarded game arguments yet."); } + const buildContext = resolveMobileBuildContext(absGame, opts.buildConfig); try { printInfo(`Feather run ${target}`); const result = runMobile({ target, - projectDir: absGame, - configPath: opts.buildConfig, + projectDir: buildContext.projectDir, + configPath: buildContext.configPath, + sourceDir: buildContext.sourceDir, outDir: opts.outDir, clean: opts.clean, + noCache: opts.noCache, + debugger: debuggerEnabled, + runtimeConfigPath: opts.config, + noPlugins: opts.noPlugins, + featherOverride: opts.featherPath, + pluginsOverride: opts.pluginsDir, + verbose: opts.verbose, device: opts.device, - adbReverse: opts.adbReverse, + adbReverse: debuggerEnabled ? opts.adbReverse : false, port: opts.port ?? (typeof userConfig?.port === "number" ? userConfig.port : undefined), }); printStatus("success", `Launched ${target}`); printKeyValues([ - ["Game", result.projectDir], + ["Game", absGame], ["Artifact", result.artifact], ["App ID", result.appId], ["Device", result.device], @@ -110,6 +133,26 @@ export async function runCommand(gamePath: string | undefined, opts: RunOptions) const sessionName = opts.sessionName ?? (userConfig?.sessionName as string | undefined); + if (!debuggerEnabled) { + printInfo("Feather run"); + printKeyValues([ + ["Game", absGame], + ["Debugger", "disabled"], + ["Args", opts.gameArgs?.join(" ")], + ]); + + const result = spawnSync(loveBin, [absGame, ...(opts.gameArgs ?? [])], { + stdio: "inherit", + env: process.env, + }); + + if (result.error) { + fail(`Failed to launch love: ${result.error.message}`, { cause: result.error }); + } + + return result.status ?? 0; + } + const shim = createShim({ gamePath: absGame, sessionName, @@ -141,3 +184,53 @@ export async function runCommand(gamePath: string | undefined, opts: RunOptions) return result.status ?? 0; } + +function resolveMobileBuildContext(absGame: string, buildConfig: string | undefined): { + projectDir: string; + configPath?: string; + sourceDir?: string; +} { + if (buildConfig) { + const configPath = resolve(buildConfig); + const configDir = dirname(configPath); + const sourceDir = relativeInside(configDir, absGame); + if (sourceDir) { + return { + projectDir: realpathSync(configDir), + configPath, + sourceDir, + }; + } + return { projectDir: absGame, configPath }; + } + + if (existsSync(join(absGame, "feather.build.json"))) { + return { projectDir: absGame }; + } + + const cwd = resolve(process.cwd()); + const sourceDir = relativeInside(cwd, absGame); + if (existsSync(join(cwd, "feather.build.json")) && sourceDir) { + return { + projectDir: realpathSync(cwd), + sourceDir, + }; + } + + return { projectDir: absGame }; +} + +function relativeInside(root: string, target: string): string | null { + const rootReal = realpathSync(root); + const targetReal = realpathSync(target); + if (!isPathInside(rootReal, targetReal)) return null; + return relative(rootReal, targetReal) || "."; +} + +function inferConfigArg(config: string | undefined, gameArgs: string[] | undefined): { config: string; gameArgs: string[] } | null { + if (config || !gameArgs || gameArgs.length !== 1) return null; + const candidate = gameArgs[0]; + if (!["feather.config.lua", ".featherrc.lua"].includes(basename(candidate))) return null; + if (!existsSync(resolve(candidate))) return null; + return { config: candidate, gameArgs: [] }; +} diff --git a/cli/src/index.ts b/cli/src/index.ts index b0d6a796..bc49d149 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -51,12 +51,18 @@ program .option('--build-config ', 'Path to feather.build.json for mobile run') .option('--out-dir ', 'Build output directory for mobile run') .option('--clean', 'Remove the output directory before mobile build') + .option('--no-cache', 'Disable Android/iOS dev native build cache') + .option('--verbose', 'Show Android/iOS build commands and native tool output') .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, { @@ -66,11 +72,14 @@ program 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, adbReverse: opts.adbReverse as boolean | undefined, port: opts.port as number | undefined, sessionName: opts.sessionName as string | undefined, noPlugins: opts.plugins === false, - config: opts.config as string | undefined, + debugger: opts.debugger !== false && !opts.disableDebugger, + config: (opts.config ?? opts.configPath) as string | undefined, featherPath: opts.featherPath as string | undefined, pluginsDir: opts.pluginsDir as string | undefined, gameArgs, @@ -155,6 +164,10 @@ function addBuildTargetCommand(target: string): void { .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') @@ -163,9 +176,18 @@ function addBuildTargetCommand(target: string): void { .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: opts.config 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, @@ -174,9 +196,29 @@ function addBuildTargetCommand(target: string): void { 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 ['web', 'android', 'ios', 'windows', 'macos', 'linux', 'steamos']) { addBuildTargetCommand(target); } diff --git a/cli/src/lib/build/android.ts b/cli/src/lib/build/android.ts index 1e5c2eb2..0cfa062e 100644 --- a/cli/src/lib/build/android.ts +++ b/cli/src/lib/build/android.ts @@ -8,32 +8,74 @@ import { createNativeWorkspace, escapeXml, findFirstPath, + logNativeCommand, + logNativeOutput, + logNativeStep, patchTextFile, resolveWorkspacePath, + type NativeCacheInfo, + type NativeBuildLogger, } from './native.js'; import { androidProductId } from './validation.js'; export type AndroidBuildModeOptions = { release?: boolean; + cache?: boolean; + debuggerSignature?: string; + verbose?: boolean; + log?: NativeBuildLogger; + onCache?: (cache: NativeCacheInfo) => void; }; export function buildAndroid(config: ResolvedBuildConfig, stageDir: string, options: AndroidBuildModeOptions = {}): BuildArtifact[] { const androidConfig = config.targets.android ?? {}; const loveAndroidDir = androidConfig.loveAndroidDir ? resolve(config.projectDir, androidConfig.loveAndroidDir) : ''; - if (!loveAndroidDir || !existsSync(loveAndroidDir)) { + if (!androidConfig.loveAndroidDir) { throw new Error('Android build requires targets.android.loveAndroidDir in feather.build.json.'); } + if (!existsSync(loveAndroidDir)) { + throw new Error(`Android template not found at ${loveAndroidDir}. Run \`feather build vendor add android --dir ${config.projectDir}\` or update targets.android.loveAndroidDir in feather.build.json.`); + } const base = artifactBaseName(config); const lovePath = writeLoveArchive(stageDir, config.outDir, base); - const workspace = createNativeWorkspace('feather-android-', loveAndroidDir, 'love-android'); + logNativeStep(options.log, `Created .love archive: ${lovePath}`); + const gradleTask = androidConfig.gradleTask + ?? (androidConfig.recordAudio ? 'assembleEmbedRecordDebug' : 'assembleEmbedNoRecordDebug'); + if (options.release || options.cache === false) { + logNativeStep(options.log, `Build cache: ${options.release ? 'disabled for release build' : 'disabled by --no-cache'}`); + } + logNativeStep(options.log, `Android template: ${loveAndroidDir}`); + const workspace = createNativeWorkspace('feather-android-', loveAndroidDir, 'love-android', { + enabled: !options.release && options.cache !== false, + target: 'android', + outDir: config.outDir, + log: options.log, + requiredPaths: [process.platform === 'win32' ? 'gradlew.bat' : 'gradlew', 'app/build.gradle'], + keyParts: { + productId: androidProductId(config), + displayName: androidConfig.displayName ?? config.name, + orientation: androidConfig.orientation ?? 'landscape', + recordAudio: Boolean(androidConfig.recordAudio), + versionCode: androidConfig.versionCode ?? 1, + versionName: androidConfig.versionName ?? config.version, + gradleTask, + artifactPath: androidConfig.artifactPath, + debuggerSignature: options.debuggerSignature, + }, + }); + options.onCache?.(workspace.cache); + logNativeStep(options.log, `Android workspace: ${workspace.dir}`); try { const embeddedLovePath = join(workspace.dir, 'app', 'src', 'embed', 'assets', 'game.love'); mkdirSync(dirname(embeddedLovePath), { recursive: true }); cpSync(lovePath, embeddedLovePath, { force: true }); + logNativeStep(options.log, `Embedded game.love: ${embeddedLovePath}`); patchAndroidProject(config, workspace.dir); + patchAndroidEmbeddedGameLoader(workspace.dir); + logNativeStep(options.log, 'Patched Android metadata'); const gradleCommand = process.platform === 'win32' ? join(workspace.dir, 'gradlew.bat') : join(workspace.dir, 'gradlew'); if (!existsSync(gradleCommand)) { @@ -45,8 +87,8 @@ export function buildAndroid(config: ResolvedBuildConfig, stageDir: string, opti ?? (androidConfig.recordAudio ? 'bundleEmbedRecordRelease' : 'bundleEmbedNoRecordRelease'); const apkTask = androidConfig.release?.apkTask ?? (androidConfig.recordAudio ? 'assembleEmbedRecordRelease' : 'assembleEmbedNoRecordRelease'); - runGradleTask(gradleCommand, workspace.dir, bundleTask); - runGradleTask(gradleCommand, workspace.dir, apkTask); + runGradleTask(gradleCommand, workspace.dir, bundleTask, options.log); + runGradleTask(gradleCommand, workspace.dir, apkTask, options.log); const aabSource = androidConfig.release?.bundleArtifactPath ? resolveWorkspacePath(workspace.dir, androidConfig.release.bundleArtifactPath, 'Android bundle artifact path') @@ -64,6 +106,8 @@ export function buildAndroid(config: ResolvedBuildConfig, stageDir: string, opti const apkPath = join(config.outDir, `${base}-android.apk`); cpSync(aabSource, aabPath, { force: true }); cpSync(apkSource, apkPath, { force: true }); + logNativeStep(options.log, `Copied AAB artifact: ${aabPath}`); + logNativeStep(options.log, `Copied APK artifact: ${apkPath}`); return [ { target: 'android', type: 'love', path: lovePath }, { target: 'android', type: 'aab', path: aabPath }, @@ -71,10 +115,9 @@ export function buildAndroid(config: ResolvedBuildConfig, stageDir: string, opti ]; } - const gradleTask = androidConfig.gradleTask - ?? (androidConfig.recordAudio ? 'assembleEmbedRecordDebug' : 'assembleEmbedNoRecordDebug'); - runGradleTask(gradleCommand, workspace.dir, gradleTask); + runGradleTask(gradleCommand, workspace.dir, gradleTask, options.log); const apkPath = copyAndroidApkArtifact(config, workspace.dir, base); + logNativeStep(options.log, `Copied APK artifact: ${apkPath}`); return [ { target: 'android', type: 'love', path: lovePath }, { target: 'android', type: 'apk', path: apkPath }, @@ -84,15 +127,43 @@ export function buildAndroid(config: ResolvedBuildConfig, stageDir: string, opti } } -function runGradleTask(gradleCommand: string, workDir: string, task: string): void { +function patchAndroidEmbeddedGameLoader(workDir: string): void { + const gameActivityPath = join(workDir, 'love', 'src', 'main', 'java', 'org', 'love2d', 'android', 'GameActivity.java'); + patchTextFile(gameActivityPath, (source) => { + if (source.includes('Feather: forcing embedded game.love from assets')) return source; + return source.replace( + /embed\s*=\s*getResources\(\)\.getBoolean\(R\.bool\.embed\);/, + [ + 'embed = getResources().getBoolean(R.bool.embed);', + ' try {', + ' InputStream featherGameStream = getAssets().open("game.love");', + ' featherGameStream.close();', + ' if (!embed) {', + ' Log.d("GameActivity", "Feather: forcing embedded game.love from assets.");', + ' }', + ' embed = true;', + ' needToCopyGameInArchive = true;', + ' } catch (IOException ignored) {', + " // No embedded game.love asset; keep the template's default mode.", + ' }', + ].join('\n'), + ); + }); +} + +function runGradleTask(gradleCommand: string, workDir: string, task: string, log?: NativeBuildLogger): void { + logNativeCommand(log, gradleCommand, [task], workDir); + const streamOutput = Boolean(log); const result = spawnSync(gradleCommand, [task], { cwd: workDir, - encoding: 'utf8', + encoding: streamOutput ? undefined : 'utf8', shell: process.platform === 'win32', + stdio: streamOutput ? 'inherit' : 'pipe', }); + if (!streamOutput) logNativeOutput(log, result.stdout, result.stderr); if (result.error) throw new Error(`Gradle wrapper failed to start: ${result.error.message}`); if (result.status !== 0) { - throw new Error((result.stderr || result.stdout || `Gradle task ${task} failed`).trim()); + throw new Error((result.stderr || result.stdout || `Gradle task ${task} failed with exit code ${result.status ?? 'unknown'}`).toString().trim()); } } @@ -157,6 +228,19 @@ export function patchAndroidProject(config: ResolvedBuildConfig, workDir: string const displayName = androidConfig.displayName ?? config.name; const orientation = androidConfig.orientation ?? 'landscape'; + patchTextFile(join(workDir, 'gradle.properties'), (source) => { + let next = source + .split('\n') + .filter((line) => !/^app\.name\s*=/.test(line) && !/^app\.name_byte_array\s*=/.test(line)) + .join('\n'); + next = setGradleProperty(next, 'app.name_byte_array', utf8ByteArray(displayName)); + next = setGradleProperty(next, 'app.application_id', productId); + next = setGradleProperty(next, 'app.orientation', orientation); + next = setGradleProperty(next, 'app.version_code', String(versionCode)); + next = setGradleProperty(next, 'app.version_name', versionName); + return next; + }); + for (const file of ['app/build.gradle', 'app/build.gradle.kts', 'build.gradle', 'build.gradle.kts']) { patchTextFile(join(workDir, file), (source) => source .replace(/applicationId\s*(?:=)?\s*["'][^"']+["']/g, (match) => assignmentValue(match, 'applicationId', productId)) @@ -197,3 +281,18 @@ export function patchAndroidProject(config: ResolvedBuildConfig, workDir: string }); } } + +function setGradleProperty(source: string, key: string, value: string): string { + const line = `${key}=${value}`; + const pattern = new RegExp(`^${escapeRegExp(key)}=.*$`, 'm'); + if (pattern.test(source)) return source.replace(pattern, line); + return `${source.replace(/\s*$/, '')}\n${line}\n`; +} + +function utf8ByteArray(value: string): string { + return [...Buffer.from(value, 'utf8')].join(','); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/cli/src/lib/build/build.ts b/cli/src/lib/build/build.ts index 57e5fdf7..f7be0e48 100644 --- a/cli/src/lib/build/build.ts +++ b/cli/src/lib/build/build.ts @@ -24,6 +24,8 @@ import { buildAndroid } from './android.js'; import { buildIos } from './ios.js'; import { buildDesktop } from './desktop.js'; import { assertBuildConfigValidForTarget } from './validation.js'; +import type { NativeBuildLogger, NativeCacheInfo } from './native.js'; +import { embedMobileDebuggerStage } from './debug-stage.js'; export type BuildOptions = LoadBuildConfigOptions & { target: BuildTarget; @@ -31,6 +33,14 @@ export type BuildOptions = LoadBuildConfigOptions & { dryRun?: boolean; allowUnsafe?: boolean; release?: boolean; + noCache?: boolean; + debugger?: boolean; + runtimeConfigPath?: string; + noPlugins?: boolean; + featherOverride?: string; + pluginsOverride?: string; + verbose?: boolean; + log?: NativeBuildLogger; }; export type BuildResult = { @@ -45,6 +55,7 @@ export type BuildResult = { files: string[]; manifestPath?: string; command?: string[]; + cache?: NativeCacheInfo; } | { ok: false; error: string; @@ -108,17 +119,53 @@ export function runBuild(options: BuildOptions): BuildResult { assertNoSymlinkEscape(config.projectDir, config.outDir, 'Build output directory'); if (options.dryRun) return planBuild(options); + const log = options.verbose ? options.log : undefined; + const mobileDevBuild = (options.target === 'android' || options.target === 'ios') && !options.release; + if (options.clean && mobileDevBuild && !options.noCache) { + log?.('Build cache: reset by --clean'); + } if (options.clean) rmSync(config.outDir, { recursive: true, force: true }); mkdirSync(config.outDir, { recursive: true }); const staged = stageProject(config); + log?.(`Staged ${staged.files.length} files from ${config.sourceDir}`); try { + let cache: NativeCacheInfo | undefined; + const mobileDevBuild = (options.target === 'android' || options.target === 'ios') && !options.release; + const debugStage = mobileDevBuild + ? embedMobileDebuggerStage(config, staged.dir, { + enabled: options.debugger !== false, + runtimeConfigPath: options.runtimeConfigPath, + noPlugins: options.noPlugins, + featherOverride: options.featherOverride, + pluginsOverride: options.pluginsOverride, + }) + : undefined; + if (debugStage?.enabled) { + log?.(`Embedded Feather debugger runtime${debugStage.configPath ? ` with ${debugStage.configPath}` : ' with generated dev config'}`); + } else if (mobileDevBuild) { + log?.('Feather debugger embedding: disabled'); + } const artifacts = options.target === 'web' ? buildWeb(config, staged.dir) : options.target === 'android' - ? buildAndroid(config, staged.dir, { release: Boolean(options.release) }) + ? buildAndroid(config, staged.dir, { + release: Boolean(options.release), + cache: !options.noCache, + debuggerSignature: debugStage?.signature, + verbose: options.verbose, + log, + onCache: (info) => { cache = info; }, + }) : options.target === 'ios' - ? buildIos(config, staged.dir, { release: Boolean(options.release) }) + ? buildIos(config, staged.dir, { + release: Boolean(options.release), + cache: !options.noCache, + debuggerSignature: debugStage?.signature, + verbose: options.verbose, + log, + onCache: (info) => { cache = info; }, + }) : buildDesktop(config, options.target, staged.dir); const manifest: BuildManifest = { name: config.name, @@ -139,6 +186,7 @@ export function runBuild(options: BuildOptions): BuildResult { files: staged.files, artifacts, manifestPath: latestManifestPath(config.outDir), + cache, }; } finally { staged.cleanup(); diff --git a/cli/src/lib/build/config.ts b/cli/src/lib/build/config.ts index 9fa2b703..e0097b2f 100644 --- a/cli/src/lib/build/config.ts +++ b/cli/src/lib/build/config.ts @@ -115,6 +115,7 @@ export type ResolvedBuildConfig = { export type LoadBuildConfigOptions = { projectDir?: string; configPath?: string; + sourceDir?: string; outDir?: string; name?: string; version?: string; @@ -185,7 +186,7 @@ export function loadBuildConfig(options: LoadBuildConfigOptions = {}): ResolvedB if (!name) throw new Error('Build name must not be empty.'); if (!version) throw new Error('Build version must not be empty.'); - const sourceDir = projectPath(projectDir, raw.sourceDir, '.', 'Build source directory'); + const sourceDir = projectPath(projectDir, options.sourceDir ?? raw.sourceDir, '.', 'Build source directory'); const outDir = projectPath(projectDir, options.outDir ?? raw.outDir, 'builds', 'Build output directory'); const outRel = isPathInside(projectDir, outDir) ? outDirRelative(projectDir, outDir) : ''; const includeRuntime = Boolean(raw.includeRuntime); diff --git a/cli/src/lib/build/debug-stage.ts b/cli/src/lib/build/debug-stage.ts new file mode 100644 index 00000000..5a5d2b14 --- /dev/null +++ b/cli/src/lib/build/debug-stage.ts @@ -0,0 +1,152 @@ +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import type { ResolvedBuildConfig } from './config.js'; +import { copyDirectory } from './files.js'; +import { featherRoot, pluginsRoot } from '../shim.js'; + +export type MobileDebugStageOptions = { + enabled?: boolean; + runtimeConfigPath?: string; + noPlugins?: boolean; + featherOverride?: string; + pluginsOverride?: string; +}; + +export type MobileDebugStageInfo = { + enabled: boolean; + configPath?: string; + generatedConfig: boolean; + signature: string; +}; + +export function embedMobileDebuggerStage( + config: ResolvedBuildConfig, + stageDir: string, + options: MobileDebugStageOptions = {}, +): MobileDebugStageInfo { + if (options.enabled === false) { + return signature({ enabled: false, generatedConfig: false }); + } + + const originalMain = join(stageDir, 'main.lua'); + if (!existsSync(originalMain)) { + throw new Error('Mobile debugger embedding requires main.lua in the staged game.'); + } + const relocatedMain = join(stageDir, '.feather-main.lua'); + if (existsSync(relocatedMain)) { + throw new Error('Mobile debugger embedding cannot use .feather-main.lua because that file already exists in the game.'); + } + + const featherDir = featherRoot(options.featherOverride); + if (!existsSync(join(featherDir, 'auto.lua'))) { + throw new Error(`Feather runtime not found at ${featherDir}.`); + } + + const runtimeConfig = resolveRuntimeConfig(config, options.runtimeConfigPath); + const generatedConfig = runtimeConfig + ? readFileSync(runtimeConfig, 'utf8') + : generatedMobileConfig(config.name); + + rmSync(join(stageDir, 'feather'), { recursive: true, force: true }); + rmSync(join(stageDir, 'plugins'), { recursive: true, force: true }); + renameSync(originalMain, relocatedMain); + copyDirectory(featherDir, join(stageDir, 'feather')); + + const pluginDir = options.pluginsOverride + ? resolve(options.pluginsOverride) + : pluginsRoot(featherDir, options.featherOverride); + + if (!options.noPlugins) { + if (existsSync(pluginDir)) { + copyDirectory(pluginDir, join(stageDir, 'plugins')); + } + } + + writeFileSync(join(stageDir, 'feather.config.lua'), generatedConfig); + writeFileSync(join(stageDir, 'main.lua'), mobileMainWrapper()); + + return signature({ + enabled: true, + configPath: runtimeConfig, + generatedConfig: !runtimeConfig, + configSource: generatedConfig, + noPlugins: Boolean(options.noPlugins), + featherDir, + pluginsDir: options.noPlugins ? undefined : pluginDir, + }); +} + +function resolveRuntimeConfig(config: ResolvedBuildConfig, override: string | undefined): string | undefined { + const candidates = override + ? [resolve(config.projectDir, override)] + : [ + join(config.sourceDir, 'feather.config.lua'), + join(config.sourceDir, '.featherrc.lua'), + join(config.projectDir, 'feather.config.lua'), + join(config.projectDir, '.featherrc.lua'), + ]; + return candidates.find((candidate) => existsSync(candidate)); +} + +function generatedMobileConfig(name: string): string { + return [ + '-- feather.config.lua', + '-- Generated only inside Feather mobile dev build staging.', + '-- The source project is not modified.', + 'return {', + ` sessionName = ${JSON.stringify(name)},`, + ' debug = true,', + ' wrapPrint = true,', + ' autoRegisterErrorHandler = true,', + ' captureScreenshot = false,', + ' __DANGEROUS_INSECURE_CONNECTION__ = true,', + ' debugOverlay = {', + ' enabled = true,', + ' visible = true,', + ' hideKey = "f12",', + ' touchToggle = true,', + ' corner = "top-right",', + ' },', + '}', + '', + ].join('\n'); +} + +function mobileMainWrapper(): string { + return [ + '-- Feather mobile debugger injector - generated in build staging only.', + 'FEATHER_PATH = "feather"', + 'FEATHER_PLUGIN_PATH = ""', + '', + 'local function loadFeatherConfig()', + ' local chunk, err = love.filesystem.load("feather.config.lua")', + ' if not chunk then error("[feather] Could not load feather.config.lua: " .. tostring(err), 2) end', + ' local ok, config = pcall(chunk)', + ' if not ok then error("[feather] Could not evaluate feather.config.lua: " .. tostring(config), 2) end', + ' if type(config) ~= "table" then config = {} end', + ' if config.debug == nil then config.debug = true end', + ' if config.wrapPrint == nil then config.wrapPrint = true end', + ' if config.autoRegisterErrorHandler == nil then config.autoRegisterErrorHandler = true end', + ' return config', + 'end', + '', + 'FEATHER_AUTO_CONFIG = loadFeatherConfig()', + 'require("feather.auto")', + '', + 'local gameMain, err = love.filesystem.load(".feather-main.lua")', + 'if not gameMain then error("[feather] Could not load original main.lua: " .. tostring(err), 2) end', + 'gameMain()', + '', + ].join('\n'); +} + +function signature(input: Record): MobileDebugStageInfo { + const hash = createHash('sha256').update(JSON.stringify(input)).digest('hex'); + return { + enabled: Boolean(input.enabled), + configPath: typeof input.configPath === 'string' ? input.configPath : undefined, + generatedConfig: Boolean(input.generatedConfig), + signature: hash, + }; +} diff --git a/cli/src/lib/build/ios.ts b/cli/src/lib/build/ios.ts index 61125a77..4281e2a5 100644 --- a/cli/src/lib/build/ios.ts +++ b/cli/src/lib/build/ios.ts @@ -3,11 +3,24 @@ import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node import { dirname, join, resolve } from 'node:path'; import { artifactBaseName, copyDirectory, writeLoveArchive, type BuildArtifact } from './files.js'; import type { ResolvedBuildConfig } from './config.js'; -import { createNativeWorkspace, findFirstPath } from './native.js'; +import { + createNativeWorkspace, + findFirstPath, + logNativeCommand, + logNativeOutput, + logNativeStep, + type NativeCacheInfo, + type NativeBuildLogger, +} from './native.js'; import { iosBundleIdentifier } from './validation.js'; export type IosBuildModeOptions = { release?: boolean; + cache?: boolean; + debuggerSignature?: string; + verbose?: boolean; + log?: NativeBuildLogger; + onCache?: (cache: NativeCacheInfo) => void; }; export function buildIos(config: ResolvedBuildConfig, stageDir: string, options: IosBuildModeOptions = {}): BuildArtifact[] { @@ -17,16 +30,44 @@ export function buildIos(config: ResolvedBuildConfig, stageDir: string, options: const iosConfig = config.targets.ios ?? {}; const loveIosDir = iosConfig.loveIosDir ? resolve(config.projectDir, iosConfig.loveIosDir) : ''; - if (!loveIosDir || !existsSync(loveIosDir)) { + if (!iosConfig.loveIosDir) { throw new Error('iOS build requires targets.ios.loveIosDir in feather.build.json.'); } + if (!existsSync(loveIosDir)) { + throw new Error(`iOS template not found at ${loveIosDir}. Run \`feather build vendor add ios --dir ${config.projectDir}\` or update targets.ios.loveIosDir in feather.build.json.`); + } const base = artifactBaseName(config); const lovePath = writeLoveArchive(stageDir, config.outDir, base); - const workspace = createNativeWorkspace('feather-ios-', loveIosDir, 'love-ios'); - const derivedDataPath = iosConfig.derivedDataPath - ? resolve(config.projectDir, iosConfig.derivedDataPath) - : join(workspace.root, 'DerivedData'); + logNativeStep(options.log, `Created .love archive: ${lovePath}`); + if (options.release || options.cache === false) { + logNativeStep(options.log, `Build cache: ${options.release ? 'disabled for release build' : 'disabled by --no-cache'}`); + } + const cacheEnabled = !options.release && options.cache !== false; + logNativeStep(options.log, `iOS template: ${loveIosDir}`); + const workspace = createNativeWorkspace('feather-ios-', loveIosDir, 'love-ios', { + enabled: cacheEnabled, + target: 'ios', + outDir: config.outDir, + log: options.log, + requiredPaths: ['platform/xcode/love.xcodeproj'], + keyParts: { + bundleIdentifier: iosBundleIdentifier(config), + displayName: iosConfig.displayName ?? config.name, + scheme: iosConfig.scheme ?? 'love-ios', + configuration: iosConfig.configuration ?? 'Debug', + sdk: iosConfig.sdk ?? 'iphonesimulator', + teamId: iosConfig.teamId, + debuggerSignature: options.debuggerSignature, + }, + }); + options.onCache?.(workspace.cache); + logNativeStep(options.log, `iOS workspace: ${workspace.dir}`); + const derivedDataPath = cacheEnabled + ? join(workspace.root, 'DerivedData') + : iosConfig.derivedDataPath + ? resolve(config.projectDir, iosConfig.derivedDataPath) + : join(workspace.root, 'DerivedData'); try { const xcodeProject = join(workspace.dir, 'platform', 'xcode', 'love.xcodeproj'); @@ -37,15 +78,17 @@ export function buildIos(config: ResolvedBuildConfig, stageDir: string, options: const gameLovePath = join(workspace.dir, 'platform', 'xcode', 'game.love'); mkdirSync(dirname(gameLovePath), { recursive: true }); cpSync(lovePath, gameLovePath, { force: true }); + logNativeStep(options.log, `Embedded game.love: ${gameLovePath}`); patchIosProject(join(xcodeProject, 'project.pbxproj')); + logNativeStep(options.log, 'Patched Xcode project resources'); if (options.release) { - const releaseArtifacts = buildIosRelease(config, workspace.dir, xcodeProject, base, lovePath); + const releaseArtifacts = buildIosRelease(config, workspace.dir, xcodeProject, base, lovePath, options.log); return releaseArtifacts; } const args = xcodebuildArgs(config, xcodeProject, derivedDataPath); - runXcodebuild(args, workspace.dir); + runXcodebuild(args, workspace.dir, options.log); const appSource = findFirstPath(derivedDataPath, (_path, entry) => entry.isDirectory() && entry.name.endsWith('.app')); if (!appSource || !existsSync(appSource)) { @@ -53,6 +96,7 @@ export function buildIos(config: ResolvedBuildConfig, stageDir: string, options: } const appPath = join(config.outDir, `${base}-ios.app`); copyDirectory(appSource, appPath); + logNativeStep(options.log, `Copied app artifact: ${appPath}`); return [ { target: 'ios', type: 'love', path: lovePath }, @@ -69,6 +113,7 @@ function buildIosRelease( xcodeProject: string, base: string, lovePath: string, + log?: NativeBuildLogger, ): BuildArtifact[] { const release = config.targets.ios?.release ?? {}; const archiveSource = release.archivePath @@ -81,11 +126,11 @@ function buildIosRelease( ? resolve(config.projectDir, release.exportOptionsPlist) : writeGeneratedExportOptions(config, join(workDir, '..', 'ExportOptions.plist')); - runXcodebuild(xcodeArchiveArgs(config, xcodeProject, archiveSource), workDir); + runXcodebuild(xcodeArchiveArgs(config, xcodeProject, archiveSource), workDir, log); if (!existsSync(archiveSource)) { throw new Error('iOS archive completed but no .xcarchive was found. Check targets.ios.release.archivePath or Xcode archive settings.'); } - runXcodebuild(xcodeExportArchiveArgs(archiveSource, exportPath, exportOptionsPlist), workDir); + runXcodebuild(xcodeExportArchiveArgs(archiveSource, exportPath, exportOptionsPlist), workDir, log); const ipaSource = findFirstPath(exportPath, (_path, entry) => entry.isFile() && entry.name.endsWith('.ipa')); if (!ipaSource || !existsSync(ipaSource)) { throw new Error('iOS export completed but no .ipa artifact was found. Check targets.ios.release.exportPath or export options.'); @@ -95,6 +140,8 @@ function buildIosRelease( const ipaPath = join(config.outDir, `${base}-ios.ipa`); copyDirectory(archiveSource, archivePath); cpSync(ipaSource, ipaPath, { force: true }); + logNativeStep(log, `Copied archive artifact: ${archivePath}`); + logNativeStep(log, `Copied IPA artifact: ${ipaPath}`); return [ { target: 'ios', type: 'love', path: lovePath }, { target: 'ios', type: 'xcarchive', path: archivePath }, @@ -158,11 +205,18 @@ export function xcodeExportArchiveArgs(archivePath: string, exportPath: string, ]; } -function runXcodebuild(args: string[], workDir: string): void { - const result = spawnSync('xcodebuild', args, { cwd: workDir, encoding: 'utf8' }); +function runXcodebuild(args: string[], workDir: string, log?: NativeBuildLogger): void { + logNativeCommand(log, 'xcodebuild', args, workDir); + const streamOutput = Boolean(log); + const result = spawnSync('xcodebuild', args, { + cwd: workDir, + encoding: streamOutput ? undefined : 'utf8', + stdio: streamOutput ? 'inherit' : 'pipe', + }); + if (!streamOutput) logNativeOutput(log, result.stdout, result.stderr); if (result.error) throw new Error('xcodebuild not found. Run `feather doctor --build-target ios`.'); if (result.status !== 0) { - throw new Error((result.stderr || result.stdout || 'xcodebuild failed').trim()); + throw new Error((result.stderr || result.stdout || `xcodebuild failed with exit code ${result.status ?? 'unknown'}`).toString().trim()); } } diff --git a/cli/src/lib/build/native.ts b/cli/src/lib/build/native.ts index 292535d9..82d81644 100644 --- a/cli/src/lib/build/native.ts +++ b/cli/src/lib/build/native.ts @@ -1,9 +1,12 @@ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, + realpathSync, rmSync, writeFileSync, type Dirent, @@ -12,23 +15,130 @@ import { tmpdir } from 'node:os'; import { join, resolve, sep } from 'node:path'; import { copyDirectory } from './files.js'; +export type NativeBuildLogger = (message: string) => void; + +export type NativeCacheInfo = { + enabled: boolean; + hit: boolean; + path?: string; + key?: string; +}; + +export type NativeCacheOptions = { + enabled: boolean; + target: 'android' | 'ios'; + outDir: string; + keyParts: Record; + requiredPaths?: string[]; + log?: NativeBuildLogger; +}; + export type NativeWorkspace = { root: string; dir: string; + cache: NativeCacheInfo; cleanup: () => void; }; -export function createNativeWorkspace(prefix: string, templateDir: string, dirname: string): NativeWorkspace { +export function createNativeWorkspace( + prefix: string, + templateDir: string, + dirname: string, + cache?: NativeCacheOptions, +): NativeWorkspace { + if (cache?.enabled) { + const key = nativeCacheKey(templateDir, cache.target, cache.keyParts); + const root = join(cache.outDir, '.feather-cache', cache.target, key); + const dir = join(root, dirname); + const hit = existsSync(dir) && cache.requiredPaths?.every((path) => existsSync(join(dir, path))) !== false; + logNativeStep(cache.log, `Build cache: ${hit ? 'hit' : 'miss'} ${root}`); + if (!hit) { + rmSync(root, { recursive: true, force: true }); + mkdirSync(root, { recursive: true }); + copyDirectory(templateDir, dir); + } + return { + root, + dir, + cache: { enabled: true, hit, path: root, key }, + cleanup: () => {}, + }; + } + const root = mkdtempSync(join(tmpdir(), prefix)); const dir = join(root, dirname); copyDirectory(templateDir, dir); return { root, dir, + cache: { enabled: false, hit: false }, cleanup: () => rmSync(root, { recursive: true, force: true }), }; } +function nativeCacheKey(templateDir: string, target: 'android' | 'ios', keyParts: Record): string { + const payload = stableJson({ + schema: 1, + target, + templateDir: realpathSync(templateDir), + templateGitHead: gitHead(templateDir), + keyParts, + }); + return createHash('sha256').update(payload).digest('hex').slice(0, 32); +} + +function gitHead(dir: string): string | null { + const result = spawnSync('git', ['-C', dir, 'rev-parse', 'HEAD'], { encoding: 'utf8' }); + return result.status === 0 ? result.stdout.trim() : null; +} + +function stableJson(value: unknown): string { + return JSON.stringify(stableValue(value)); +} + +function stableValue(value: unknown): unknown { + if (!value || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map(stableValue); + return Object.fromEntries( + Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, item]) => [key, stableValue(item)]), + ); +} + +export function logNativeStep(log: NativeBuildLogger | undefined, message: string): void { + log?.(message); +} + +export function logNativeCommand( + log: NativeBuildLogger | undefined, + command: string, + args: string[], + cwd: string, +): void { + if (!log) return; + log(`$ ${[command, ...args].map(shellQuote).join(' ')}`); + log(` cwd: ${cwd}`); +} + +export function logNativeOutput( + log: NativeBuildLogger | undefined, + stdout: string | Buffer | null | undefined, + stderr: string | Buffer | null | undefined, +): void { + if (!log) return; + const lines = [ + ...String(stdout ?? '').trim().split(/\r?\n/).filter(Boolean), + ...String(stderr ?? '').trim().split(/\r?\n/).filter(Boolean), + ]; + for (const line of lines) log(` ${line}`); +} + +function shellQuote(value: string): string { + if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) return value; + return JSON.stringify(value); +} + export function resolveWorkspacePath(root: string, path: string, label: string): string { const absolute = resolve(root, path); const normalizedRoot = resolve(root); diff --git a/cli/src/lib/run/mobile.ts b/cli/src/lib/run/mobile.ts index 2c2e9d09..2ff45505 100644 --- a/cli/src/lib/run/mobile.ts +++ b/cli/src/lib/run/mobile.ts @@ -4,6 +4,8 @@ import type { BuildArtifact } from '../build/files.js'; import { loadBuildConfig, type BuildTarget, type LoadBuildConfigOptions } from '../build/config.js'; import { runBuild } from '../build/build.js'; import { androidProductId, iosBundleIdentifier } from '../build/validation.js'; +import { printMuted } from '../output.js'; +import { logNativeCommand, logNativeOutput, type NativeBuildLogger } from '../build/native.js'; export type MobileRunTarget = Extract; @@ -11,6 +13,13 @@ export type MobileRunOptions = LoadBuildConfigOptions & { target: MobileRunTarget; device?: string; clean?: boolean; + noCache?: boolean; + debugger?: boolean; + runtimeConfigPath?: string; + noPlugins?: boolean; + featherOverride?: string; + pluginsOverride?: string; + verbose?: boolean; adbReverse?: boolean; port?: number; }; @@ -30,9 +39,18 @@ export function runMobile(options: MobileRunOptions): MobileRunResult { target: options.target, projectDir: options.projectDir, configPath: options.configPath, + sourceDir: options.sourceDir, outDir: options.outDir, clean: options.clean, + noCache: options.noCache, + debugger: options.debugger, + runtimeConfigPath: options.runtimeConfigPath, + noPlugins: options.noPlugins, + featherOverride: options.featherOverride, + pluginsOverride: options.pluginsOverride, allowUnsafe: true, + verbose: options.verbose, + log: options.verbose ? printMuted : undefined, }); if (!buildResult.ok) { @@ -42,6 +60,7 @@ export function runMobile(options: MobileRunOptions): MobileRunResult { const config = loadBuildConfig({ projectDir: buildResult.projectDir, configPath: options.configPath, + sourceDir: options.sourceDir, outDir: options.outDir, }); @@ -54,6 +73,7 @@ export function runMobile(options: MobileRunOptions): MobileRunResult { device: options.device, adbReverse: options.adbReverse !== false, port: options.port ?? 4004, + log: options.verbose ? printMuted : undefined, }); return { target: 'android', @@ -69,7 +89,7 @@ export function runMobile(options: MobileRunOptions): MobileRunResult { const app = requireArtifact(buildResult.artifacts, 'app', 'iOS app'); const appId = iosBundleIdentifier(config); const device = options.device ?? 'booted'; - installAndLaunchIos({ app, appId, device }); + installAndLaunchIos({ app, appId, device, log: options.verbose ? printMuted : undefined }); return { target: 'ios', projectDir: buildResult.projectDir, @@ -93,41 +113,57 @@ function installAndLaunchAndroid(input: { device?: string; adbReverse: boolean; port: number; + log?: NativeBuildLogger; }): void { - runAdb(input.device, ['version'], 'adb not found. Run `feather doctor --build-target android` for setup guidance.'); - runAdb(input.device, ['install', '-r', input.apk], 'Android install failed.'); + runAdb(input.device, ['version'], 'adb not found. Run `feather doctor --build-target android` for setup guidance.', input.log); + runAdb(input.device, ['install', '-r', input.apk], 'Android install failed.', input.log); + runAdb(input.device, ['shell', 'am', 'force-stop', input.appId], 'Android force-stop failed.', input.log); if (input.adbReverse) { runAdb( input.device, ['reverse', `tcp:${input.port}`, `tcp:${input.port}`], 'Android adb reverse failed. Check USB debugging or pass --no-adb-reverse.', + input.log, ); } runAdb( input.device, ['shell', 'monkey', '-p', input.appId, '-c', 'android.intent.category.LAUNCHER', '1'], 'Android launch failed.', + input.log, ); } -function runAdb(device: string | undefined, args: string[], message: string): void { +function runAdb(device: string | undefined, args: string[], message: string, log?: NativeBuildLogger): void { const fullArgs = device ? ['-s', device, ...args] : args; - const result = spawnSync('adb', fullArgs, { encoding: 'utf8' }); + logNativeCommand(log, 'adb', fullArgs, process.cwd()); + const streamOutput = Boolean(log); + const result = spawnSync('adb', fullArgs, { + encoding: streamOutput ? undefined : 'utf8', + stdio: streamOutput ? 'inherit' : 'pipe', + }); + if (!streamOutput) logNativeOutput(log, result.stdout, result.stderr); if (result.error) throw new Error(`${message} ${(result.error as Error).message}`.trim()); if (result.status !== 0) { - throw new Error(`${message} ${(result.stderr || result.stdout || '').trim()}`.trim()); + throw new Error(`${message} ${(result.stderr || result.stdout || `exit code ${result.status ?? 'unknown'}`).toString().trim()}`.trim()); } } -function installAndLaunchIos(input: { app: string; appId: string; device: string }): void { - runXcrun(['simctl', 'install', input.device, input.app], 'iOS simulator install failed.'); - runXcrun(['simctl', 'launch', input.device, input.appId], 'iOS simulator launch failed.'); +function installAndLaunchIos(input: { app: string; appId: string; device: string; log?: NativeBuildLogger }): void { + runXcrun(['simctl', 'install', input.device, input.app], 'iOS simulator install failed.', input.log); + runXcrun(['simctl', 'launch', input.device, input.appId], 'iOS simulator launch failed.', input.log); } -function runXcrun(args: string[], message: string): void { - const result = spawnSync('xcrun', args, { encoding: 'utf8' }); +function runXcrun(args: string[], message: string, log?: NativeBuildLogger): void { + logNativeCommand(log, 'xcrun', args, process.cwd()); + const streamOutput = Boolean(log); + const result = spawnSync('xcrun', args, { + encoding: streamOutput ? undefined : 'utf8', + stdio: streamOutput ? 'inherit' : 'pipe', + }); + if (!streamOutput) logNativeOutput(log, result.stdout, result.stderr); if (result.error) throw new Error(`${message} xcrun not found. Run \`feather doctor --build-target ios\` for setup guidance.`); if (result.status !== 0) { - throw new Error(`${message} ${(result.stderr || result.stdout || '').trim()}`.trim()); + throw new Error(`${message} ${(result.stderr || result.stdout || `exit code ${result.status ?? 'unknown'}`).toString().trim()}`.trim()); } } diff --git a/cli/src/lib/shim.ts b/cli/src/lib/shim.ts index ffd3586d..ece98565 100644 --- a/cli/src/lib/shim.ts +++ b/cli/src/lib/shim.ts @@ -5,8 +5,17 @@ import { fileURLToPath } from 'node:url'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); -// Path to the bundled Lua library shipped with this CLI package -const BUNDLED_LUA = resolve(__dirname, '../../lua'); +// Path to the bundled Lua library shipped with this CLI package. +// In a source checkout `npm run build` does not run the publish-time Lua bundle, +// so fall back to the repository's src-lua directory for local development. +const PACKAGED_LUA = resolve(__dirname, '../../lua'); +const SOURCE_LUA = resolve(__dirname, '../../../src-lua'); + +export function bundledLuaRoot(): string { + if (existsSync(join(PACKAGED_LUA, 'feather', 'auto.lua'))) return PACKAGED_LUA; + if (existsSync(join(SOURCE_LUA, 'feather', 'auto.lua'))) return SOURCE_LUA; + return PACKAGED_LUA; +} export interface ShimOptions { gamePath: string; @@ -22,16 +31,16 @@ export interface Shim { cleanup: () => void; } -function featherRoot(override?: string): string { +export function featherRoot(override?: string): string { if (override) return resolve(override); - return join(BUNDLED_LUA, 'feather'); + return join(bundledLuaRoot(), 'feather'); } -function pluginsRoot(featherDir: string, override?: string): string { +export function pluginsRoot(featherDir: string, override?: string): string { if (override) return join(resolve(override), '..', 'plugins'); const localPlugins = join(featherDir, '..', 'plugins'); if (existsSync(localPlugins)) return resolve(localPlugins); - return join(BUNDLED_LUA, 'plugins'); + return join(bundledLuaRoot(), 'plugins'); } /** Read plugin IDs from a plugins directory by checking for manifest.lua in each subdir. */ @@ -82,12 +91,13 @@ ${pluginListLine} FEATHER_PATH = "feather" FEATHER_PLUGIN_PATH = "" ${opts.noPlugins ? 'FEATHER_SKIP_PLUGINS = true' : ''} -require("feather.auto").setup({ +FEATHER_AUTO_CONFIG = { debug = true, wrapPrint = true, sessionName = os.getenv("FEATHER_SESSION_NAME") or ${JSON.stringify(sessionName)}, ${configLines} -}) +} +require("feather.auto") -- Load the game's main.lua via absolute OS path to avoid shadowing by this file local gamePath = os.getenv("FEATHER_GAME_PATH") diff --git a/cli/test/commands.test.mjs b/cli/test/commands.test.mjs index 313e71cc..eec8c3d0 100644 --- a/cli/test/commands.test.mjs +++ b/cli/test/commands.test.mjs @@ -13,6 +13,7 @@ import { mkdirSync, mkdtempSync, readFileSync, + rmSync, symlinkSync, writeFileSync, } from 'node:fs'; @@ -108,6 +109,8 @@ fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ FEATHER_SESSION_NAME: process.env.FEATHER_SESSION_NAME, }, shimMainExists: shimDir ? fs.existsSync(path.join(shimDir, 'main.lua')) : false, + featherAutoExists: shimDir ? fs.existsSync(path.join(shimDir, 'feather', 'auto.lua')) : false, + shimMain: shimDir ? fs.readFileSync(path.join(shimDir, 'main.lua'), 'utf8') : '', }, null, 2)); process.exit(${JSON.stringify(exitCode)}); `, @@ -223,6 +226,7 @@ function writeFakeLoveAndroid(dir, options = {}) { const root = join(dir, 'love-android'); mkdirSync(join(root, 'app', 'src', 'main', 'res', 'values'), { recursive: true }); mkdirSync(join(root, 'app', 'src', 'main'), { recursive: true }); + mkdirSync(join(root, 'love', 'src', 'main', 'java', 'org', 'love2d', 'android'), { recursive: true }); writeFileSync( join(root, 'app', 'build.gradle'), `android { @@ -233,6 +237,15 @@ function writeFakeLoveAndroid(dir, options = {}) { versionName "0.0.0" } } +`, + ); + writeFileSync( + join(root, 'gradle.properties'), + `app.name_byte_array=76,195,150,86,69 +app.application_id=org.love2d.android +app.orientation=landscape +app.version_code=31 +app.version_name=11.5 `, ); writeFileSync( @@ -246,6 +259,22 @@ ${manifestPermission} LÖVE\n'); + writeFileSync( + join(root, 'love', 'src', 'main', 'java', 'org', 'love2d', 'android', 'GameActivity.java'), + `package org.love2d.android; +import java.io.IOException; +import java.io.InputStream; +class GameActivity { + boolean embed; + boolean needToCopyGameInArchive; + Object getResources() { return null; } + Object getAssets() { return null; } + void onCreate() { + embed = getResources().getBoolean(R.bool.embed); + } +} +`, + ); const gradlew = join(root, 'gradlew'); writeFileSync( gradlew, @@ -264,15 +293,21 @@ const previous = fs.existsSync(${JSON.stringify(recordPath)}) : { records: [] }; const entry = { argv: process.argv.slice(2), + cwd, embeddedLoveExists: fs.existsSync(path.join(cwd, 'app', 'src', 'embed', 'assets', 'game.love')), gradle: fs.readFileSync(path.join(cwd, 'app', 'build.gradle'), 'utf8'), + gradleProperties: fs.readFileSync(path.join(cwd, 'gradle.properties'), 'utf8'), manifest: fs.readFileSync(path.join(cwd, 'app', 'src', 'main', 'AndroidManifest.xml'), 'utf8'), + gameActivity: fs.existsSync(path.join(cwd, 'love', 'src', 'main', 'java', 'org', 'love2d', 'android', 'GameActivity.java')) + ? fs.readFileSync(path.join(cwd, 'love', 'src', 'main', 'java', 'org', 'love2d', 'android', 'GameActivity.java'), 'utf8') + : '', signingProperties: fs.existsSync(path.join(cwd, 'feather-signing.properties')) ? fs.readFileSync(path.join(cwd, 'feather-signing.properties'), 'utf8') : '', }; previous.records.push(entry); fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ ...entry, records: previous.records }, null, 2)); +console.log('fake gradle ' + process.argv.slice(2).join(' ')); process.exit(0); `, ); @@ -307,6 +342,26 @@ function writeBuildConfig(dir, config) { writeFileSync(join(dir, 'feather.build.json'), `${JSON.stringify(config, null, 2)}\n`); } +function readStoredZipEntries(zipPath) { + const buffer = readFileSync(zipPath); + const entries = new Map(); + let offset = 0; + while (offset + 30 < buffer.length && buffer.readUInt32LE(offset) === 0x04034b50) { + const method = buffer.readUInt16LE(offset + 8); + const compressedSize = buffer.readUInt32LE(offset + 18); + const fileNameLength = buffer.readUInt16LE(offset + 26); + const extraLength = buffer.readUInt16LE(offset + 28); + const nameStart = offset + 30; + const dataStart = nameStart + fileNameLength + extraLength; + const name = buffer.subarray(nameStart, nameStart + fileNameLength).toString('utf8'); + const data = buffer.subarray(dataStart, dataStart + compressedSize); + if (method !== 0) throw new Error(`Test zip reader only supports stored entries: ${name}`); + entries.set(name, data); + offset = dataStart + compressedSize; + } + return entries; +} + async function writeFakeAppleLibrariesZip(dir) { const { createZipBuffer } = await import('../dist/lib/build/archive.js'); const zipPath = join(dir, 'love-apple-libraries.zip'); @@ -383,10 +438,94 @@ test('run: fake love receives shim, args, env, and exit code is propagated', () assert.equal(record.env.FEATHER_GAME_PATH, resolve(gameDir)); assert.equal(record.env.FEATHER_SESSION_NAME, 'Command Test'); assert.equal(record.shimMainExists, true); + assert.equal(record.featherAutoExists, true); assert.equal(existsSync(record.argv[0]), false, 'shim should be cleaned after love exits'); assert.deepEqual(record.argv.slice(1), ['--level', '2']); }); +test('run: source checkout build exposes feather.auto without a bundled cli/lua directory', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + const { fakePath, recordPath } = writeFakeLove(dir); + + const result = run(['run', '--love', fakePath, gameDir]); + + assert.equal(result.exitCode, 0, outputOf(result)); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.equal(record.featherAutoExists, true); +}); + +test('run: accepts configPath aliases and recovers npm-stripped config path argument', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + const configPath = join(gameDir, 'feather.config.lua'); + writeFileSync(configPath, 'return { sessionName = "From Config Alias" }\n'); + + const alias = writeFakeLove(dir, { recordPath: join(dir, 'alias-record.json') }); + const aliasResult = run(['run', '--love', alias.fakePath, '--configPath', configPath, gameDir]); + assert.equal(aliasResult.exitCode, 0, outputOf(aliasResult)); + const aliasRecord = JSON.parse(readFileSync(alias.recordPath, 'utf8')); + assert.equal(aliasRecord.env.FEATHER_SESSION_NAME, 'From Config Alias'); + assert.deepEqual(aliasRecord.argv.slice(1), []); + + const stripped = writeFakeLove(dir, { recordPath: join(dir, 'stripped-record.json') }); + const strippedResult = run(['run', '--love', stripped.fakePath, gameDir, configPath]); + assert.equal(strippedResult.exitCode, 0, outputOf(strippedResult)); + const strippedRecord = JSON.parse(readFileSync(stripped.recordPath, 'utf8')); + assert.equal(strippedRecord.env.FEATHER_SESSION_NAME, 'From Config Alias'); + assert.deepEqual(strippedRecord.argv.slice(1), []); +}); + +test('run: shim preloads config before requiring feather.auto', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + const configPath = join(gameDir, 'feather.config.lua'); + writeFileSync(configPath, 'return { __DANGEROUS_INSECURE_CONNECTION__ = true, sessionName = "Security Config" }\n'); + const { fakePath, recordPath } = writeFakeLove(dir); + + const result = run(['run', '--love', fakePath, gameDir, '--config', configPath]); + + assert.equal(result.exitCode, 0, outputOf(result)); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.ok(record.shimMain.includes('FEATHER_AUTO_CONFIG = {')); + assert.ok(record.shimMain.includes('__DANGEROUS_INSECURE_CONNECTION__ = true')); + assert.equal(record.shimMain.includes('require("feather.auto").setup'), false); + assert.ok(record.shimMain.includes('require("feather.auto")')); +}); + +test('run --no-debugger: launches game directly without Feather shim', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + const { fakePath, recordPath } = writeFakeLove(dir); + + const result = run(['run', '--love', fakePath, '--no-debugger', gameDir, '--', '--plain']); + + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('Debugger disabled')); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.equal(record.argv[0], resolve(gameDir)); + assert.deepEqual(record.argv.slice(1), ['--plain']); + assert.equal(record.featherAutoExists, false); + assert.equal(record.shimMain, readFileSync(join(gameDir, 'main.lua'), 'utf8')); +}); + +test('run --disable-debugger: aliases debugger-disabled desktop launch', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + const { fakePath, recordPath } = writeFakeLove(dir); + + const result = run(['run', '--love', fakePath, '--disable-debugger', gameDir]); + + assert.equal(result.exitCode, 0, outputOf(result)); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.equal(record.argv[0], resolve(gameDir)); +}); + test('run --target android: builds, installs, sets adb reverse, launches, and supports device selection', () => { const dir = makeTmp(); writeGame(dir); @@ -408,9 +547,80 @@ test('run --target android: builds, installs, sets adb reverse, launches, and su assert.deepEqual(records.map((entry) => entry.args), [ ['-s', 'emulator-5554', 'version'], ['-s', 'emulator-5554', 'install', '-r', join(dir, 'builds', 'run-android-1.0.0-android.apk')], + ['-s', 'emulator-5554', 'shell', 'am', 'force-stop', 'com.example.runandroid'], ['-s', 'emulator-5554', 'reverse', 'tcp:4010', 'tcp:4010'], ['-s', 'emulator-5554', 'shell', 'monkey', '-p', 'com.example.runandroid', '-c', 'android.intent.category.LAUNCHER', '1'], ]); + const entries = readStoredZipEntries(join(dir, 'builds', 'run-android-1.0.0.love')); + assert.equal(entries.has('feather/auto.lua'), true); + assert.match(entries.get('feather.config.lua').toString('utf8'), /port\s*=\s*4010/); +}); + +test('run --target android --config: embeds selected raw Feather config in mobile love archive', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + const configPath = join(gameDir, 'custom-feather.config.lua'); + writeFileSync(configPath, `return { + sessionName = "Mobile Custom", + __DANGEROUS_INSECURE_CONNECTION__ = true, + debugger = { + hotReload = { + enabled = true, + allow = { "game.*" }, + }, + }, +} +`); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Run Android Config', + version: '1.0.0', + productId: 'com.example.runandroidconfig', + sourceDir: 'game', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + const { binDir } = writeFakeAdb(dir); + + const result = run(['run', gameDir, '--target', 'android', '--config', configPath, '--no-adb-reverse'], { + cwd: dir, + env: envWithPath(binDir), + }); + assert.equal(result.exitCode, 0, outputOf(result)); + const entries = readStoredZipEntries(join(dir, 'builds', 'run-android-config-1.0.0.love')); + assert.equal(entries.has('main.lua'), true); + assert.equal(entries.has('.feather-main.lua'), true); + assert.equal(entries.has('feather/core/debug_overlay.lua'), true); + const config = entries.get('feather.config.lua').toString('utf8'); + assert.match(config, /sessionName\s*=\s*"Mobile Custom"/); + assert.match(config, /hotReload\s*=\s*\{/); + assert.match(config, /allow\s*=\s*\{\s*"game\.\*"\s*\}/); +}); + +test('run --target android: uses root build config for a nested game path', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'src-lua', 'example', 'test_cli'); + writeGame(gameDir); + const { recordPath: gradleRecordPath } = writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Nested Android', + version: '1.0.0', + productId: 'com.example.nestedandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + const { binDir, recordPath } = writeFakeAdb(dir); + + const result = run(['run', gameDir, '--target', 'android', '--no-adb-reverse'], { + cwd: dir, + env: envWithPath(binDir), + }); + + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('Launched android')); + assert.ok(outputOf(result).includes(gameDir)); + assert.ok(existsSync(join(dir, 'builds', 'nested-android-1.0.0-android.apk'))); + const gradleRecord = JSON.parse(readFileSync(gradleRecordPath, 'utf8')); + assert.equal(gradleRecord.embeddedLoveExists, true); }); test('run --target android --no-adb-reverse skips reverse setup', () => { @@ -431,6 +641,50 @@ test('run --target android --no-adb-reverse skips reverse setup', () => { assert.equal(records.some((entry) => entry.args.includes('reverse')), false); }); +test('run --target android --no-debugger skips adb reverse setup', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'No Debugger Android', + version: '1.0.0', + productId: 'com.example.nodebuggerandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + const { binDir, recordPath } = writeFakeAdb(dir); + + const result = run(['run', dir, '--target', 'android', '--no-debugger'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + const records = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.equal(records.some((entry) => entry.args.includes('reverse')), false); + assert.equal(outputOf(result).includes('ADB reverse disabled'), true); + const entries = readStoredZipEntries(join(dir, 'builds', 'no-debugger-android-1.0.0.love')); + assert.equal(entries.has('feather/auto.lua'), false); + assert.equal(entries.has('.feather-main.lua'), false); +}); + +test('run --target android --no-cache forwards cache option to mobile build', () => { + const dir = makeTmp(); + writeGame(dir); + const { recordPath: gradleRecordPath } = writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Run No Cache', + version: '1.0.0', + productId: 'com.example.runnocache', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + const { binDir } = writeFakeAdb(dir); + + const first = run(['run', dir, '--target', 'android', '--no-adb-reverse', '--no-cache'], { env: envWithPath(binDir) }); + const second = run(['run', dir, '--target', 'android', '--no-adb-reverse', '--no-cache'], { env: envWithPath(binDir) }); + assert.equal(first.exitCode, 0, outputOf(first)); + assert.equal(second.exitCode, 0, outputOf(second)); + assert.equal(existsSync(join(dir, 'builds', '.feather-cache')), false); + const records = JSON.parse(readFileSync(gradleRecordPath, 'utf8')).records; + assert.equal(records.length, 2); + assert.notEqual(records[0].cwd, records[1].cwd); +}); + test('run --target android: missing adb produces doctor guidance', () => { const dir = makeTmp(); writeGame(dir); @@ -1153,7 +1407,7 @@ test('build android: invalid config fails before staging or writing artifacts', targets: { android: { loveAndroidDir: 'love-android', versionCode: 0 } }, }); - const result = run(['build', 'android', '--dir', dir, '--json']); + const result = run(['build', 'android', '--dir', dir, '--allow-unsafe', '--json']); assert.equal(result.exitCode, 1); assert.ok(outputOf(result).includes('Invalid android build config')); assert.ok(outputOf(result).includes('productId')); @@ -1180,7 +1434,7 @@ test('build android: injects game.love, runs Gradle, copies APK, and writes mani }, }); - const result = run(['build', 'android', '--dir', dir, '--json']); + const result = run(['build', 'android', '--dir', dir, '--allow-unsafe', '--json']); assert.equal(result.exitCode, 0, outputOf(result)); assert.equal(ANSI_RE.test(result.stdout), false); const parsed = JSON.parse(result.stdout); @@ -1195,14 +1449,223 @@ test('build android: injects game.love, runs Gradle, copies APK, and writes mani assert.ok(record.gradle.includes('applicationId "com.example.mobilegame"')); assert.ok(record.gradle.includes('versionCode 7')); assert.ok(record.gradle.includes('versionName "1.2.3-dev"')); + assert.ok(record.gradleProperties.includes('app.application_id=com.example.mobilegame')); + assert.ok(record.gradleProperties.includes('app.orientation=landscape')); + assert.ok(record.gradleProperties.includes('app.version_code=7')); + assert.ok(record.gradleProperties.includes('app.version_name=1.2.3-dev')); + assert.equal(record.gradleProperties.includes('app.name='), false); + assert.ok(record.gradleProperties.includes('app.name_byte_array=')); assert.ok(record.manifest.includes('android:label="Mobile Game Dev"')); assert.ok(record.manifest.includes('android:screenOrientation="landscape"')); assert.ok(record.manifest.includes('android.permission.RECORD_AUDIO')); + assert.ok(record.gameActivity.includes('Feather: forcing embedded game.love from assets')); const manifest = JSON.parse(readFileSync(join(dir, 'builds', 'feather-build-manifest.json'), 'utf8')); assert.equal(manifest.target, 'android'); assert.equal(manifest.artifacts.some((artifact) => artifact.type === 'apk'), true); }); +test('build android: embeds Feather debugger runtime and raw config in dev love archive', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + `return { + sessionName = "Nested Config", + __DANGEROUS_INSECURE_CONNECTION__ = true, + debugOverlay = { + enabled = true, + visible = false, + }, +} +`, + ); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Embedded Android', + version: '1.0.0', + productId: 'com.example.embeddedandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const result = run(['build', 'android', '--dir', dir, '--allow-unsafe', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + const entries = readStoredZipEntries(join(dir, 'builds', 'embedded-android-1.0.0.love')); + assert.equal(entries.has('main.lua'), true); + assert.equal(entries.has('.feather-main.lua'), true); + assert.equal(entries.has('feather/auto.lua'), true); + assert.equal(entries.has('feather/core/debug_overlay.lua'), true); + assert.equal(entries.has('plugins/profiler/manifest.lua'), true); + assert.match(entries.get('main.lua').toString('utf8'), /require\("feather\.auto"\)/); + assert.match(entries.get('feather.config.lua').toString('utf8'), /debugOverlay\s*=\s*\{/); + assert.match(entries.get('feather.config.lua').toString('utf8'), /visible\s*=\s*false/); +}); + +test('build android --no-debugger: builds raw source without Feather embed', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Raw Android', + version: '1.0.0', + productId: 'com.example.rawandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const result = run(['build', 'android', '--dir', dir, '--no-debugger', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + const entries = readStoredZipEntries(join(dir, 'builds', 'raw-android-1.0.0.love')); + assert.equal(entries.has('main.lua'), true); + assert.equal(entries.has('.feather-main.lua'), false); + assert.equal(entries.has('feather/auto.lua'), false); + assert.equal(entries.has('feather.config.lua'), false); +}); + +test('build android --release: does not auto-embed Feather debugger runtime', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Release Raw Android', + version: '1.0.0', + productId: 'com.example.releaserawandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const result = run(['build', 'android', '--dir', dir, '--release', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + const entries = readStoredZipEntries(join(dir, 'builds', 'release-raw-android-1.0.0.love')); + assert.equal(entries.has('.feather-main.lua'), false); + assert.equal(entries.has('feather/auto.lua'), false); +}); + +test('build android: reuses dev native cache between builds', () => { + const dir = makeTmp(); + writeGame(dir); + const { recordPath } = writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Cached Android', + version: '1.0.0', + productId: 'com.example.cachedandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const first = run(['build', 'android', '--dir', dir, '--json']); + assert.equal(first.exitCode, 0, outputOf(first)); + const firstParsed = JSON.parse(first.stdout); + assert.equal(firstParsed.cache.enabled, true); + assert.equal(firstParsed.cache.hit, false); + assert.ok(firstParsed.cache.path.includes(join('builds', '.feather-cache', 'android'))); + assert.equal(existsSync(firstParsed.cache.path), true); + + const second = run(['build', 'android', '--dir', dir, '--json']); + assert.equal(second.exitCode, 0, outputOf(second)); + const secondParsed = JSON.parse(second.stdout); + assert.equal(secondParsed.cache.enabled, true); + assert.equal(secondParsed.cache.hit, true); + assert.equal(secondParsed.cache.key, firstParsed.cache.key); + assert.equal(secondParsed.cache.path, firstParsed.cache.path); + + const records = JSON.parse(readFileSync(recordPath, 'utf8')).records; + assert.equal(records.length, 2); + assert.equal(records[0].cwd, records[1].cwd); + assert.ok(records[0].cwd.includes(join('builds', '.feather-cache', 'android'))); +}); + +test('build android --no-cache uses fresh native workspaces', () => { + const dir = makeTmp(); + writeGame(dir); + const { recordPath } = writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Uncached Android', + version: '1.0.0', + productId: 'com.example.uncachedandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const first = run(['build', 'android', '--dir', dir, '--no-cache', '--json']); + const second = run(['build', 'android', '--dir', dir, '--no-cache', '--json']); + assert.equal(first.exitCode, 0, outputOf(first)); + assert.equal(second.exitCode, 0, outputOf(second)); + assert.equal(JSON.parse(first.stdout).cache.enabled, false); + assert.equal(JSON.parse(second.stdout).cache.enabled, false); + assert.equal(existsSync(join(dir, 'builds', '.feather-cache')), false); + const records = JSON.parse(readFileSync(recordPath, 'utf8')).records; + assert.equal(records.length, 2); + assert.notEqual(records[0].cwd, records[1].cwd); + assert.equal(records[0].cwd.includes('.feather-cache'), false); + assert.equal(records[1].cwd.includes('.feather-cache'), false); +}); + +test('build android: stale native cache missing Gradle wrapper is recopied', () => { + const dir = makeTmp(); + writeGame(dir); + const { recordPath } = writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Stale Cached Android', + version: '1.0.0', + productId: 'com.example.stalecachedandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const first = run(['build', 'android', '--dir', dir, '--json']); + assert.equal(first.exitCode, 0, outputOf(first)); + const firstParsed = JSON.parse(first.stdout); + rmSync(join(firstParsed.cache.path, 'love-android', process.platform === 'win32' ? 'gradlew.bat' : 'gradlew'), { force: true }); + + const second = run(['build', 'android', '--dir', dir, '--json']); + assert.equal(second.exitCode, 0, outputOf(second)); + const secondParsed = JSON.parse(second.stdout); + assert.equal(secondParsed.cache.enabled, true); + assert.equal(secondParsed.cache.hit, false); + assert.equal(secondParsed.cache.path, firstParsed.cache.path); + assert.equal(existsSync(join(secondParsed.cache.path, 'love-android', process.platform === 'win32' ? 'gradlew.bat' : 'gradlew')), true); + const records = JSON.parse(readFileSync(recordPath, 'utf8')).records; + assert.equal(records.length, 2); +}); + +test('build android --clean resets dev native cache', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Clean Cached Android', + version: '1.0.0', + productId: 'com.example.cleancachedandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const first = run(['build', 'android', '--dir', dir, '--json']); + const second = run(['build', 'android', '--dir', dir, '--json']); + const cleaned = run(['build', 'android', '--dir', dir, '--clean', '--json']); + assert.equal(first.exitCode, 0, outputOf(first)); + assert.equal(second.exitCode, 0, outputOf(second)); + assert.equal(cleaned.exitCode, 0, outputOf(cleaned)); + assert.equal(JSON.parse(first.stdout).cache.hit, false); + assert.equal(JSON.parse(second.stdout).cache.hit, true); + assert.equal(JSON.parse(cleaned.stdout).cache.hit, false); +}); + +test('build android --verbose: shows native build steps and Gradle output', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Verbose Android', + version: '1.0.0', + productId: 'com.example.verboseandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const result = run(['build', 'android', '--dir', dir, '--verbose']); + + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(result.stdout.includes('Building android in verbose mode')); + assert.ok(result.stdout.includes('Staged')); + assert.ok(result.stdout.includes('Android workspace')); + assert.ok(result.stdout.includes('assembleEmbedNoRecordDebug')); + assert.ok(result.stdout.includes('fake gradle assembleEmbedNoRecordDebug')); +}); + test('build android: honors gradleTask, custom artifactPath, and removes microphone permission', () => { const dir = makeTmp(); writeGame(dir); @@ -1278,6 +1741,7 @@ test('build android --release: runs bundle/apk tasks, copies artifacts, and keep assert.equal(outputOf(result).includes('store-secret'), false); assert.equal(outputOf(result).includes('key-secret'), false); const parsed = JSON.parse(result.stdout); + assert.equal(parsed.cache.enabled, false); assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'aab'), true); assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'apk'), true); assert.equal(existsSync(join(dir, 'builds', 'store-android-3.0.0-android.aab')), true); @@ -1348,10 +1812,11 @@ fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ gameLoveExists: fs.existsSync(path.join(process.cwd(), 'platform', 'xcode', 'game.love')), projectContainsGameLove: fs.readFileSync(path.join(process.cwd(), 'platform', 'xcode', 'love.xcodeproj', 'project.pbxproj'), 'utf8').includes('game.love'), }, null, 2)); +console.log('fake xcodebuild ' + args.join(' ')); process.exit(0); `); - const result = run(['build', 'ios', '--dir', dir, '--json'], { env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }) }); + const result = run(['build', 'ios', '--dir', dir, '--no-cache', '--json'], { env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }) }); assert.equal(result.exitCode, 0, outputOf(result)); assert.equal(ANSI_RE.test(result.stdout), false); const parsed = JSON.parse(result.stdout); @@ -1372,11 +1837,108 @@ process.exit(0); assert.ok(record.argv.includes('DEVELOPMENT_TEAM=ABC123XYZ')); assert.equal(record.gameLoveExists, true); assert.equal(record.projectContainsGameLove, true); + const entries = readStoredZipEntries(join(dir, 'builds', 'ios-game-5.0.0.love')); + assert.equal(entries.has('main.lua'), true); + assert.equal(entries.has('.feather-main.lua'), true); + assert.equal(entries.has('feather/auto.lua'), true); + assert.equal(entries.has('feather/core/debug_overlay.lua'), true); const manifest = JSON.parse(readFileSync(join(dir, 'builds', 'feather-build-manifest.json'), 'utf8')); assert.equal(manifest.target, 'ios'); assert.equal(manifest.artifacts.some((artifact) => artifact.type === 'app'), true); }); +test('build ios: reuses dev native cache and cached DerivedData between builds', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Cached iOS', + version: '1.0.0', + targets: { + ios: { + loveIosDir: 'love-ios', + bundleIdentifier: 'com.example.cachedios', + }, + }, + }); + const recordPath = join(dir, 'xcodebuild-cache-record.json'); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +const derivedData = args[args.indexOf('-derivedDataPath') + 1]; +const app = path.join(derivedData, 'Build', 'Products', 'Debug-iphonesimulator', 'love-ios.app'); +fs.mkdirSync(app, { recursive: true }); +fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); +const previous = fs.existsSync(${JSON.stringify(recordPath)}) + ? JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)}, 'utf8')) + : { records: [] }; +previous.records.push({ + argv: args, + cwd: process.cwd(), + derivedData, + gameLoveExists: fs.existsSync(path.join(process.cwd(), 'platform', 'xcode', 'game.love')), +}); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify(previous, null, 2)); +process.exit(0); +`); + const env = envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }); + + const first = run(['build', 'ios', '--dir', dir, '--json'], { env }); + assert.equal(first.exitCode, 0, outputOf(first)); + const firstParsed = JSON.parse(first.stdout); + assert.equal(firstParsed.cache.enabled, true); + assert.equal(firstParsed.cache.hit, false); + + const second = run(['build', 'ios', '--dir', dir, '--json'], { env }); + assert.equal(second.exitCode, 0, outputOf(second)); + const secondParsed = JSON.parse(second.stdout); + assert.equal(secondParsed.cache.enabled, true); + assert.equal(secondParsed.cache.hit, true); + assert.equal(secondParsed.cache.path, firstParsed.cache.path); + + const records = JSON.parse(readFileSync(recordPath, 'utf8')).records; + assert.equal(records.length, 2); + assert.equal(records[0].cwd, records[1].cwd); + assert.equal(records[0].derivedData, records[1].derivedData); + assert.ok(records[0].cwd.includes(join('builds', '.feather-cache', 'ios'))); + assert.ok(records[0].derivedData.includes(join('builds', '.feather-cache', 'ios'))); + assert.equal(records[0].gameLoveExists, true); + assert.equal(records[1].gameLoveExists, true); +}); + +test('build ios --verbose: shows native build steps and xcodebuild output', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Verbose iOS', + version: '1.0.0', + targets: { ios: { loveIosDir: 'love-ios', bundleIdentifier: 'com.example.verboseios' } }, + }); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +const derivedData = args[args.indexOf('-derivedDataPath') + 1]; +const app = path.join(derivedData, 'Build', 'Products', 'Debug-iphonesimulator', 'love-ios.app'); +fs.mkdirSync(app, { recursive: true }); +fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); +console.log('fake xcodebuild ' + args.join(' ')); +process.exit(0); +`); + + const result = run(['build', 'ios', '--dir', dir, '--verbose'], { + env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }), + }); + + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(result.stdout.includes('Building ios in verbose mode')); + assert.ok(result.stdout.includes('iOS workspace')); + assert.ok(result.stdout.includes('xcodebuild')); + assert.ok(result.stdout.includes('fake xcodebuild')); +}); + test('build ios --release: archives, exports IPA, and writes release manifest artifacts', () => { const dir = makeTmp(); writeGame(dir); @@ -1438,6 +2000,7 @@ process.exit(0); assert.equal(result.exitCode, 0, outputOf(result)); assert.equal(ANSI_RE.test(result.stdout), false); const parsed = JSON.parse(result.stdout); + assert.equal(parsed.cache.enabled, false); assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'xcarchive'), true); assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'ipa'), true); assert.equal(existsSync(join(dir, 'builds', 'release-ios-6.0.0-ios.xcarchive')), true); @@ -1454,6 +2017,11 @@ process.exit(0); test('build mobile: missing native template paths fail with actionable errors', () => { const dir = makeTmp(); writeGame(dir); + writeBuildConfig(dir, { + name: 'Missing Mobile Template', + version: '1.0.0', + productId: 'com.example.missingmobiletemplate', + }); const android = run(['build', 'android', '--dir', dir, '--allow-unsafe', '--json']); assert.equal(android.exitCode, 1); diff --git a/docs/configuration.md b/docs/configuration.md index e724a17b..673f707a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -31,6 +31,7 @@ | `appId` | `string` | `""` | Desktop App ID that is allowed to send commands to this game. Copy from **Feather → Settings → Security → Desktop App ID**. Required unless `__DANGEROUS_INSECURE_CONNECTION__` is set. | | `__DANGEROUS_INSECURE_CONNECTION__` | `boolean` | `false` | Explicit opt-in to skip the appId check and accept commands from any Feather desktop. Must be set to acknowledge the security risk (e.g. in a LAN jam build without a fixed App ID). | | `debugger` | `boolean` or `table` | `false` | Enable the step debugger, or pass debugger options such as `debugger.hotReload`. | +| `debugOverlay` | `table` or `false` | enabled in debug | Small in-game “Feather debugger enabled” badge. Use `enabled`, `visible`, `hideKey`, `touchToggle`, and `corner` to customize it. Press `F12` or double-tap the top-right corner to hide/show by default. | | `assetPreview` | `boolean` | `true` | Enable the core Assets tab tracking and previews. Set to `false` to avoid hooking asset loaders and `love.draw`. | | `binaryTextThreshold` | `number` | `4096` | Observer, time-travel, debugger, and console text values longer than this many bytes are sent through the hybrid binary protocol instead of JSON. | diff --git a/docs/installation.md b/docs/installation.md index 78ac48d0..2287ef83 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -45,7 +45,7 @@ USE_DEBUGGER=1 love . ``` > [!TIP] -> `feather run --target android` runs ADB reverse by default, so `host = "127.0.0.1"` can still work over USB. For Wi-Fi devices, Steam Deck, or another computer, use the LAN IP shown in Feather Settings. +> `feather run --target android` runs ADB reverse by default, so `host = "127.0.0.1"` can still work over USB. Mobile dev runs embed Feather and your selected `feather.config.lua` into the temporary `.love` archive; pass `--no-debugger` to build/install raw source instead. For Wi-Fi devices, Steam Deck, or another computer, use the LAN IP shown in Feather Settings. See [CLI](cli.md) for all commands, flags, and `feather.config.lua` options. @@ -71,6 +71,7 @@ Mobile dev run examples: ```bash feather run path/to/my-game --target android feather run path/to/my-game --target android --device emulator-5554 +feather run path/to/my-game --target android --no-debugger feather run path/to/my-game --target ios feather run path/to/my-game --target ios --device ``` diff --git a/src-lua/feather/auto.lua b/src-lua/feather/auto.lua index 372afe92..dd4c1cc8 100644 --- a/src-lua/feather/auto.lua +++ b/src-lua/feather/auto.lua @@ -215,7 +215,7 @@ function auto.setup(config) end if not DEBUGGER then - auto.setup() + auto.setup(rawget(_G, "FEATHER_AUTO_CONFIG")) end return auto diff --git a/src-lua/feather/core/debug_overlay.lua b/src-lua/feather/core/debug_overlay.lua new file mode 100644 index 00000000..7bc5df3d --- /dev/null +++ b/src-lua/feather/core/debug_overlay.lua @@ -0,0 +1,139 @@ +local Class = require(FEATHER_PATH .. ".lib.class") + +local DebugOverlay = Class({}) + +local DEFAULTS = { + enabled = true, + visible = true, + hideKey = "f12", + touchToggle = true, + corner = "top-right", +} + +local function withDefault(value, fallback) + if value == nil then + return fallback + end + return value +end + +function DebugOverlay:init(feather, config) + config = config or {} + if config == false then + config = { enabled = false } + end + + self.feather = feather + self.enabled = withDefault(config.enabled, DEFAULTS.enabled) + self.visible = withDefault(config.visible, DEFAULTS.visible) + self.hideKey = config.hideKey or DEFAULTS.hideKey + self.touchToggle = withDefault(config.touchToggle, DEFAULTS.touchToggle) + self.corner = config.corner or DEFAULTS.corner + self._lastTouchTime = 0 + self._doubleTapWindow = config.doubleTapWindow or 0.35 + self._touchSize = config.touchSize or 96 +end + +function DebugOverlay:toggle() + self.visible = not self.visible +end + +function DebugOverlay:onKeypressed(key) + if not self.enabled then + return + end + if key == self.hideKey then + self:toggle() + end +end + +function DebugOverlay:_isInCorner(x, y) + if not love or not love.graphics then + return false + end + local width, height = love.graphics.getDimensions() + local size = self._touchSize + + if self.corner == "top-left" then + return x <= size and y <= size + elseif self.corner == "bottom-left" then + return x <= size and y >= height - size + elseif self.corner == "bottom-right" then + return x >= width - size and y >= height - size + end + + return x >= width - size and y <= size +end + +function DebugOverlay:onTouchpressed(_id, x, y) + if not self.enabled or not self.touchToggle then + return + end + if not self:_isInCorner(x, y) then + return + end + + local now = love and love.timer and love.timer.getTime and love.timer.getTime() or os.clock() + if now - self._lastTouchTime <= self._doubleTapWindow then + self:toggle() + self._lastTouchTime = 0 + else + self._lastTouchTime = now + end +end + +function DebugOverlay:_status() + local feather = self.feather + if feather.featherDebugger and feather.featherDebugger.paused then + return "paused", { 1.0, 0.78, 0.28, 1 } + end + if feather.mode == "disk" then + return "disk mode", { 0.52, 0.78, 1.0, 1 } + end + if feather.wsConnected then + return "connected", { 0.35, 1.0, 0.55, 1 } + end + if feather.__connState == "authenticating" or feather.__connState == "idle" then + return "connecting", { 0.52, 0.78, 1.0, 1 } + end + return "disconnected", { 1.0, 0.38, 0.38, 1 } +end + +function DebugOverlay:onDraw() + if not self.enabled or not self.visible then + return + end + if not love or not love.graphics then + return + end + + pcall(function() + local g = love.graphics + local text, accent = self:_status() + local label = "Feather debugger enabled · " .. text + local font = g.getFont() + local paddingX, paddingY = 8, 5 + local textWidth = font and font:getWidth(label) or (#label * 7) + local textHeight = font and font:getHeight() or 14 + local width = textWidth + paddingX * 2 + local height = textHeight + paddingY * 2 + local screenWidth = g.getWidth() + local x = math.max(8, screenWidth - width - 10) + local y = 10 + + if g.push then + g.push("all") + end + g.setColor(0.04, 0.05, 0.07, 0.82) + g.rectangle("fill", x, y, width, height, 5, 5) + g.setColor(accent) + g.rectangle("fill", x, y, 4, height, 5, 5) + g.setColor(1, 1, 1, 0.92) + g.print(label, x + paddingX, y + paddingY) + if g.pop then + g.pop() + end + end) +end + +return DebugOverlay diff --git a/src-lua/feather/init.lua b/src-lua/feather/init.lua index 2d4b3dbb..3808a8a6 100644 --- a/src-lua/feather/init.lua +++ b/src-lua/feather/init.lua @@ -14,6 +14,7 @@ local FeatherLogger = require(FEATHER_PATH .. ".core.logger") local FeatherObserver = require(FEATHER_PATH .. ".core.observer") local FeatherPerformance = require(FEATHER_PATH .. ".core.performance") local FeatherAssets = require(FEATHER_PATH .. ".core.assets") +local FeatherDebugOverlay = require(FEATHER_PATH .. ".core.debug_overlay") local FeatherDebugger = require(FEATHER_PATH .. ".debugger") local FeatherUI = require(FEATHER_PATH .. ".ui") local get_current_dir = require(FEATHER_PATH .. ".utils").get_current_dir @@ -85,6 +86,7 @@ local customErrorHandler = errorhandler ---@field assetPreview? boolean Enable core asset tracking and previews (default true) ---@field binaryTextThreshold? number Observer/time-travel strings longer than this are sent as binary text (default 4096) ---@field hotReload? table Top-level hot reload options. Prefer debugger.hotReload for new configs. +---@field debugOverlay? table|boolean Small in-game debugger status badge (default enabled in debug builds) --- Feather constructor ---@param config FeatherConfig function Feather:init(config) @@ -199,6 +201,7 @@ function Feather:init(config) self.pluginManager = FeatherPluginManager(self, self.featherLogger, self.featherObserver) self.pluginManager:hookLoveCallbacks() + self.debugOverlay = FeatherDebugOverlay(self, conf.debugOverlay) ---@type FeatherDebugger self.featherDebugger = FeatherDebugger(self) diff --git a/src-lua/feather/plugin_manager.lua b/src-lua/feather/plugin_manager.lua index 2c34d4d8..ee1ecc2a 100644 --- a/src-lua/feather/plugin_manager.lua +++ b/src-lua/feather/plugin_manager.lua @@ -397,6 +397,16 @@ function FeatherPluginManager:hookLoveCallbacks() original(...) end dispatch(method, ...) + local overlay = mgr.feather and mgr.feather.debugOverlay + if overlay then + if method == "onDraw" and overlay.onDraw then + overlay:onDraw() + elseif method == "onKeypressed" and overlay.onKeypressed then + overlay:onKeypressed(...) + elseif method == "onTouchpressed" and overlay.onTouchpressed then + overlay:onTouchpressed(...) + end + end end self._loveCallbackWrappers[name] = wrapper end diff --git a/src-lua/manifest.txt b/src-lua/manifest.txt index c0e58653..ff772a12 100644 --- a/src-lua/manifest.txt +++ b/src-lua/manifest.txt @@ -1,6 +1,7 @@ core:feather/auto.lua core:feather/core/assets.lua core:feather/core/base.lua +core:feather/core/debug_overlay.lua core:feather/core/logger.lua core:feather/core/observer.lua core:feather/core/performance.lua From ac6bf085db42b16e258ace7d20713bf00d60d7ab Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 18:01:23 -0400 Subject: [PATCH 47/68] lua: remove goto to avoid runtime error --- src-lua/feather/auto.lua | 6 +- src-lua/feather/plugin_manager.lua | 100 +++++++++---------- src-lua/plugins/animation-inspector/init.lua | 75 +++++++------- src-lua/plugins/audio-debug/init.lua | 43 ++++---- src-lua/plugins/coroutine-monitor/init.lua | 27 +++-- src-lua/plugins/entity-inspector/init.lua | 28 +++--- src-lua/plugins/network-inspector/init.lua | 35 ++++--- 7 files changed, 154 insertions(+), 160 deletions(-) diff --git a/src-lua/feather/auto.lua b/src-lua/feather/auto.lua index dd4c1cc8..385cba5f 100644 --- a/src-lua/feather/auto.lua +++ b/src-lua/feather/auto.lua @@ -166,10 +166,7 @@ function auto.setup(config) local plugins = {} for _, entry in ipairs(scanPlugins()) do - if entry.mod and not exclude[entry.id] then - if entry.optIn and not include[entry.id] then - goto continue - end + if entry.mod and not exclude[entry.id] and not (entry.optIn and not include[entry.id]) then local opts = entry.opts if pluginOptions[entry.id] then opts = {} @@ -190,7 +187,6 @@ function auto.setup(config) entry.compatibility ) end - ::continue:: end -- Append any extra user plugins diff --git a/src-lua/feather/plugin_manager.lua b/src-lua/feather/plugin_manager.lua index ee1ecc2a..9d0c0e19 100644 --- a/src-lua/feather/plugin_manager.lua +++ b/src-lua/feather/plugin_manager.lua @@ -167,65 +167,63 @@ function FeatherPluginManager:init(feather, logger, observer) type = "error", str = "Plugin <" .. plugin.identifier .. "> is not compatible: " .. message, }) - goto continue - end - - local ok, pluginInstance = xpcall(plugin.plugin, function(err) - return type(err) == "string" and debug.traceback(err, 2) or debug.traceback(tostring(err), 2) - end, { - options = plugin.options, - feather = feather, - logger = logger, - observer = observer, - api = compatibility.api, - minApi = compatibility.minApi, - maxApi = compatibility.maxApi, - }) - - if ok then - local supported = true - if pluginInstance and pluginInstance.isSupported then - supported = pluginInstance:isSupported(feather.version) - end - table.insert(self.plugins, { - instance = pluginInstance, - identifier = plugin.identifier, - disabled = plugin.disabled or not supported or false, - incompatible = not supported, - incompatibilityReason = (not supported) and describeApiCompatibility(compatibility, feather.version) or nil, - capabilities = plugin.capabilities or {}, - compatibility = compatibility, - name = plugin.name, - version = plugin.version, + else + local ok, pluginInstance = xpcall(plugin.plugin, function(err) + return type(err) == "string" and debug.traceback(err, 2) or debug.traceback(tostring(err), 2) + end, { + options = plugin.options, + feather = feather, + logger = logger, + observer = observer, + api = compatibility.api, + minApi = compatibility.minApi, + maxApi = compatibility.maxApi, }) - if not supported then - self.logger:log({ - type = "error", - str = "Plugin <" .. plugin.identifier .. "> is not compatible: " .. describeApiCompatibility(compatibility, feather.version), + if ok then + local supported = true + if pluginInstance and pluginInstance.isSupported then + supported = pluginInstance:isSupported(feather.version) + end + table.insert(self.plugins, { + instance = pluginInstance, + identifier = plugin.identifier, + disabled = plugin.disabled or not supported or false, + incompatible = not supported, + incompatibilityReason = (not supported) and describeApiCompatibility(compatibility, feather.version) or nil, + capabilities = plugin.capabilities or {}, + compatibility = compatibility, + name = plugin.name, + version = plugin.version, }) - end - -- Warn if a plugin requests a capability not in the user's allowlist - if allowedPerms and allowedPerms ~= "all" then - for _, perm in ipairs(plugin.capabilities or {}) do - if not allowedPerms[perm] then - self.logger:log({ - type = "error", - str = "[Plugin " - .. plugin.identifier - .. "] requests capability '" - .. perm - .. "' which is not in the allowlist", - }) + if not supported then + self.logger:log({ + type = "error", + str = "Plugin <" .. plugin.identifier .. "> is not compatible: " .. describeApiCompatibility(compatibility, feather.version), + }) + end + + -- Warn if a plugin requests a capability not in the user's allowlist + if allowedPerms and allowedPerms ~= "all" then + for _, perm in ipairs(plugin.capabilities or {}) do + if not allowedPerms[perm] then + self.logger:log({ + type = "error", + str = "[Plugin " + .. plugin.identifier + .. "] requests capability '" + .. perm + .. "' which is not in the allowlist", + }) + end end end + else + -- pluginInstance is the formatted error+traceback string from the xpcall handler + self.logger:log({ type = "error", str = tostring(pluginInstance) }) end - else - -- pluginInstance is the formatted error+traceback string from the xpcall handler - self.logger:log({ type = "error", str = tostring(pluginInstance) }) end - ::continue:: end end diff --git a/src-lua/plugins/animation-inspector/init.lua b/src-lua/plugins/animation-inspector/init.lua index fde10108..34329cf7 100644 --- a/src-lua/plugins/animation-inspector/init.lua +++ b/src-lua/plugins/animation-inspector/init.lua @@ -100,48 +100,45 @@ function AnimationInspectorPlugin:handleRequest() local ok, anim = pcall(entry.getter) if ok and anim then local status = anim.status or "?" - if not self.showPaused and status == "paused" then - goto continue - end - - local totalFrames = anim.frames and #anim.frames or 0 - local position = anim.position or 0 - local timer = anim.timer or 0 - local totalDuration = anim.totalDuration or 0 - local currentDuration = (anim.durations and anim.durations[position]) or 0 - local flipped = "" - if anim.flippedH then - flipped = flipped .. "H" - end - if anim.flippedV then - flipped = flipped .. (flipped ~= "" and "+V" or "V") - end - if flipped == "" then - flipped = "—" - end + if self.showPaused or status ~= "paused" then + local totalFrames = anim.frames and #anim.frames or 0 + local position = anim.position or 0 + local timer = anim.timer or 0 + local totalDuration = anim.totalDuration or 0 + local currentDuration = (anim.durations and anim.durations[position]) or 0 + local flipped = "" + if anim.flippedH then + flipped = flipped .. "H" + end + if anim.flippedV then + flipped = flipped .. (flipped ~= "" and "+V" or "V") + end + if flipped == "" then + flipped = "—" + end - -- Get frame dimensions if available - local dims = "?" - if anim.frames and anim.frames[position] then - local pok, w, h = pcall(function() - local _, _, fw, fh = anim.frames[position]:getViewport() - return fw, fh - end) - if pok then - dims = string.format("%dx%d", w, h) + -- Get frame dimensions if available + local dims = "?" + if anim.frames and anim.frames[position] then + local pok, w, h = pcall(function() + local _, _, fw, fh = anim.frames[position]:getViewport() + return fw, fh + end) + if pok then + dims = string.format("%dx%d", w, h) + end end - end - rows[#rows + 1] = { - name = entry.name, - status = status, - frame = string.format("%d/%d", position, totalFrames), - timer = string.format("%.2f/%.2fs", timer, totalDuration), - frameDur = string.format("%.3fs", currentDuration), - flip = flipped, - size = dims, - } - ::continue:: + rows[#rows + 1] = { + name = entry.name, + status = status, + frame = string.format("%d/%d", position, totalFrames), + timer = string.format("%.2f/%.2fs", timer, totalDuration), + frameDur = string.format("%.3fs", currentDuration), + flip = flipped, + size = dims, + } + end else rows[#rows + 1] = { name = entry.name, diff --git a/src-lua/plugins/audio-debug/init.lua b/src-lua/plugins/audio-debug/init.lua index edf0c2c5..40a0be7e 100644 --- a/src-lua/plugins/audio-debug/init.lua +++ b/src-lua/plugins/audio-debug/init.lua @@ -103,30 +103,27 @@ function AudioDebugPlugin:handleRequest() local status = playing and "playing" or "stopped" -- Skip stopped sources if filter is off - if not self.showStopped and not playing then - goto continue - end - - local channels = src:getChannelCount() - local vol = src:getVolume() - local pitch = src:getPitch() - local looping = src:isLooping() - local srcType = src:getType() -- static / stream / queue - local duration = src:getDuration("seconds") - local position = src:tell("seconds") + if self.showStopped or playing then + local channels = src:getChannelCount() + local vol = src:getVolume() + local pitch = src:getPitch() + local looping = src:isLooping() + local srcType = src:getType() -- static / stream / queue + local duration = src:getDuration("seconds") + local position = src:tell("seconds") - rows[#rows + 1] = { - name = entry.label, - type = srcType, - status = status, - volume = string.format("%.2f", vol), - pitch = string.format("%.2f", pitch), - looping = looping and "yes" or "no", - channels = channels == 1 and "mono" or "stereo", - duration = duration >= 0 and string.format("%.1fs", duration) or "?", - position = string.format("%.1fs", position), - } - ::continue:: + rows[#rows + 1] = { + name = entry.label, + type = srcType, + status = status, + volume = string.format("%.2f", vol), + pitch = string.format("%.2f", pitch), + looping = looping and "yes" or "no", + channels = channels == 1 and "mono" or "stereo", + duration = duration >= 0 and string.format("%.1fs", duration) or "?", + position = string.format("%.1fs", position), + } + end end local columns = { diff --git a/src-lua/plugins/coroutine-monitor/init.lua b/src-lua/plugins/coroutine-monitor/init.lua index 364fffcc..337b6b02 100644 --- a/src-lua/plugins/coroutine-monitor/init.lua +++ b/src-lua/plugins/coroutine-monitor/init.lua @@ -197,22 +197,19 @@ function CoroutineMonitorPlugin:handleRequest() end -- Skip dead coroutines if filter is off - if not self.showDead and status == "dead" then - goto continue - end - - local age = now - entry.created - local lastYield = entry.lastYieldTime > 0 and string.format("%.1fs ago", now - entry.lastYieldTime) or "—" + if self.showDead or status ~= "dead" then + local age = now - entry.created + local lastYield = entry.lastYieldTime > 0 and string.format("%.1fs ago", now - entry.lastYieldTime) or "—" - rows[#rows + 1] = { - name = entry.label, - status = status, - yields = tostring(entry.yields), - yieldsFrame = tostring(entry.yieldsThisFrame), - lastYield = lastYield, - age = string.format("%.1fs", age), - } - ::continue:: + rows[#rows + 1] = { + name = entry.label, + status = status, + yields = tostring(entry.yields), + yieldsFrame = tostring(entry.yieldsThisFrame), + lastYield = lastYield, + age = string.format("%.1fs", age), + } + end end local columns = { diff --git a/src-lua/plugins/entity-inspector/init.lua b/src-lua/plugins/entity-inspector/init.lua index 5846edec..a2677af0 100644 --- a/src-lua/plugins/entity-inspector/init.lua +++ b/src-lua/plugins/entity-inspector/init.lua @@ -285,30 +285,32 @@ function EntityInspectorPlugin:handleRequest(_request, _feather) for i, entity in ipairs(entityList) do -- Apply user filter + local includeEntity = true if source.filter then local ok, pass = pcall(source.filter, entity) if ok and not pass then - goto continue + includeEntity = false end end - local node = buildNode(entity, source, i, 1, self.maxDepth, self.maxValueLen, count, self.maxEntities) + if includeEntity then + local node = buildNode(entity, source, i, 1, self.maxDepth, self.maxValueLen, count, self.maxEntities) - if node then - -- Apply search filter (name match) - if filter then - if not node.name:lower():find(filter, 1, true) then - goto continue + if node then + -- Apply search filter (name match) + local matchesSearch = true + if filter then + matchesSearch = node.name:lower():find(filter, 1, true) ~= nil + end + if matchesSearch then + nodes[#nodes + 1] = node end end - nodes[#nodes + 1] = node - end - if count.n >= self.maxEntities then - break + if count.n >= self.maxEntities then + break + end end - - ::continue:: end return { diff --git a/src-lua/plugins/network-inspector/init.lua b/src-lua/plugins/network-inspector/init.lua index a775c2e5..6074371f 100644 --- a/src-lua/plugins/network-inspector/init.lua +++ b/src-lua/plugins/network-inspector/init.lua @@ -387,22 +387,29 @@ function NetworkInspectorPlugin:update() local matchEndpoint = p.endpoint:lower():find(filterLower, 1, true) local matchPayload = p.payload:lower():find(filterLower, 1, true) local matchDir = p.direction:lower():find(filterLower, 1, true) - if not matchEndpoint and not matchPayload and not matchDir then - goto continue + local matches = matchEndpoint or matchPayload or matchDir + if matches then + rows[#rows + 1] = { + id = tostring(p.id), + direction = p.direction == "out" and "→ OUT" or "← IN", + endpoint = p.endpoint, + size = formatSize(p.size), + payload = p.payload, + gameTime = string.format("%.2f", p.gameTime), + status = p.status == "error" and ("✗ " .. (p.error or "error")) or "✓", + } end + else + rows[#rows + 1] = { + id = tostring(p.id), + direction = p.direction == "out" and "→ OUT" or "← IN", + endpoint = p.endpoint, + size = formatSize(p.size), + payload = p.payload, + gameTime = string.format("%.2f", p.gameTime), + status = p.status == "error" and ("✗ " .. (p.error or "error")) or "✓", + } end - - rows[#rows + 1] = { - id = tostring(p.id), - direction = p.direction == "out" and "→ OUT" or "← IN", - endpoint = p.endpoint, - size = formatSize(p.size), - payload = p.payload, - gameTime = string.format("%.2f", p.gameTime), - status = p.status == "error" and ("✗ " .. (p.error or "error")) or "✓", - } - - ::continue:: end return { From 3794868b16de61561f5b8b4fd247495822e05087 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 18:14:01 -0400 Subject: [PATCH 48/68] cli: add web builds --- cli/README.md | 27 +++-- cli/src/commands/build-vendor.ts | 2 + cli/src/commands/doctor/index.ts | 2 +- cli/src/commands/run.ts | 52 ++++++++- cli/src/index.ts | 20 ++-- cli/src/lib/build/build.ts | 12 +- cli/src/lib/build/debug-stage.ts | 8 +- cli/src/lib/build/files.ts | 11 +- cli/src/lib/build/native.ts | 7 +- cli/src/lib/build/vendor.ts | 79 +++++++++---- cli/src/lib/build/web.ts | 8 ++ cli/src/lib/run/web.ts | 187 +++++++++++++++++++++++++++++++ cli/test/commands.test.mjs | 187 ++++++++++++++++++++++++++++++- docs/index.md | 5 +- docs/installation.md | 11 +- docs/recommendations.md | 3 +- src-lua/feather/auto.lua | 68 +++++++---- src-lua/feather/lib/base64.lua | 2 +- src-lua/feather/lib/bit.lua | 88 +++++++++++++++ src-lua/feather/lib/ws.lua | 2 +- src-lua/manifest.txt | 1 + 21 files changed, 693 insertions(+), 89 deletions(-) create mode 100644 cli/src/lib/run/web.ts create mode 100644 src-lua/feather/lib/bit.lua diff --git a/cli/README.md b/cli/README.md index f6ce90f1..ad2193e2 100644 --- a/cli/README.md +++ b/cli/README.md @@ -13,12 +13,13 @@ The Feather CLI lets you run and debug LÖVE games **without modifying your game ```bash feather run feather run path/to/my-game +feather run path/to/my-game --target web feather run path/to/my-game --target android feather run path/to/my-game --target ios ``` > [!IMPORTANT] -> `feather run` launches desktop games directly. For Android and iOS dev loops it builds the configured native template, installs the artifact, and launches it on a connected device or simulator. +> `feather run` launches desktop games directly. For web dev loops it builds and serves a local love.js artifact. For Android and iOS dev loops it builds the configured native template, installs the artifact, and launches it on a connected device or simulator. --- @@ -80,6 +81,9 @@ feather run . --no-debugger # launch without Feather injection feather run . --love /usr/bin/love # override love2d binary feather run . --plugins-dir ./my-plugins # use a custom plugins directory feather run . -- --level dev # pass args through to the game +feather run . --target web # build love.js output and serve it locally +feather run . --target web --web-port 3000 +feather run . --target web --no-debugger # serve raw source without Feather embed feather run . --target android # build, install, adb reverse, and launch Android feather run . --target android --device emulator-5554 feather run . --target android --verbose # show Gradle/ADB commands and output @@ -103,19 +107,21 @@ When `game-path` is omitted in an interactive terminal, Feather opens an Ink wor | `--config ` | Explicit path to a `feather.config.lua` file. | | `--feather-path ` | Use a local feather install instead of the CLI's bundled copy. | | `--plugins-dir ` | Use a custom plugins directory instead of the CLI's bundled plugins. | -| `--target ` | Run target: `desktop`, `android`, or `ios`. Defaults to `desktop`. | +| `--target ` | Run target: `desktop`, `web`, `android`, or `ios`. Defaults to `desktop`. | | `--device ` | Android device serial or iOS simulator UDID. iOS defaults to `booted`. | -| `--build-config ` | Path to `feather.build.json` for mobile run. | -| `--out-dir ` | Build output directory for mobile run. | -| `--clean` | Remove the output directory before the mobile build. | +| `--build-config ` | Path to `feather.build.json` for web/mobile run. | +| `--out-dir ` | Build output directory for web/mobile run. | +| `--clean` | Remove the output directory before the web/mobile build. | | `--no-cache` | Disable Android/iOS dev native build cache for this run. | -| `--verbose` | Show Android/iOS build, install, and launch commands plus native tool output. | +| `--verbose` | Show web/mobile build steps plus Android/iOS install and launch commands. | | `--no-adb-reverse` | Skip Android `adb reverse` setup. | | `--port ` | Port used for Android `adb reverse`; defaults to `feather.config.lua` `port` or `4004`. | +| `--web-host ` | Host used by the web dev server. Defaults to `127.0.0.1`. | +| `--web-port ` | Port used by the web dev server. Defaults to `8000`; use `0` for an OS-assigned port. | Use `--` to separate Feather CLI options from arguments intended for the LÖVE game. Everything after `--` is passed to `love` after the generated shim path. -Mobile run is dev-only in V1 and does not forward game arguments. By default it embeds the bundled Feather runtime, bundled plugins, and the selected `feather.config.lua` into the temporary `.love` archive before installing. Android requires `adb`, a configured `targets.android.loveAndroidDir`, and USB debugging or an emulator. iOS requires macOS, Xcode, a configured `targets.ios.loveIosDir`, and a booted simulator. +Web and mobile run are dev-only in V1 and do not forward game arguments. By default they embed the bundled Feather runtime, bundled plugins, and the selected `feather.config.lua` into the temporary `.love` archive before serving or installing. Web requires a configured `targets.web.loveJsDir`. Android requires `adb`, a configured `targets.android.loveAndroidDir`, and USB debugging or an emulator. iOS requires macOS, Xcode, a configured `targets.ios.loveIosDir`, and a booted simulator. **Project config file:** @@ -360,6 +366,7 @@ Build a LÖVE game into local artifacts. V1 supports `web`, `android`, `ios`, `w ```bash feather build web --dir path/to/my-game +feather build vendor add web --dir path/to/my-game feather build vendor add mobile --dir path/to/my-game feather build android --dir path/to/my-game feather build android --dir path/to/my-game --verbose @@ -378,16 +385,18 @@ feather build web --allow-unsafe Builds read `feather.build.json` from the project root. Missing config is allowed for simple desktop builds, but web builds need a local love.js player directory, mobile builds need local LÖVE native template paths, and uploads need store metadata. -To fetch the mobile native templates locally: +To fetch web/mobile build vendors locally: ```bash +feather build vendor add web feather build vendor add mobile +feather build vendor add all --json feather build vendor add android --ref 11.5 feather build vendor add ios --ref 11.5 --json feather build vendor list ``` -`build vendor add` clones official LÖVE vendor sources into `vendor/` and updates `feather.build.json` by default. Android fetches `love2d/love-android` with submodules. iOS fetches `love2d/love` and installs the matching `love--apple-libraries.zip` into the Xcode tree. The version comes from `loveVersion` or `--ref`, falling back to `11.5`. +`build vendor add` clones LÖVE build vendor sources 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. Mobile versions come from `loveVersion` or `--ref`, falling back to `11.5`; web defaults to the love.js `main` branch unless `--web-ref` or `--ref` is passed. ```json { diff --git a/cli/src/commands/build-vendor.ts b/cli/src/commands/build-vendor.ts index 58de5dcd..800d4fa2 100644 --- a/cli/src/commands/build-vendor.ts +++ b/cli/src/commands/build-vendor.ts @@ -21,6 +21,7 @@ export type BuildVendorCommandOptions = { config?: string; vendorDir?: string; ref?: string; + webRef?: string; androidRef?: string; iosRef?: string; force?: boolean; @@ -49,6 +50,7 @@ export async function buildVendorAddCommand(targetValues: string[], opts: BuildV configPath: opts.config, vendorDir: opts.vendorDir, ref: opts.ref, + webRef: opts.webRef, androidRef: opts.androidRef, iosRef: opts.iosRef, force: opts.force, diff --git a/cli/src/commands/doctor/index.ts b/cli/src/commands/doctor/index.ts index 3cd41245..c4133564 100644 --- a/cli/src/commands/doctor/index.ts +++ b/cli/src/commands/doctor/index.ts @@ -216,7 +216,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) 'love.js player', loveJsDir && existsSync(resolve(projectDir, loveJsDir)) ? 'pass' : 'fail', loveJsDir ?? 'not configured', - 'Set targets.web.loveJsDir in feather.build.json to a local love.js checkout or build output.', + `Run \`feather build vendor add web --dir ${projectDir}\` or set targets.web.loveJsDir in feather.build.json to a local love.js checkout or build output.`, ); } else if (opts.buildTarget === 'android') { const androidConfig = buildConfig.targets.android ?? {}; diff --git a/cli/src/commands/run.ts b/cli/src/commands/run.ts index e4b6ea0a..f4aae6bb 100644 --- a/cli/src/commands/run.ts +++ b/cli/src/commands/run.ts @@ -8,6 +8,7 @@ import { chooseRunWorkflow } from "../ui/run-workflow.js"; import { fail } from "../lib/command.js"; import { printInfo, printKeyValues, printMuted, printStatus } from "../lib/output.js"; import { runMobile, type MobileRunTarget } from "../lib/run/mobile.js"; +import { runWeb } from "../lib/run/web.js"; import { isPathInside } from "../lib/path-safety.js"; export interface RunOptions { @@ -19,13 +20,15 @@ export interface RunOptions { featherPath?: string; pluginsDir?: string; gameArgs?: string[]; - target?: "desktop" | MobileRunTarget; + target?: "desktop" | "web" | MobileRunTarget; device?: string; buildConfig?: string; outDir?: string; clean?: boolean; noCache?: boolean; verbose?: boolean; + webHost?: string; + webPort?: number; adbReverse?: boolean; port?: number; } @@ -33,12 +36,15 @@ export interface RunOptions { export async function runCommand(gamePath: string | undefined, opts: RunOptions): Promise { const target = opts.target ?? "desktop"; const debuggerEnabled = opts.debugger !== false; - if (!["desktop", "android", "ios"].includes(target)) { - fail("Run target must be one of: desktop, android, ios."); + if (!["desktop", "web", "android", "ios"].includes(target)) { + fail("Run target must be one of: desktop, web, android, ios."); } if (opts.port !== undefined && (!Number.isInteger(opts.port) || opts.port < 1 || opts.port > 65535)) { fail("Port must be a number between 1 and 65535."); } + if (opts.webPort !== undefined && (!Number.isInteger(opts.webPort) || opts.webPort < 0 || opts.webPort > 65535)) { + fail("Web port must be a number between 0 and 65535."); + } if (!gamePath) { if (!process.stdin.isTTY) { @@ -85,11 +91,47 @@ export async function runCommand(gamePath: string | undefined, opts: RunOptions) const userConfig = loadConfig(absGame, opts.config) ?? undefined; + if (target === "web") { + if ((opts.gameArgs?.length ?? 0) > 0) { + fail("Web run does not support forwarded game arguments yet."); + } + const buildContext = resolveRunBuildContext(absGame, opts.buildConfig); + try { + printInfo("Feather run web"); + const result = await runWeb({ + projectDir: buildContext.projectDir, + configPath: buildContext.configPath, + sourceDir: buildContext.sourceDir, + outDir: opts.outDir, + clean: opts.clean, + debugger: debuggerEnabled, + runtimeConfigPath: opts.config, + noPlugins: opts.noPlugins, + featherOverride: opts.featherPath, + pluginsOverride: opts.pluginsDir, + verbose: opts.verbose, + host: opts.webHost, + port: opts.webPort, + }); + printStatus("success", "Serving web build"); + printKeyValues([ + ["Game", absGame], + ["HTML", result.htmlDir], + ["URL", result.url], + ["Debugger", result.debugger ? "enabled" : "disabled"], + ]); + await result.wait; + return; + } catch (err) { + fail((err as Error).message, { cause: err }); + } + } + if (target === "android" || target === "ios") { if ((opts.gameArgs?.length ?? 0) > 0) { fail("Mobile run does not support forwarded game arguments yet."); } - const buildContext = resolveMobileBuildContext(absGame, opts.buildConfig); + const buildContext = resolveRunBuildContext(absGame, opts.buildConfig); try { printInfo(`Feather run ${target}`); const result = runMobile({ @@ -185,7 +227,7 @@ export async function runCommand(gamePath: string | undefined, opts: RunOptions) return result.status ?? 0; } -function resolveMobileBuildContext(absGame: string, buildConfig: string | undefined): { +function resolveRunBuildContext(absGame: string, buildConfig: string | undefined): { projectDir: string; configPath?: string; sourceDir?: string; diff --git a/cli/src/index.ts b/cli/src/index.ts index bc49d149..50500d56 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -46,13 +46,15 @@ program program .command('run [game-path] [game-args...]') .description('Inject Feather into a Love2D game and run it') - .option('--target ', 'Run target: desktop, android, or ios', 'desktop') + .option('--target ', 'Run target: desktop, web, android, or ios', 'desktop') .option('--device ', 'Android device serial or iOS simulator UDID') - .option('--build-config ', 'Path to feather.build.json for mobile run') - .option('--out-dir ', 'Build output directory for mobile run') - .option('--clean', 'Remove the output directory before mobile build') + .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 Android/iOS build commands and native tool output') + .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)') @@ -67,13 +69,15 @@ program .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' | 'android' | 'ios' | undefined, + target: opts.target as 'desktop' | 'web' | 'android' | 'ios' | undefined, device: opts.device as string | undefined, buildConfig: opts.buildConfig as string | undefined, outDir: opts.outDir as string | undefined, clean: opts.clean as boolean | undefined, noCache: opts.cache === false, verbose: opts.verbose as boolean | undefined, + webHost: opts.webHost as string | undefined, + webPort: opts.webPort as number | undefined, adbReverse: opts.adbReverse as boolean | undefined, port: opts.port as number | undefined, sessionName: opts.sessionName as string | undefined, @@ -229,12 +233,13 @@ const buildVendor = build buildVendor .command('add [targets...]') - .description('Fetch build vendors: android, ios, mobile, or all') + .description('Fetch build vendors: web, android, ios, mobile, or all') .allowUnknownOption() .option('--dir ', 'Project directory (default: current directory)') .option('--config ', 'Path to feather.build.json') .option('--vendor-dir ', 'Vendor directory inside the project', 'vendor') .option('--ref ', 'LÖVE version/tag/ref for all vendors') + .option('--web-ref ', 'love.js vendor branch/tag/ref override') .option('--android-ref ', 'Android vendor branch/tag/ref override') .option('--ios-ref ', 'iOS vendor branch/tag/ref override') .option('--force', 'Replace existing vendor directories or conflicting config paths') @@ -246,6 +251,7 @@ buildVendor 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, diff --git a/cli/src/lib/build/build.ts b/cli/src/lib/build/build.ts index f7be0e48..79454011 100644 --- a/cli/src/lib/build/build.ts +++ b/cli/src/lib/build/build.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { assertNoSymlinkEscape } from '../path-safety.js'; import { @@ -6,6 +6,7 @@ import { fileSize, latestManifestPath, listProjectFiles, + removePath, stageProject, writeJson, type BuildArtifact, @@ -35,6 +36,7 @@ export type BuildOptions = LoadBuildConfigOptions & { release?: boolean; noCache?: boolean; debugger?: boolean; + embedDebugger?: boolean; runtimeConfigPath?: string; noPlugins?: boolean; featherOverride?: string; @@ -121,18 +123,18 @@ export function runBuild(options: BuildOptions): BuildResult { if (options.dryRun) return planBuild(options); const log = options.verbose ? options.log : undefined; const mobileDevBuild = (options.target === 'android' || options.target === 'ios') && !options.release; + const debuggerEmbedBuild = options.embedDebugger === true || mobileDevBuild; if (options.clean && mobileDevBuild && !options.noCache) { log?.('Build cache: reset by --clean'); } - if (options.clean) rmSync(config.outDir, { recursive: true, force: true }); + if (options.clean) removePath(config.outDir); mkdirSync(config.outDir, { recursive: true }); const staged = stageProject(config); log?.(`Staged ${staged.files.length} files from ${config.sourceDir}`); try { let cache: NativeCacheInfo | undefined; - const mobileDevBuild = (options.target === 'android' || options.target === 'ios') && !options.release; - const debugStage = mobileDevBuild + const debugStage = debuggerEmbedBuild ? embedMobileDebuggerStage(config, staged.dir, { enabled: options.debugger !== false, runtimeConfigPath: options.runtimeConfigPath, @@ -143,7 +145,7 @@ export function runBuild(options: BuildOptions): BuildResult { : undefined; if (debugStage?.enabled) { log?.(`Embedded Feather debugger runtime${debugStage.configPath ? ` with ${debugStage.configPath}` : ' with generated dev config'}`); - } else if (mobileDevBuild) { + } else if (debuggerEmbedBuild) { log?.('Feather debugger embedding: disabled'); } const artifacts = options.target === 'web' diff --git a/cli/src/lib/build/debug-stage.ts b/cli/src/lib/build/debug-stage.ts index 5a5d2b14..fbc4d0fa 100644 --- a/cli/src/lib/build/debug-stage.ts +++ b/cli/src/lib/build/debug-stage.ts @@ -31,11 +31,11 @@ export function embedMobileDebuggerStage( const originalMain = join(stageDir, 'main.lua'); if (!existsSync(originalMain)) { - throw new Error('Mobile debugger embedding requires main.lua in the staged game.'); + throw new Error('Feather debugger embedding requires main.lua in the staged game.'); } const relocatedMain = join(stageDir, '.feather-main.lua'); if (existsSync(relocatedMain)) { - throw new Error('Mobile debugger embedding cannot use .feather-main.lua because that file already exists in the game.'); + throw new Error('Feather debugger embedding cannot use .feather-main.lua because that file already exists in the game.'); } const featherDir = featherRoot(options.featherOverride); @@ -92,7 +92,7 @@ function resolveRuntimeConfig(config: ResolvedBuildConfig, override: string | un function generatedMobileConfig(name: string): string { return [ '-- feather.config.lua', - '-- Generated only inside Feather mobile dev build staging.', + '-- Generated only inside Feather dev build staging.', '-- The source project is not modified.', 'return {', ` sessionName = ${JSON.stringify(name)},`, @@ -115,7 +115,7 @@ function generatedMobileConfig(name: string): string { function mobileMainWrapper(): string { return [ - '-- Feather mobile debugger injector - generated in build staging only.', + '-- Feather debugger injector - generated in build staging only.', 'FEATHER_PATH = "feather"', 'FEATHER_PLUGIN_PATH = ""', '', diff --git a/cli/src/lib/build/files.ts b/cli/src/lib/build/files.ts index a7810f63..3a430903 100644 --- a/cli/src/lib/build/files.ts +++ b/cli/src/lib/build/files.ts @@ -20,6 +20,15 @@ export type StagedProject = { cleanup: () => void; }; +export function removePath(path: string): void { + rmSync(path, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100, + }); +} + function patternToRegExp(pattern: string): RegExp { const normalized = pattern.replace(/\\/g, '/').replace(/^\/+/, ''); const escaped = normalized @@ -83,7 +92,7 @@ export function stageProject(config: ResolvedBuildConfig): StagedProject { return { dir: stageDir, files, - cleanup: () => rmSync(root, { recursive: true, force: true }), + cleanup: () => removePath(root), }; } diff --git a/cli/src/lib/build/native.ts b/cli/src/lib/build/native.ts index 82d81644..50d7db12 100644 --- a/cli/src/lib/build/native.ts +++ b/cli/src/lib/build/native.ts @@ -7,13 +7,12 @@ import { readdirSync, readFileSync, realpathSync, - rmSync, writeFileSync, type Dirent, } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve, sep } from 'node:path'; -import { copyDirectory } from './files.js'; +import { copyDirectory, removePath } from './files.js'; export type NativeBuildLogger = (message: string) => void; @@ -53,7 +52,7 @@ export function createNativeWorkspace( const hit = existsSync(dir) && cache.requiredPaths?.every((path) => existsSync(join(dir, path))) !== false; logNativeStep(cache.log, `Build cache: ${hit ? 'hit' : 'miss'} ${root}`); if (!hit) { - rmSync(root, { recursive: true, force: true }); + removePath(root); mkdirSync(root, { recursive: true }); copyDirectory(templateDir, dir); } @@ -72,7 +71,7 @@ export function createNativeWorkspace( root, dir, cache: { enabled: false, hit: false }, - cleanup: () => rmSync(root, { recursive: true, force: true }), + cleanup: () => removePath(root), }; } diff --git a/cli/src/lib/build/vendor.ts b/cli/src/lib/build/vendor.ts index 42caaa09..29849def 100644 --- a/cli/src/lib/build/vendor.ts +++ b/cli/src/lib/build/vendor.ts @@ -16,15 +16,16 @@ import { } from './config.js'; import { assertNoSymlinkEscape, assertSafeRelativePath, isPathInside } from '../path-safety.js'; -export const buildVendorTargets = ['android', 'ios', 'mobile', 'all'] as const; +export const buildVendorTargets = ['web', 'android', 'ios', 'mobile', 'all'] as const; export type BuildVendorTargetInput = typeof buildVendorTargets[number]; -export type ConcreteBuildVendorTarget = 'android' | 'ios'; +export type ConcreteBuildVendorTarget = 'web' | 'android' | 'ios'; export type BuildVendorAddOptions = { projectDir?: string; configPath?: string; vendorDir?: string; ref?: string; + webRef?: string; androidRef?: string; iosRef?: string; force?: boolean; @@ -77,6 +78,8 @@ export type BuildVendorListResult = { }; const DEFAULT_LOVE_VERSION = '11.5'; +const DEFAULT_LOVE_JS_REF = 'main'; +const LOVE_JS_REPO = 'https://github.com/2dengine/love.js'; const LOVE_ANDROID_REPO = 'https://github.com/love2d/love-android'; const LOVE_REPO = 'https://github.com/love2d/love'; @@ -102,9 +105,7 @@ export async function addBuildVendors(targets: BuildVendorTargetInput[], options configPath, vendorDir, loveVersion, - ref: target === 'android' - ? sanitizeRef(options.androidRef ?? options.ref ?? config.loveVersion ?? DEFAULT_LOVE_VERSION, 'Android vendor ref') - : sanitizeRef(options.iosRef ?? options.ref ?? config.loveVersion ?? DEFAULT_LOVE_VERSION, 'iOS vendor ref'), + ref: vendorRef(target, options, config.loveVersion), force: Boolean(options.force), dryRun: Boolean(options.dryRun), updateConfig: options.updateConfig !== false, @@ -126,7 +127,7 @@ export function listBuildVendors(options: BuildVendorListOptions = {}): BuildVen const raw = readBuildConfig(projectDir, options.configPath); const configPath = buildConfigPath(projectDir, options.configPath); const vendorDir = resolveVendorDir(projectDir, options.vendorDir ?? 'vendor'); - const vendors = (['android', 'ios'] as const).map((target) => vendorStatus(projectDir, raw, vendorDir, target)); + const vendors = (['web', 'android', 'ios'] as const).map((target) => vendorStatus(projectDir, raw, vendorDir, target)); return { ok: true, projectDir, configPath, vendors }; } @@ -137,6 +138,7 @@ function expandVendorTargets(targets: BuildVendorTargetInput[]): ConcreteBuildVe if (target === 'mobile' || target === 'all') { expanded.add('android'); expanded.add('ios'); + if (target === 'all') expanded.add('web'); } else { expanded.add(target); } @@ -158,19 +160,15 @@ type AddSingleVendorInput = { }; async function addSingleVendor(input: AddSingleVendorInput): Promise { - const defaultRelativePath = input.target === 'android' - ? relativeProjectPath(input.projectDir, join(input.vendorDir, 'love-android')) - : relativeProjectPath(input.projectDir, join(input.vendorDir, 'love-ios')); - const configuredPath = input.target === 'android' - ? input.raw.targets?.android?.loveAndroidDir - : input.raw.targets?.ios?.loveIosDir; + const defaultRelativePath = defaultVendorRelativePath(input.projectDir, input.vendorDir, input.target); + const configuredPath = configuredVendorPath(input.raw, input.target); if (configuredPath && configuredPath !== defaultRelativePath && !input.force) { throw new Error(`${input.target} vendor is already configured at ${configuredPath}. Use --force to replace it with ${defaultRelativePath}.`); } const targetPath = resolveProjectVendorPath(input.projectDir, configuredPath && !input.force ? configuredPath : defaultRelativePath, `${input.target} vendor directory`); const relativePath = relativeProjectPath(input.projectDir, targetPath); - const repo = input.target === 'android' ? LOVE_ANDROID_REPO : LOVE_REPO; + const repo = vendorRepo(input.target); const actions: string[] = []; if (existsSync(targetPath) && !input.force) { @@ -212,17 +210,13 @@ async function addSingleVendor(input: AddSingleVendorInput): Promise/i.test(next)) { next = next.replace(/]*>/i, (match) => `${match}${escapeHtml(title)}`); } + next = normalizeBaseHref(next); next = next.replace(/player(?:\.min)?\.js(?:\?g=[^"']*)?/g, (match) => { const script = match.startsWith('player.min') ? 'player.min.js' : 'player.js'; return `${script}?g=game.love`; @@ -48,6 +49,13 @@ function patchLoveJsIndex(indexPath: string, title: string): void { writeFileSync(indexPath, next); } +function normalizeBaseHref(html: string): string { + if (//i, ''); + } + return html; +} + function escapeHtml(value: string): string { return value.replace(/[&<>"']/g, (char) => ({ '&': '&', diff --git a/cli/src/lib/run/web.ts b/cli/src/lib/run/web.ts new file mode 100644 index 00000000..d5b23536 --- /dev/null +++ b/cli/src/lib/run/web.ts @@ -0,0 +1,187 @@ +import { createReadStream, existsSync, statSync } from 'node:fs'; +import { createServer, type ServerResponse } from 'node:http'; +import { extname, join, normalize, relative, resolve, sep } from 'node:path'; +import type { BuildArtifact } from '../build/files.js'; +import { runBuild } from '../build/build.js'; +import type { LoadBuildConfigOptions } from '../build/config.js'; +import { printMuted } from '../output.js'; + +export type WebRunOptions = LoadBuildConfigOptions & { + clean?: boolean; + debugger?: boolean; + runtimeConfigPath?: string; + noPlugins?: boolean; + featherOverride?: string; + pluginsOverride?: string; + verbose?: boolean; + host?: string; + port?: number; +}; + +export type WebRunResult = { + target: 'web'; + projectDir: string; + htmlDir: string; + url: string; + host: string; + port: number; + debugger: boolean; + wait: Promise; +}; + +export async function runWeb(options: WebRunOptions): Promise { + const buildResult = runBuild({ + target: 'web', + projectDir: options.projectDir, + configPath: options.configPath, + sourceDir: options.sourceDir, + outDir: options.outDir, + clean: options.clean, + allowUnsafe: true, + embedDebugger: true, + debugger: options.debugger, + runtimeConfigPath: options.runtimeConfigPath, + noPlugins: options.noPlugins, + featherOverride: options.featherOverride, + pluginsOverride: options.pluginsOverride, + verbose: options.verbose, + log: options.verbose ? printMuted : undefined, + }); + + if (!buildResult.ok) { + throw new Error(buildResult.error); + } + + const htmlDir = requireArtifact(buildResult.artifacts, 'html', 'Web HTML'); + const host = options.host ?? '127.0.0.1'; + const requestedPort = options.port ?? 8000; + const { url, port, close } = process.env.FEATHER_TEST_WEB_RUN_NO_SERVER === '1' + ? { url: `http://${host}:${requestedPort}/`, port: requestedPort, close: () => {} } + : await serveStatic(htmlDir, host, requestedPort); + + const shutdown = () => { + close(); + process.exit(0); + }; + process.once('SIGINT', shutdown); + process.once('SIGTERM', shutdown); + + return { + target: 'web', + projectDir: buildResult.projectDir, + htmlDir, + url, + host, + port, + debugger: options.debugger !== false, + wait: new Promise(() => {}), + }; +} + +function requireArtifact(artifacts: BuildArtifact[], type: string, label: string): string { + const artifact = artifacts.find((item) => item.type === type); + if (!artifact || !existsSync(artifact.path)) { + throw new Error(`${label} artifact was not found after build.`); + } + return artifact.path; +} + +async function serveStatic(root: string, host: string, port: number): Promise<{ url: string; port: number; close: () => void }> { + const rootPath = resolve(root); + const server = createServer((request, response) => { + const url = new URL(request.url ?? '/', `http://${host}`); + const pathname = decodeURIComponent(url.pathname); + const filePath = resolvePath(rootPath, pathname); + if (!filePath) { + respondText(response, 403, 'Forbidden'); + return; + } + + const target = existsSync(filePath) && statSync(filePath).isDirectory() + ? join(filePath, 'index.html') + : filePath; + + if (!existsSync(target) || !statSync(target).isFile()) { + respondText(response, 404, 'Not found'); + return; + } + + response.writeHead(200, responseHeaders(target)); + createReadStream(target).pipe(response); + }); + + await new Promise((resolveListen, rejectListen) => { + server.once('error', rejectListen); + server.listen(port, host, () => { + server.off('error', rejectListen); + resolveListen(); + }); + }); + + const address = server.address(); + const actualPort = typeof address === 'object' && address ? address.port : port; + return { + url: `http://${host}:${actualPort}/`, + port: actualPort, + close: () => server.close(), + }; +} + +function resolvePath(root: string, pathname: string): string | null { + const normalizedPath = normalize(pathname).replace(/^(\.\.[/\\])+/, ''); + const filePath = resolve(root, `.${sep}${normalizedPath}`); + const rel = relative(root, filePath); + if (rel.startsWith('..') || rel === '..' || rel.includes(`..${sep}`)) return null; + return filePath; +} + +function respondText(response: ServerResponse, status: number, body: string): void { + response.writeHead(status, { + ...devServerHeaders(), + 'content-type': 'text/plain; charset=utf-8', + }); + response.end(body); +} + +function responseHeaders(path: string): Record { + return { + ...devServerHeaders(), + 'content-type': contentType(path), + }; +} + +function devServerHeaders(): Record { + return { + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + 'Cross-Origin-Resource-Policy': 'same-origin', + 'Content-Security-Policy': [ + "default-src 'self' data: blob:", + "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "media-src 'self' data: blob:", + "font-src 'self' data:", + "connect-src 'self' ws: wss: http: https:", + "worker-src 'self' blob:", + ].join('; '), + }; +} + +function contentType(path: string): string { + switch (extname(path).toLowerCase()) { + case '.html': return 'text/html; charset=utf-8'; + case '.js': return 'text/javascript; charset=utf-8'; + case '.css': return 'text/css; charset=utf-8'; + case '.json': return 'application/json; charset=utf-8'; + case '.wasm': return 'application/wasm'; + case '.data': return 'application/octet-stream'; + case '.mem': return 'application/octet-stream'; + case '.love': return 'application/octet-stream'; + case '.png': return 'image/png'; + case '.jpg': + case '.jpeg': return 'image/jpeg'; + case '.svg': return 'image/svg+xml'; + default: return 'application/octet-stream'; + } +} diff --git a/cli/test/commands.test.mjs b/cli/test/commands.test.mjs index eec8c3d0..77a4b460 100644 --- a/cli/test/commands.test.mjs +++ b/cli/test/commands.test.mjs @@ -5,7 +5,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { chmodSync, @@ -45,6 +45,60 @@ function run(args, extra = {}) { }; } +function spawnCli(args, extra = {}) { + const child = spawn(process.execPath, [CLI, ...args], { + env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, + ...extra, + }); + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + return child; +} + +function waitForOutput(child, pattern, timeoutMs = 10000) { + return new Promise((resolveWait, rejectWait) => { + let output = ''; + const timer = setTimeout(() => { + rejectWait(new Error(`Timed out waiting for ${pattern}. Output:\n${output}`)); + }, timeoutMs); + const onData = (chunk) => { + output += chunk; + if (pattern.test(output)) { + clearTimeout(timer); + cleanup(); + resolveWait(output); + } + }; + const onExit = (code) => { + clearTimeout(timer); + cleanup(); + rejectWait(new Error(`Process exited with ${code} before ${pattern}. Output:\n${output}`)); + }; + const cleanup = () => { + child.stdout.off('data', onData); + child.stderr.off('data', onData); + child.off('exit', onExit); + }; + child.stdout.on('data', onData); + child.stderr.on('data', onData); + child.once('exit', onExit); + }); +} + +function stopChild(child) { + return new Promise((resolveStop) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolveStop(); + return; + } + child.once('exit', () => resolveStop()); + child.kill('SIGTERM'); + setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + }, 2000).unref(); + }); +} + function outputOf(result) { return `${result.stdout}\n${result.stderr}`; } @@ -183,7 +237,10 @@ if (args[0] !== 'clone') { const target = args[args.length - 1]; const repo = args[args.length - 2]; fs.mkdirSync(target, { recursive: true }); -if (repo.includes('love-android')) { +if (repo.includes('love.js')) { + fs.writeFileSync(path.join(target, 'index.html'), ''); + fs.writeFileSync(path.join(target, 'player.js'), 'console.log("love.js");\\n'); +} else if (repo.includes('love-android')) { fs.writeFileSync(path.join(target, 'gradlew'), '#!/bin/sh\\n'); fs.mkdirSync(path.join(target, 'app', 'src', 'embed', 'assets'), { recursive: true }); } else if (repo.includes('love')) { @@ -608,7 +665,7 @@ test('run --target android: uses root build config for a nested game path', () = productId: 'com.example.nestedandroid', targets: { android: { loveAndroidDir: 'love-android' } }, }); - const { binDir, recordPath } = writeFakeAdb(dir); + const { binDir } = writeFakeAdb(dir); const result = run(['run', gameDir, '--target', 'android', '--no-adb-reverse'], { cwd: dir, @@ -1146,7 +1203,6 @@ test('doctor --production fails unmanaged embedded runtime', () => { test('build web: creates love archive, love.js html package, zip, and manifest', () => { const dir = makeTmp(); writeGame(dir); - const loveJsDir = writeFakeLoveJs(dir); writeBuildConfig(dir, { name: 'Command Game', version: '1.2.3', @@ -1167,10 +1223,96 @@ test('build web: creates love archive, love.js html package, zip, and manifest', const index = readFileSync(join(dir, 'builds', 'command-game-1.2.3-html', 'index.html'), 'utf8'); assert.ok(index.includes('Command Game')); assert.ok(index.includes('player.min.js?g=game.love')); + assert.equal(index.includes('href="/play/"'), false); const manifest = JSON.parse(readFileSync(join(dir, 'builds', 'feather-build-manifest.json'), 'utf8')); assert.equal(manifest.target, 'web'); }); +test('run --target web: builds, embeds Feather, serves generated html, and stays running', async () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeFileSync(join(dir, 'feather.config.lua'), `return { + sessionName = "Web Custom", + __DANGEROUS_INSECURE_CONNECTION__ = true, + debugOverlay = { visible = false }, +} +`); + writeBuildConfig(dir, { + name: 'Run Web', + version: '1.0.0', + targets: { web: { loveJsDir: 'love.js' } }, + }); + + const child = spawnCli(['run', dir, '--target', 'web', '--web-port', '0', '--config', join(dir, 'feather.config.lua')], { + env: envWithPath('', { FEATHER_TEST_WEB_RUN_NO_SERVER: '1' }), + }); + try { + const output = await waitForOutput(child, /Debugger\s+enabled/); + assert.match(output, /Serving web build/); + assert.match(output, /Debugger\s+enabled/); + assert.equal(existsSync(join(dir, 'builds', 'run-web-1.0.0-html', 'index.html')), true); + const entries = readStoredZipEntries(join(dir, 'builds', 'run-web-1.0.0.love')); + assert.equal(entries.has('main.lua'), true); + assert.equal(entries.has('.feather-main.lua'), true); + assert.equal(entries.has('feather/auto.lua'), true); + assert.equal(entries.has('feather/core/debug_overlay.lua'), true); + assert.match(entries.get('feather.config.lua').toString('utf8'), /sessionName\s*=\s*"Web Custom"/); + assert.match(entries.get('feather.config.lua').toString('utf8'), /debugOverlay\s*=\s*\{/); + } finally { + await stopChild(child); + } +}); + +test('run --target web --no-debugger: builds and serves raw source', async () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeBuildConfig(dir, { + name: 'Run Web Raw', + version: '1.0.0', + targets: { web: { loveJsDir: 'love.js' } }, + }); + + const child = spawnCli(['run', dir, '--target', 'web', '--web-port', '0', '--no-debugger'], { + env: envWithPath('', { FEATHER_TEST_WEB_RUN_NO_SERVER: '1' }), + }); + try { + const output = await waitForOutput(child, /Debugger\s+disabled/); + assert.match(output, /Debugger\s+disabled/); + const entries = readStoredZipEntries(join(dir, 'builds', 'run-web-raw-1.0.0.love')); + assert.equal(entries.has('main.lua'), true); + assert.equal(entries.has('.feather-main.lua'), false); + assert.equal(entries.has('feather/auto.lua'), false); + } finally { + await stopChild(child); + } +}); + +test('run --target web: rejects forwarded game arguments', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeBuildConfig(dir, { + name: 'Run Web Args', + version: '1.0.0', + targets: { web: { loveJsDir: 'love.js' } }, + }); + + const result = run(['run', dir, '--target', 'web', '--', '--foo']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Web run does not support forwarded game arguments yet.')); +}); + +test('run --target web: missing love.js config exits with web build guidance', () => { + const dir = makeTmp(); + writeGame(dir); + + const result = run(['run', dir, '--target', 'web']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Web build requires targets.web.loveJsDir')); +}); + test('build linux: delegates desktop packaging to love-release and writes manifest', () => { const dir = makeTmp(); writeGame(dir); @@ -1294,6 +1436,28 @@ test('build vendor add android --json: clones vendor and updates config', () => assert.ok(records.some((record) => record.args.includes('https://github.com/love2d/love-android'))); }); +test('build vendor add web --json: clones love.js vendor and updates config', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Vendor Web', version: '1.0.0', loveVersion: '11.5' }); + const { binDir, recordPath } = writeFakeVendorGit(dir); + + const result = run(['build', 'vendor', 'add', 'web', '--dir', dir, '--json'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.vendors[0].target, 'web'); + assert.equal(parsed.vendors[0].relativePath, 'vendor/love.js'); + assert.equal(parsed.vendors[0].ref, 'main'); + assert.equal(parsed.vendors[0].configUpdated, true); + assert.equal(existsSync(join(dir, 'vendor', 'love.js', 'index.html')), true); + assert.equal(existsSync(join(dir, 'vendor', 'love.js', 'player.js')), true); + const config = JSON.parse(readFileSync(join(dir, 'feather.build.json'), 'utf8')); + assert.equal(config.targets.web.loveJsDir, 'vendor/love.js'); + const records = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.ok(records.some((record) => record.args.includes('https://github.com/2dengine/love.js'))); +}); + test('build vendor add ios --json: clones vendor, installs Apple libraries, and updates config', async () => { const dir = makeTmp(); writeGame(dir); @@ -1330,6 +1494,18 @@ test('build vendor add mobile --dry-run --json: reports planned vendors without assert.equal(existsSync(join(dir, 'feather.build.json')), false); }); +test('build vendor add all --dry-run --json: includes web and mobile vendors', () => { + const dir = makeTmp(); + writeGame(dir); + + const result = run(['build', 'vendor', 'add', 'all', '--dir', dir, '--dry-run', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.deepEqual(parsed.vendors.map((vendor) => vendor.target), ['android', 'ios', 'web']); + assert.equal(existsSync(join(dir, 'vendor')), false); +}); + test('build vendor add --no-config: fetches vendor without writing build config', () => { const dir = makeTmp(); writeGame(dir); @@ -1367,9 +1543,11 @@ test('build vendor add: existing directories and conflicting config require --fo test('build vendor list --json: reports configured, missing, and valid vendors', () => { const dir = makeTmp(); writeGame(dir); + writeFakeLoveJs(dir); writeFakeLoveAndroid(dir); writeBuildConfig(dir, { targets: { + web: { loveJsDir: 'love.js' }, android: { loveAndroidDir: 'love-android' }, ios: { loveIosDir: 'vendor/love-ios' }, }, @@ -1380,6 +1558,7 @@ test('build vendor list --json: reports configured, missing, and valid vendors', assert.equal(ANSI_RE.test(result.stdout), false); const parsed = JSON.parse(result.stdout); const labels = new Map(parsed.vendors.map((vendor) => [vendor.target, vendor])); + assert.equal(labels.get('web').valid, true); assert.equal(labels.get('android').valid, true); assert.equal(labels.get('ios').exists, false); assert.equal(labels.get('ios').detail, 'missing'); diff --git a/docs/index.md b/docs/index.md index 0528ed60..cce534a5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,7 +29,7 @@ Like Flipper or React DevTools, but for your game. Inspect logs, variables, perf ## Quick Start > [!IMPORTANT] -> For quick local desktop iteration, use `feather run path/to/my-game` without changing game code. For Android and iOS dev loops, `feather run path/to/my-game --target android|ios` builds the configured native template, installs it, and launches it on a device or simulator. +> For quick local desktop iteration, use `feather run path/to/my-game` without changing game code. For web dev loops, `feather run path/to/my-game --target web` builds and serves a local love.js artifact. For Android and iOS dev loops, `feather run path/to/my-game --target android|ios` builds the configured native template, installs it, and launches it on a device or simulator. ### Option A — CLI injection (no game-side changes) @@ -37,6 +37,7 @@ Like Flipper or React DevTools, but for your game. Inspect logs, variables, perf npm install -g @kyonru/feather feather init path/to/my-game feather run path/to/my-game +feather run path/to/my-game --target web feather run path/to/my-game --target android ``` @@ -54,7 +55,7 @@ USE_DEBUGGER=1 love path/to/my-game ``` > [!IMPORTANT] -> Use this for handhelds, Steam Deck, or a second computer. Android/iOS can also use `feather run --target android|ios` once mobile build templates are configured. +> Use this for handhelds, Steam Deck, or a second computer. Web can use `feather run --target web` once love.js is configured, and Android/iOS can use `feather run --target android|ios` once mobile build templates are configured. `feather init` creates `feather.config.lua`: diff --git a/docs/installation.md b/docs/installation.md index 2287ef83..3f105f57 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -12,7 +12,7 @@ feather run path/to/my-game A new session tab appears in the Feather desktop app automatically. No `require` calls, no `DEBUGGER:update(dt)` — the CLI handles everything. > [!NOTE] -> Use plain `feather run` for local desktop iteration where the CLI launches LÖVE directly. +> Use plain `feather run` for local desktop iteration where the CLI launches LÖVE directly. Use `feather run --target web` when you want a served love.js dev artifact. > [!IMPORTANT] > For Android and iOS dev loops, `feather run path/to/my-game --target android|ios` can build the configured native template, install it, and launch it. For handhelds, Steam Deck, or another computer, embed Feather into the game instead. @@ -45,7 +45,7 @@ USE_DEBUGGER=1 love . ``` > [!TIP] -> `feather run --target android` runs ADB reverse by default, so `host = "127.0.0.1"` can still work over USB. Mobile dev runs embed Feather and your selected `feather.config.lua` into the temporary `.love` archive; pass `--no-debugger` to build/install raw source instead. For Wi-Fi devices, Steam Deck, or another computer, use the LAN IP shown in Feather Settings. +> `feather run --target android` runs ADB reverse by default, so `host = "127.0.0.1"` can still work over USB. Web and mobile dev runs embed Feather and your selected `feather.config.lua` into the temporary `.love` archive; pass `--no-debugger` to build/serve/install raw source instead. For Wi-Fi devices, Steam Deck, or another computer, use the LAN IP shown in Feather Settings. See [CLI](cli.md) for all commands, flags, and `feather.config.lua` options. @@ -64,11 +64,14 @@ feather doctor path/to/my-game --upload-target itch `feather build` supports web, Android, iOS, Windows, macOS, Linux, and SteamOS artifacts. Web builds need a local love.js player directory configured in `feather.build.json`; Android/iOS builds need local love-android or LÖVE iOS template paths; desktop builds expect `love-release` on `PATH`. `feather upload itch` expects Butler on `PATH` and either `BUTLER_API_KEY` in CI or a local `butler login` session. -For mobile setup, run `feather build vendor add mobile --dir path/to/my-game` to fetch official LÖVE template checkouts into `vendor/` and update `feather.build.json`. Then run `feather doctor path/to/my-game --build-target android` or `--build-target ios`. Doctor checks the native template path, product/bundle id, local build tools, and the common environment variables before the build or run command stages or writes artifacts. Vendor fetching does not install Android SDK, JDK, Xcode, or signing assets. +For web/mobile setup, run `feather build vendor add web --dir path/to/my-game` for love.js or `feather build vendor add mobile --dir path/to/my-game` for Android/iOS templates. Both commands fetch vendor checkouts into `vendor/` and update `feather.build.json`. Then run `feather doctor path/to/my-game --build-target web`, `--build-target android`, or `--build-target ios`. Doctor checks the configured vendor paths, product/bundle id where relevant, local build tools, and the common environment variables before the build or run command stages or writes artifacts. Vendor fetching does not install Android SDK, JDK, Xcode, or signing assets. -Mobile dev run examples: +Web/mobile dev run examples: ```bash +feather run path/to/my-game --target web +feather run path/to/my-game --target web --web-port 3000 +feather run path/to/my-game --target web --no-debugger feather run path/to/my-game --target android feather run path/to/my-game --target android --device emulator-5554 feather run path/to/my-game --target android --no-debugger diff --git a/docs/recommendations.md b/docs/recommendations.md index d13441ec..22ba9014 100644 --- a/docs/recommendations.md +++ b/docs/recommendations.md @@ -116,11 +116,12 @@ If you use Feather's local release helpers, keep release metadata in `feather.bu ```bash feather build web --dir path/to/my-game --json +feather build vendor add web --dir path/to/my-game --json feather build vendor add mobile --dir path/to/my-game --json feather upload itch web --dir path/to/my-game --dry-run --json ``` -Web builds package a local love.js player; `build vendor add mobile` fetches official Android/iOS LÖVE templates into `vendor/`; desktop targets delegate packaging to `love-release`; Itch uploads use Butler. Mobile release mode is opt-in with `--release` and produces Android AAB/APK or iOS archive/IPA artifacts. Play Console, App Store, and Steam upload are intentionally later phases until those release flows are hardened. +Web builds package a local love.js player; `build vendor add web` fetches love.js into `vendor/`; `build vendor add mobile` fetches official Android/iOS LÖVE templates; desktop targets delegate packaging to `love-release`; Itch uploads use Butler. Mobile release mode is opt-in with `--release` and produces Android AAB/APK or iOS archive/IPA artifacts. Play Console, App Store, and Steam upload are intentionally later phases until those release flows are hardened. --- diff --git a/src-lua/feather/auto.lua b/src-lua/feather/auto.lua index 385cba5f..917af993 100644 --- a/src-lua/feather/auto.lua +++ b/src-lua/feather/auto.lua @@ -38,6 +38,27 @@ local function toFsPath(modulePath) return modulePath:gsub("%.$", ""):gsub("%.", "/") end +local function getInfo(fsPath) + if not love or not love.filesystem or not love.filesystem.getInfo then + return nil + end + local ok, info = pcall(love.filesystem.getInfo, fsPath) + if ok then + return info + end + return nil +end + +local function isDirectory(fsPath) + local info = getInfo(fsPath) + return info and info.type == "directory" +end + +local function isFile(fsPath) + local info = getInfo(fsPath) + return info and info.type == "file" +end + --- Load a manifest.lua from a filesystem path. ---@param fsPath string Absolute-ish path understood by love.filesystem ---@return table|nil @@ -45,6 +66,9 @@ local function loadManifest(fsPath) if not love or not love.filesystem then return nil end + if love.filesystem.getInfo and not isFile(fsPath) then + return nil + end local chunk, _ = love.filesystem.load(fsPath) if not chunk then return nil @@ -101,30 +125,36 @@ local function scanPlugins() if not love or not love.filesystem then return entries end - local items = love.filesystem.getDirectoryItems(fsDir) + local ok, items = pcall(love.filesystem.getDirectoryItems, fsDir) + if not ok then + return entries + end if not items then return entries end for _, dirName in ipairs(items) do - local manifest = loadManifest(fsDir .. "/" .. dirName .. "/manifest.lua") - if manifest and manifest.id then - local mod = tryRequire(pluginModPath .. manifest.id) - entries[#entries + 1] = { - mod = mod, - id = manifest.id, - opts = manifest.opts or {}, - optIn = manifest.optIn or false, - disabled = manifest.disabled ~= false, - capabilities = manifest.capabilities or {}, - compatibility = { - api = manifest.api, - minApi = manifest.minApi, - maxApi = manifest.maxApi, - name = manifest.name, - version = manifest.version, - }, - } + local pluginDir = fsDir .. "/" .. dirName + if isDirectory(pluginDir) then + local manifest = loadManifest(pluginDir .. "/manifest.lua") + if manifest and manifest.id then + local mod = tryRequire(pluginModPath .. manifest.id) + entries[#entries + 1] = { + mod = mod, + id = manifest.id, + opts = manifest.opts or {}, + optIn = manifest.optIn or false, + disabled = manifest.disabled ~= false, + capabilities = manifest.capabilities or {}, + compatibility = { + api = manifest.api, + minApi = manifest.minApi, + maxApi = manifest.maxApi, + name = manifest.name, + version = manifest.version, + }, + } + end end end diff --git a/src-lua/feather/lib/base64.lua b/src-lua/feather/lib/base64.lua index de8b484e..9335e77a 100644 --- a/src-lua/feather/lib/base64.lua +++ b/src-lua/feather/lib/base64.lua @@ -3,7 +3,7 @@ --- Optimised: builds 4-char chunks via pre-computed lookup table to avoid --- per-character string.sub calls (≈4× faster on large payloads). -local bit = require("bit") +local bit = require((FEATHER_PATH or "feather") .. ".lib.bit") local band = bit.band local shr = bit.rshift diff --git a/src-lua/feather/lib/bit.lua b/src-lua/feather/lib/bit.lua new file mode 100644 index 00000000..9fc10d16 --- /dev/null +++ b/src-lua/feather/lib/bit.lua @@ -0,0 +1,88 @@ +--- Small bit-operation compatibility layer for Feather. +--- Uses LuaJIT's bit module when available, then bit32, then a Lua 5.1 fallback. + +local ok, native = pcall(require, "bit") +if ok and native then + return native +end + +local ok32, native32 = pcall(require, "bit32") +if ok32 and native32 then + return native32 +end + +local floor = math.floor +local TWO_32 = 4294967296 + +local function normalize(n) + return (n or 0) % TWO_32 +end + +local function bitop(a, b, predicate) + a = normalize(a) + b = normalize(b) + + local result = 0 + local place = 1 + + while a > 0 or b > 0 do + local abit = a % 2 + local bbit = b % 2 + + if predicate(abit, bbit) then + result = result + place + end + + a = (a - abit) / 2 + b = (b - bbit) / 2 + place = place * 2 + end + + return result +end + +local function apply(predicate, a, b, ...) + if b == nil then + return normalize(a) + end + + local result = bitop(a, b, predicate) + for i = 1, select("#", ...) do + result = bitop(result, select(i, ...), predicate) + end + return result +end + +local function band(a, b, ...) + return apply(function(abit, bbit) + return abit == 1 and bbit == 1 + end, a, b, ...) +end + +local function bor(a, b, ...) + return apply(function(abit, bbit) + return abit == 1 or bbit == 1 + end, a, b, ...) +end + +local function bxor(a, b, ...) + return apply(function(abit, bbit) + return abit ~= bbit + end, a, b, ...) +end + +local function lshift(n, bits) + return floor(normalize(n) * (2 ^ bits)) % TWO_32 +end + +local function rshift(n, bits) + return floor(normalize(n) / (2 ^ bits)) +end + +return { + band = band, + bor = bor, + bxor = bxor, + lshift = lshift, + rshift = rshift, +} diff --git a/src-lua/feather/lib/ws.lua b/src-lua/feather/lib/ws.lua index 4952fce5..12bbc905 100644 --- a/src-lua/feather/lib/ws.lua +++ b/src-lua/feather/lib/ws.lua @@ -14,7 +14,7 @@ Usage: ]] local socket = require("socket") -local bit = require("bit") +local bit = require((FEATHER_PATH or "feather") .. ".lib.bit") local band, bor, bxor = bit.band, bit.bor, bit.bxor local shl, shr = bit.lshift, bit.rshift diff --git a/src-lua/manifest.txt b/src-lua/manifest.txt index ff772a12..d4e9d417 100644 --- a/src-lua/manifest.txt +++ b/src-lua/manifest.txt @@ -9,6 +9,7 @@ core:feather/debugger.lua core:feather/error_handler.lua core:feather/init.lua core:feather/lib/base64.lua +core:feather/lib/bit.lua core:feather/lib/class.lua core:feather/lib/inspect.lua core:feather/lib/json.lua From fb0b4a2ae4582887034b0f451fb7946c4c51d7ac Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 19:25:14 -0400 Subject: [PATCH 49/68] cli: improve ios builds --- cli/src/lib/build/config.ts | 1 + cli/src/lib/build/debug-stage.ts | 15 ++ cli/src/lib/build/ios.ts | 338 +++++++++++++++++++++++-- cli/src/lib/build/validation.ts | 1 + cli/src/lib/build/vendor.ts | 114 ++++++--- cli/src/lib/run/mobile.ts | 11 + cli/test/commands.test.mjs | 223 +++++++++++++++- src-lua/feather/core/debug_overlay.lua | 15 +- 8 files changed, 655 insertions(+), 63 deletions(-) diff --git a/cli/src/lib/build/config.ts b/cli/src/lib/build/config.ts index e0097b2f..f3097507 100644 --- a/cli/src/lib/build/config.ts +++ b/cli/src/lib/build/config.ts @@ -40,6 +40,7 @@ export type IosBuildTargetConfig = { scheme?: string; configuration?: string; sdk?: string; + deploymentTarget?: string; derivedDataPath?: string; teamId?: string; release?: { diff --git a/cli/src/lib/build/debug-stage.ts b/cli/src/lib/build/debug-stage.ts index fbc4d0fa..89a6e712 100644 --- a/cli/src/lib/build/debug-stage.ts +++ b/cli/src/lib/build/debug-stage.ts @@ -100,6 +100,7 @@ function generatedMobileConfig(name: string): string { ' wrapPrint = true,', ' autoRegisterErrorHandler = true,', ' captureScreenshot = false,', + ' iosTextRenderGuard = true,', ' __DANGEROUS_INSECURE_CONNECTION__ = true,', ' debugOverlay = {', ' enabled = true,', @@ -131,7 +132,21 @@ function mobileMainWrapper(): string { ' return config', 'end', '', + 'local function installIosTextRenderGuard(config)', + ' if type(config) ~= "table" then config = {} end', + ' if config.iosTextRendering == true or config.iosTextRenderGuard == false then return end', + ' if not love.system or not love.system.getOS or love.system.getOS() ~= "iOS" then return end', + ' if not love.graphics then return end', + ' local originalPrint = love.graphics.print', + ' local originalPrintf = love.graphics.printf', + ' love.graphics._featherOriginalPrint = originalPrint', + ' love.graphics._featherOriginalPrintf = originalPrintf', + ' love.graphics.print = function(...) end', + ' love.graphics.printf = function(...) end', + 'end', + '', 'FEATHER_AUTO_CONFIG = loadFeatherConfig()', + 'installIosTextRenderGuard(FEATHER_AUTO_CONFIG)', 'require("feather.auto")', '', 'local gameMain, err = love.filesystem.load(".feather-main.lua")', diff --git a/cli/src/lib/build/ios.ts b/cli/src/lib/build/ios.ts index 4281e2a5..6214aa50 100644 --- a/cli/src/lib/build/ios.ts +++ b/cli/src/lib/build/ios.ts @@ -1,6 +1,8 @@ import { spawnSync } from 'node:child_process'; -import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { writeZip, type ZipEntry } from './archive.js'; import { artifactBaseName, copyDirectory, writeLoveArchive, type BuildArtifact } from './files.js'; import type { ResolvedBuildConfig } from './config.js'; import { @@ -55,9 +57,11 @@ export function buildIos(config: ResolvedBuildConfig, stageDir: string, options: bundleIdentifier: iosBundleIdentifier(config), displayName: iosConfig.displayName ?? config.name, scheme: iosConfig.scheme ?? 'love-ios', - configuration: iosConfig.configuration ?? 'Debug', + configuration: iosConfiguration(config), sdk: iosConfig.sdk ?? 'iphonesimulator', + deploymentTarget: iosConfig.deploymentTarget ?? '12.0', teamId: iosConfig.teamId, + gameLoveResourcePatch: 4, debuggerSignature: options.debuggerSignature, }, }); @@ -79,7 +83,9 @@ export function buildIos(config: ResolvedBuildConfig, stageDir: string, options: mkdirSync(dirname(gameLovePath), { recursive: true }); cpSync(lovePath, gameLovePath, { force: true }); logNativeStep(options.log, `Embedded game.love: ${gameLovePath}`); - patchIosProject(join(xcodeProject, 'project.pbxproj')); + patchIosPlist(join(workspace.dir, 'platform', 'xcode', 'ios', 'love-ios.plist'), config); + logNativeStep(options.log, 'Patched iOS plist metadata'); + patchIosProject(join(xcodeProject, 'project.pbxproj'), config, base); logNativeStep(options.log, 'Patched Xcode project resources'); if (options.release) { @@ -90,12 +96,13 @@ export function buildIos(config: ResolvedBuildConfig, stageDir: string, options: const args = xcodebuildArgs(config, xcodeProject, derivedDataPath); runXcodebuild(args, workspace.dir, options.log); - const appSource = findFirstPath(derivedDataPath, (_path, entry) => entry.isDirectory() && entry.name.endsWith('.app')); + const appSource = findIosAppArtifact(config, derivedDataPath); if (!appSource || !existsSync(appSource)) { throw new Error('iOS build completed but no .app artifact was found. Check targets.ios.derivedDataPath or Xcode build settings.'); } const appPath = join(config.outDir, `${base}-ios.app`); copyDirectory(appSource, appPath); + ensureLoveInAppBundle(appPath, lovePath, options.log); logNativeStep(options.log, `Copied app artifact: ${appPath}`); return [ @@ -130,8 +137,14 @@ function buildIosRelease( if (!existsSync(archiveSource)) { throw new Error('iOS archive completed but no .xcarchive was found. Check targets.ios.release.archivePath or Xcode archive settings.'); } - runXcodebuild(xcodeExportArchiveArgs(archiveSource, exportPath, exportOptionsPlist), workDir, log); - const ipaSource = findFirstPath(exportPath, (_path, entry) => entry.isFile() && entry.name.endsWith('.ipa')); + + let ipaSource: string | null; + if (shouldPackageUnsignedIpa(config)) { + ipaSource = packageUnsignedIpaFromArchive(archiveSource, exportPath, base, log); + } else { + runXcodebuild(xcodeExportArchiveArgs(archiveSource, exportPath, exportOptionsPlist), workDir, log); + ipaSource = findFirstPath(exportPath, (_path, entry) => entry.isFile() && entry.name.endsWith('.ipa')); + } if (!ipaSource || !existsSync(ipaSource)) { throw new Error('iOS export completed but no .ipa artifact was found. Check targets.ios.release.exportPath or export options.'); } @@ -149,23 +162,255 @@ function buildIosRelease( ]; } +function shouldPackageUnsignedIpa(config: ResolvedBuildConfig): boolean { + const iosConfig = config.targets.ios ?? {}; + const release = iosConfig.release ?? {}; + const teamId = release.teamId ?? iosConfig.teamId; + return !teamId && !release.exportOptionsPlist && !release.provisioningProfileSpecifier; +} + +function packageUnsignedIpaFromArchive(archivePath: string, exportPath: string, base: string, log?: NativeBuildLogger): string { + const appSource = findArchiveApp(archivePath); + if (!appSource) { + throw new Error('iOS archive completed but no .app was found in Products/Applications.'); + } + + const payloadRoot = mkdtempSync(join(tmpdir(), 'feather-ios-payload-')); + const payloadDir = join(payloadRoot, 'Payload'); + const payloadApp = join(payloadDir, appSource.split('/').pop() ?? `${base}.app`); + mkdirSync(payloadDir, { recursive: true }); + copyDirectory(appSource, payloadApp); + + const ipaPath = join(exportPath, `${base}.ipa`); + mkdirSync(exportPath, { recursive: true }); + const ditto = spawnSync('/usr/bin/ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', 'Payload', ipaPath], { + cwd: payloadRoot, + encoding: 'utf8', + stdio: 'pipe', + }); + if (ditto.status === 0) { + logNativeStep(log, `Packaged unsigned IPA: ${ipaPath}`); + return ipaPath; + } + + if (process.platform === 'darwin') { + throw new Error(`Failed to package unsigned IPA with ditto: ${spawnOutput(ditto)}`); + } + + writeZip(ipaPath, collectZipEntries(payloadRoot, 'Payload')); + logNativeStep(log, `Packaged unsigned IPA: ${ipaPath}`); + return ipaPath; +} + +function findArchiveApp(archivePath: string): string | null { + const applicationsDir = join(archivePath, 'Products', 'Applications'); + return findFirstPath(applicationsDir, (_path, entry) => entry.isDirectory() && entry.name.endsWith('.app')) + ?? findFirstPath(archivePath, (_path, entry) => entry.isDirectory() && entry.name.endsWith('.app')); +} + +function collectZipEntries(root: string, relativePath: string): ZipEntry[] { + const absolute = join(root, relativePath); + const stat = statSync(absolute); + if (stat.isFile()) { + return [{ name: relativePath, data: readFileSync(absolute) }]; + } + if (!stat.isDirectory()) return []; + return readdirSync(absolute).flatMap((name) => collectZipEntries(root, join(relativePath, name))); +} + +function ensureLoveInAppBundle(appPath: string, lovePath: string, log?: NativeBuildLogger): void { + if (!existsSync(appPath)) { + throw new Error(`iOS app bundle not found at ${appPath}.`); + } + const bundledLovePath = join(appPath, 'game.love'); + if (existsSync(bundledLovePath)) { + logNativeStep(log, `Bundled game.love in app: ${bundledLovePath}`); + return; + } + cpSync(lovePath, bundledLovePath, { force: true }); + logNativeStep(log, `Bundled missing game.love in app: ${bundledLovePath}`); + maybeAdHocCodesign(appPath, log); +} + +function maybeAdHocCodesign(appPath: string, log?: NativeBuildLogger): void { + if (process.platform !== 'darwin') return; + const result = spawnSync('codesign', ['--force', '--sign', '-', appPath], { + encoding: 'utf8', + stdio: 'pipe', + }); + if (result.status === 0) { + logNativeStep(log, `Ad-hoc signed app bundle: ${appPath}`); + return; + } + logNativeStep(log, 'Ad-hoc codesign skipped; simulator installs may still work, but device installs require signing.'); +} + +function iosConfiguration(config: ResolvedBuildConfig): string { + return config.targets.ios?.configuration ?? 'Release'; +} + +function iosSdkBuildFolder(sdk: string): string { + if (sdk.startsWith('iphonesimulator')) return 'iphonesimulator'; + if (sdk.startsWith('iphoneos')) return 'iphoneos'; + return sdk; +} + +function findIosAppArtifact(config: ResolvedBuildConfig, derivedDataPath: string): string | null { + const sdk = config.targets.ios?.sdk ?? 'iphonesimulator'; + const configuration = iosConfiguration(config); + const productsDir = join(derivedDataPath, 'Build', 'Products'); + const expectedDir = join(productsDir, `${configuration}-${iosSdkBuildFolder(sdk)}`); + const expected = findFirstPath(expectedDir, (_path, entry) => entry.isDirectory() && entry.name.endsWith('.app')); + if (expected) return expected; + + const matchingConfiguration = findFirstPath(productsDir, (path, entry) => ( + entry.isDirectory() + && entry.name.endsWith('.app') + && path.includes(`${configuration}-`) + )); + if (matchingConfiguration) return matchingConfiguration; + + return findFirstPath(derivedDataPath, (_path, entry) => entry.isDirectory() && entry.name.endsWith('.app')); +} + +function patchIosPlist(path: string, config: ResolvedBuildConfig): void { + if (!existsSync(path)) return; + if (process.platform === 'darwin') { + patchIosPlistWithPlistBuddy(path, config); + validateIosPlist(path); + return; + } + + const iosConfig = config.targets.ios ?? {}; + let source = readFileSync(path, 'utf8'); + source = upsertPlistString(source, 'CFBundleName', iosConfig.displayName ?? config.name); + source = upsertPlistString(source, 'CFBundleDisplayName', iosConfig.displayName ?? config.name); + source = upsertPlistString(source, 'CFBundleIdentifier', iosBundleIdentifier(config)); + source = upsertPlistString(source, 'CFBundleExecutable', artifactBaseName(config)); + source = upsertPlistString(source, 'CFBundleShortVersionString', config.version); + source = upsertPlistString(source, 'CFBundleVersion', config.version); + source = upsertPlistBool(source, 'ITSAppUsesNonExemptEncryption', false); + source = upsertPlistBool(source, 'UIApplicationSupportsIndirectInputEvents', true); + source = upsertPlistBool(source, 'UIRequiresFullScreen', true); + source = upsertPlistBool(source, 'UIStatusBarHidden', true); + if (config.copyright) source = upsertPlistString(source, 'NSHumanReadableCopyright', config.copyright); + source = removePlistKey(source, 'CFBundleDocumentTypes'); + source = removePlistKey(source, 'UTExportedTypeDeclarations'); + writeFileSync(path, source); +} + +function patchIosPlistWithPlistBuddy(path: string, config: ResolvedBuildConfig): void { + const iosConfig = config.targets.ios ?? {}; + const executable = artifactBaseName(config); + setPlistBuddyString(path, 'CFBundleName', iosConfig.displayName ?? config.name); + setPlistBuddyString(path, 'CFBundleDisplayName', iosConfig.displayName ?? config.name); + setPlistBuddyString(path, 'CFBundleIdentifier', iosBundleIdentifier(config)); + setPlistBuddyString(path, 'CFBundleExecutable', executable); + setPlistBuddyString(path, 'CFBundleShortVersionString', config.version); + setPlistBuddyString(path, 'CFBundleVersion', config.version); + setPlistBuddyBool(path, 'ITSAppUsesNonExemptEncryption', false); + setPlistBuddyBool(path, 'UIApplicationSupportsIndirectInputEvents', true); + setPlistBuddyBool(path, 'UIRequiresFullScreen', true); + setPlistBuddyBool(path, 'UIStatusBarHidden', true); + if (config.copyright) setPlistBuddyString(path, 'NSHumanReadableCopyright', config.copyright); + deletePlistBuddyKey(path, 'CFBundleDocumentTypes'); + deletePlistBuddyKey(path, 'UTExportedTypeDeclarations'); +} + +function setPlistBuddyString(path: string, key: string, value: string): void { + setPlistBuddyValue(path, key, 'string', value); +} + +function setPlistBuddyBool(path: string, key: string, value: boolean): void { + setPlistBuddyValue(path, key, 'bool', value ? 'true' : 'false'); +} + +function setPlistBuddyValue(path: string, key: string, type: 'bool' | 'string', value: string): void { + const set = runPlistBuddy(path, `Set :${key} ${value}`); + if (set.status === 0) return; + + const add = runPlistBuddy(path, `Add :${key} ${type} ${value}`); + if (add.status !== 0) { + throw new Error(`Failed to update iOS plist key ${key}: ${spawnOutput(add) || spawnOutput(set)}`); + } +} + +function deletePlistBuddyKey(path: string, key: string): void { + runPlistBuddy(path, `Delete :${key}`); +} + +function runPlistBuddy(path: string, command: string): ReturnType { + return spawnSync('/usr/libexec/PlistBuddy', ['-c', command, path], { + encoding: 'utf8', + stdio: 'pipe', + }); +} + +function validateIosPlist(path: string): void { + const result = spawnSync('/usr/bin/plutil', ['-lint', path], { + encoding: 'utf8', + stdio: 'pipe', + }); + if (result.error || result.status !== 0) { + throw new Error(`iOS plist validation failed: ${(result.stderr || result.stdout || result.error?.message || '').trim()}`); + } +} + +function spawnOutput(result: ReturnType): string { + const output = result.stderr || result.stdout || result.error?.message || ''; + return output.toString().trim(); +} + +function upsertPlistString(source: string, key: string, value: string): string { + return upsertPlistValue(source, key, `${escapeXml(value)}`); +} + +function upsertPlistBool(source: string, key: string, value: boolean): string { + return upsertPlistValue(source, key, value ? '' : ''); +} + +function upsertPlistValue(source: string, key: string, valueXml: string): string { + const pattern = new RegExp(`(\\s*${escapeRegExp(key)}\\s*)<[^>]+>[^<]*]+>|(\\s*${escapeRegExp(key)}\\s*)<(?:true|false)\\/>`); + if (pattern.test(source)) { + return source.replace(pattern, `$1$2${valueXml}`); + } + return source.replace('', `\t${key}\n\t${valueXml}\n`); +} + +function removePlistKey(source: string, key: string): string { + const escaped = escapeRegExp(key); + return source.replace(new RegExp(`\\n\\t${escaped}[\\s\\S]*?(?=\\n\\t|\\n<\\/dict>)`, 'g'), ''); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + export function xcodebuildArgs(config: ResolvedBuildConfig, xcodeProject: string, derivedDataPath: string): string[] { const iosConfig = config.targets.ios ?? {}; + const sdk = iosConfig.sdk ?? 'iphonesimulator'; const args = [ '-project', xcodeProject, '-scheme', iosConfig.scheme ?? 'love-ios', '-configuration', - iosConfig.configuration ?? 'Debug', + iosConfiguration(config), '-sdk', - iosConfig.sdk ?? 'iphonesimulator', + sdk, + '-destination', + sdk.startsWith('iphoneos') ? 'generic/platform=iOS' : 'generic/platform=iOS Simulator', '-derivedDataPath', derivedDataPath, `PRODUCT_BUNDLE_IDENTIFIER=${iosBundleIdentifier(config)}`, `INFOPLIST_KEY_CFBundleDisplayName=${iosConfig.displayName ?? config.name}`, + `IPHONEOS_DEPLOYMENT_TARGET=${iosConfig.deploymentTarget ?? '12.0'}`, + 'OTHER_CFLAGS=-Wno-everything', ]; if (iosConfig.teamId) args.push(`DEVELOPMENT_TEAM=${iosConfig.teamId}`); + if (sdk.startsWith('iphonesimulator') && !iosConfig.teamId) { + args.push('CODE_SIGN_IDENTITY=', 'CODE_SIGNING_REQUIRED=NO', 'CODE_SIGNING_ALLOWED=NO'); + } args.push('build'); return args; } @@ -183,12 +428,19 @@ export function xcodeArchiveArgs(config: ResolvedBuildConfig, xcodeProject: stri release.configuration ?? 'Release', '-sdk', release.sdk ?? 'iphoneos', + '-destination', + 'generic/platform=iOS', '-archivePath', archivePath, `PRODUCT_BUNDLE_IDENTIFIER=${iosBundleIdentifier(config)}`, `INFOPLIST_KEY_CFBundleDisplayName=${iosConfig.displayName ?? config.name}`, + `IPHONEOS_DEPLOYMENT_TARGET=${iosConfig.deploymentTarget ?? '12.0'}`, + 'OTHER_CFLAGS=-Wno-everything', ]; if (teamId) args.push(`DEVELOPMENT_TEAM=${teamId}`); + if (!teamId) { + args.push('CODE_SIGN_IDENTITY=', 'CODE_SIGNING_REQUIRED=NO', 'CODE_SIGNING_ALLOWED=NO'); + } args.push('archive'); return args; } @@ -264,29 +516,79 @@ function escapeXml(value: string): string { }[char]!)); } -export function patchIosProject(projectPath: string): void { +export function patchIosProject(projectPath: string, config?: ResolvedBuildConfig, productFile?: string): void { if (!existsSync(projectPath)) return; const source = readFileSync(projectPath, 'utf8'); - if (source.includes('game.love')) return; const buildFileId = 'FEATHERGAMELOVE000000000001'; const fileRefId = 'FEATHERGAMELOVE000000000002'; + const fileRef = `\t\t${fileRefId} /* game.love */ = {isa = PBXFileReference; lastKnownFileType = file; name = game.love; path = game.love; sourceTree = SOURCE_ROOT; };\n`; + const buildFile = `\t\t${buildFileId} /* game.love in Resources */ = {isa = PBXBuildFile; fileRef = ${fileRefId} /* game.love */; };\n`; let next = source; - if (next.includes('/* Begin PBXBuildFile section */')) { + if (config && productFile) { + next = patchIosProjectMetadata(next, config, productFile); + } + + if (next.includes(`${buildFileId} /* game.love in Resources */`)) { + next = next.replace( + new RegExp(`\\t\\t${buildFileId} /\\* game\\.love in Resources \\*/ = \\{[^\\n]+\\};\\n`), + buildFile, + ); + } else if (next.includes('/* Begin PBXBuildFile section */')) { next = next.replace( /\/\* Begin PBXBuildFile section \*\/\n/, - `/* Begin PBXBuildFile section */\n\t\t${buildFileId} /* game.love in Resources */ = {isa = PBXBuildFile; fileRef = ${fileRefId} /* game.love */; };\n`, + `/* Begin PBXBuildFile section */\n${buildFile}`, ); } - if (next.includes('/* Begin PBXFileReference section */')) { + + if (next.includes(`${fileRefId} /* game.love */`)) { + next = next.replace( + new RegExp(`\\t\\t${fileRefId} /\\* game\\.love \\*/ = \\{[^\\n]+\\};\\n`), + fileRef, + ); + } else if (next.includes('/* Begin PBXFileReference section */')) { next = next.replace( /\/\* Begin PBXFileReference section \*\/\n/, - `/* Begin PBXFileReference section */\n\t\t${fileRefId} /* game.love */ = {isa = PBXFileReference; lastKnownFileType = archive.love; name = game.love; path = game.love; sourceTree = ""; };\n`, + `/* Begin PBXFileReference section */\n${fileRef}`, ); } - next = next.replace( - /(isa = PBXResourcesBuildPhase;[\s\S]*?files = \(\n)/, - `$1\t\t\t\t${buildFileId} /* game.love in Resources */,\n`, - ); + + const resourcesPhaseId = findIosResourcesPhaseId(next); + if (resourcesPhaseId) { + next = ensureResourceBuildFile(next, resourcesPhaseId, buildFileId); + } + if (next === source) next = `${source}\n/* Feather: include game.love in the app resources. */\n`; writeFileSync(projectPath, next); } + +function patchIosProjectMetadata(project: string, config: ResolvedBuildConfig, productFile: string): string { + const bundleIdentifier = iosBundleIdentifier(config); + return project + .replace(/PRODUCT_BUNDLE_IDENTIFIER = [^;]+;/g, `PRODUCT_BUNDLE_IDENTIFIER = ${bundleIdentifier};`) + .replace(/PRODUCT_NAME = "?love"?;/g, `PRODUCT_NAME = ${productFile};`) + .replace(/productName = "?love"?;/g, `productName = ${productFile};`) + .replace(/path = love\.app;/g, `path = ${productFile}.app;`) + .replace(/MARKETING_VERSION = [^;]+;/g, `MARKETING_VERSION = ${config.version};`); +} + +function findIosResourcesPhaseId(project: string): string | null { + const targetMatch = project.match(/([A-Z0-9]{10,}) \/\* love-ios \*\/ = \{[\s\S]*?buildPhases = \(([\s\S]*?)\);[\s\S]*?name = "?love-ios"?;/); + if (targetMatch) { + const resourcesMatch = targetMatch[2]?.match(/([A-Z0-9]{10,}) \/\* Resources \*\//); + if (resourcesMatch?.[1]) return resourcesMatch[1]; + } + + const phaseWithLaunchScreen = project.match(/([A-Z0-9]{10,}) \/\* Resources \*\/ = \{[\s\S]*?files = \([\s\S]*?Launch Screen\.xib in Resources[\s\S]*?\);/); + if (phaseWithLaunchScreen?.[1]) return phaseWithLaunchScreen[1]; + + const firstResourcesPhase = project.match(/([A-Z0-9]{10,}) \/\* Resources \*\/ = \{[\s\S]*?isa = PBXResourcesBuildPhase;/); + return firstResourcesPhase?.[1] ?? null; +} + +function ensureResourceBuildFile(project: string, resourcesPhaseId: string, buildFileId: string): string { + const phasePattern = new RegExp(`(${resourcesPhaseId} /\\* Resources \\*/ = \\{[\\s\\S]*?files = \\(\\n)([\\s\\S]*?)(\\n\\s*\\);)`); + return project.replace(phasePattern, (match, prefix: string, files: string, suffix: string) => { + if (files.includes(`${buildFileId} /* game.love in Resources */`)) return match; + return `${prefix}\t\t\t\t${buildFileId} /* game.love in Resources */,\n${files}${suffix}`; + }); +} diff --git a/cli/src/lib/build/validation.ts b/cli/src/lib/build/validation.ts index 6fed36e2..43b774d6 100644 --- a/cli/src/lib/build/validation.ts +++ b/cli/src/lib/build/validation.ts @@ -170,6 +170,7 @@ export function validateIosBuildConfig(config: ResolvedBuildConfig, release = fa ['targets.ios.scheme', ios.scheme], ['targets.ios.configuration', ios.configuration], ['targets.ios.sdk', ios.sdk], + ['targets.ios.deploymentTarget', ios.deploymentTarget], ] as const) { if (value !== undefined && (!isNonEmptySingleLine(value) || !XCODE_VALUE_RE.test(value))) { issues.push({ diff --git a/cli/src/lib/build/vendor.ts b/cli/src/lib/build/vendor.ts index 29849def..98e41bf1 100644 --- a/cli/src/lib/build/vendor.ts +++ b/cli/src/lib/build/vendor.ts @@ -3,7 +3,6 @@ import { existsSync, mkdirSync, readFileSync, - rmSync, writeFileSync, } from 'node:fs'; import { inflateRawSync } from 'node:zlib'; @@ -14,6 +13,7 @@ import { readBuildConfig, type FeatherBuildConfig, } from './config.js'; +import { removePath } from './files.js'; import { assertNoSymlinkEscape, assertSafeRelativePath, isPathInside } from '../path-safety.js'; export const buildVendorTargets = ['web', 'android', 'ios', 'mobile', 'all'] as const; @@ -185,14 +185,20 @@ async function addSingleVendor(input: AddSingleVendorInput): Promise { @@ -356,24 +390,25 @@ type UnzippedEntry = { function unzip(zip: Buffer): UnzippedEntry[] { const entries: UnzippedEntry[] = []; - let offset = 0; - while (offset + 30 <= zip.length) { + let offset = centralDirectoryOffset(zip); + while (offset + 46 <= zip.length) { const signature = zip.readUInt32LE(offset); - if (signature !== 0x04034b50) break; - const flags = zip.readUInt16LE(offset + 6); - const method = zip.readUInt16LE(offset + 8); - const compressedSize = zip.readUInt32LE(offset + 18); - const uncompressedSize = zip.readUInt32LE(offset + 22); - const nameLength = zip.readUInt16LE(offset + 26); - const extraLength = zip.readUInt16LE(offset + 28); - if (flags & 0x08) { - throw new Error('ZIP entries with data descriptors are not supported.'); + if (signature !== 0x02014b50) break; + const method = zip.readUInt16LE(offset + 10); + const compressedSize = zip.readUInt32LE(offset + 20); + const uncompressedSize = zip.readUInt32LE(offset + 24); + const nameLength = zip.readUInt16LE(offset + 28); + const extraLength = zip.readUInt16LE(offset + 30); + const commentLength = zip.readUInt16LE(offset + 32); + const localHeaderOffset = zip.readUInt32LE(offset + 42); + if (compressedSize === 0xffffffff || uncompressedSize === 0xffffffff || localHeaderOffset === 0xffffffff) { + throw new Error('ZIP64 archives are not supported for Apple libraries.'); } - const nameStart = offset + 30; - const dataStart = nameStart + nameLength + extraLength; + const nameStart = offset + 46; + const name = zip.subarray(nameStart, nameStart + nameLength).toString('utf8'); + const dataStart = localFileDataOffset(zip, localHeaderOffset); const dataEnd = dataStart + compressedSize; if (dataEnd > zip.length) throw new Error('Invalid ZIP archive.'); - const name = zip.subarray(nameStart, nameStart + nameLength).toString('utf8'); const compressed = zip.subarray(dataStart, dataEnd); let data: Buffer; if (method === 0) { @@ -385,11 +420,32 @@ function unzip(zip: Buffer): UnzippedEntry[] { } if (data.length !== uncompressedSize) throw new Error(`Invalid ZIP entry size for ${name}.`); entries.push({ name, data }); - offset = dataEnd; + offset = nameStart + nameLength + extraLength + commentLength; } return entries; } +function centralDirectoryOffset(zip: Buffer): number { + const minOffset = Math.max(0, zip.length - 65557); + for (let offset = zip.length - 22; offset >= minOffset; offset -= 1) { + if (zip.readUInt32LE(offset) === 0x06054b50) { + return zip.readUInt32LE(offset + 16); + } + } + throw new Error('Invalid ZIP archive: central directory not found.'); +} + +function localFileDataOffset(zip: Buffer, offset: number): number { + if (offset + 30 > zip.length || zip.readUInt32LE(offset) !== 0x04034b50) { + throw new Error('Invalid ZIP archive: local file header not found.'); + } + const nameLength = zip.readUInt16LE(offset + 26); + const extraLength = zip.readUInt16LE(offset + 28); + const dataOffset = offset + 30 + nameLength + extraLength; + if (dataOffset > zip.length) throw new Error('Invalid ZIP archive.'); + return dataOffset; +} + function updateVendorConfig( projectDir: string, configPath: string, diff --git a/cli/src/lib/run/mobile.ts b/cli/src/lib/run/mobile.ts index 2ff45505..dc52d83b 100644 --- a/cli/src/lib/run/mobile.ts +++ b/cli/src/lib/run/mobile.ts @@ -150,10 +150,21 @@ function runAdb(device: string | undefined, args: string[], message: string, log } function installAndLaunchIos(input: { app: string; appId: string; device: string; log?: NativeBuildLogger }): void { + runXcrunOptional(['simctl', 'terminate', input.device, input.appId], input.log); + runXcrunOptional(['simctl', 'uninstall', input.device, input.appId], input.log); runXcrun(['simctl', 'install', input.device, input.app], 'iOS simulator install failed.', input.log); runXcrun(['simctl', 'launch', input.device, input.appId], 'iOS simulator launch failed.', input.log); } +function runXcrunOptional(args: string[], log?: NativeBuildLogger): void { + logNativeCommand(log, 'xcrun', args, process.cwd()); + const result = spawnSync('xcrun', args, { + encoding: 'utf8', + stdio: 'pipe', + }); + logNativeOutput(log, result.stdout, result.stderr); +} + function runXcrun(args: string[], message: string, log?: NativeBuildLogger): void { logNativeCommand(log, 'xcrun', args, process.cwd()); const streamOutput = Boolean(log); diff --git a/cli/test/commands.test.mjs b/cli/test/commands.test.mjs index 77a4b460..e2f0c77e 100644 --- a/cli/test/commands.test.mjs +++ b/cli/test/commands.test.mjs @@ -375,7 +375,29 @@ process.exit(0); function writeFakeLoveIos(dir) { const root = join(dir, 'love-ios'); const projectDir = join(root, 'platform', 'xcode', 'love.xcodeproj'); + const plistDir = join(root, 'platform', 'xcode', 'ios'); mkdirSync(projectDir, { recursive: true }); + mkdirSync(plistDir, { recursive: true }); + writeFileSync( + join(plistDir, 'love-ios.plist'), + ` + + + + CFBundleExecutable + love + CFBundleIdentifier + org.love2d.love + CFBundleName + love + CFBundleShortVersionString + 11.5 + CFBundleVersion + 11.5 + + +`, + ); writeFileSync( join(projectDir, 'project.pbxproj'), `// !$*UTF8*$! @@ -384,11 +406,37 @@ function writeFakeLoveIos(dir) { /* End PBXBuildFile section */ /* Begin PBXFileReference section */ /* End PBXFileReference section */ - 1234567890ABCDEF00000001 /* Resources */ = { +/* Begin PBXNativeTarget section */ + 111111111111111111111111 /* love-macosx */ = { + isa = PBXNativeTarget; + buildPhases = ( + 222222222222222222222222 /* Resources */, + ); + name = "love-macosx"; + }; + 333333333333333333333333 /* love-ios */ = { + isa = PBXNativeTarget; + buildPhases = ( + 444444444444444444444444 /* Sources */, + 555555555555555555555555 /* Frameworks */, + 666666666666666666666666 /* Resources */, + ); + name = "love-ios"; + }; +/* End PBXNativeTarget section */ +/* Begin PBXResourcesBuildPhase section */ + 222222222222222222222222 /* Resources */ = { isa = PBXResourcesBuildPhase; files = ( ); }; + 666666666666666666666666 /* Resources */ = { + isa = PBXResourcesBuildPhase; + files = ( + 777777777777777777777777 /* Launch Screen.xib in Resources */, + ); + }; +/* End PBXResourcesBuildPhase section */ } `, ); @@ -419,12 +467,76 @@ function readStoredZipEntries(zipPath) { return entries; } +function createDataDescriptorZipBuffer(entries) { + const chunks = []; + const central = []; + let offset = 0; + + for (const entry of entries) { + const name = Buffer.from(entry.name); + const data = Buffer.from(entry.data); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0x08, 6); + local.writeUInt16LE(0, 8); + local.writeUInt32LE(0, 10); + local.writeUInt32LE(0, 14); + local.writeUInt32LE(0, 18); + local.writeUInt32LE(0, 22); + local.writeUInt16LE(name.length, 26); + local.writeUInt16LE(0, 28); + + const descriptor = Buffer.alloc(16); + descriptor.writeUInt32LE(0x08074b50, 0); + descriptor.writeUInt32LE(0, 4); + descriptor.writeUInt32LE(data.length, 8); + descriptor.writeUInt32LE(data.length, 12); + + const centralEntry = Buffer.alloc(46); + centralEntry.writeUInt32LE(0x02014b50, 0); + centralEntry.writeUInt16LE(20, 4); + centralEntry.writeUInt16LE(20, 6); + centralEntry.writeUInt16LE(0x08, 8); + centralEntry.writeUInt16LE(0, 10); + centralEntry.writeUInt32LE(0, 12); + centralEntry.writeUInt32LE(0, 16); + centralEntry.writeUInt32LE(data.length, 20); + centralEntry.writeUInt32LE(data.length, 24); + centralEntry.writeUInt16LE(name.length, 28); + centralEntry.writeUInt16LE(0, 30); + centralEntry.writeUInt16LE(0, 32); + centralEntry.writeUInt16LE(0, 34); + centralEntry.writeUInt16LE(0, 36); + centralEntry.writeUInt32LE(0, 38); + centralEntry.writeUInt32LE(offset, 42); + + chunks.push(local, name, data, descriptor); + central.push(centralEntry, name); + offset += local.length + name.length + data.length + descriptor.length; + } + + const centralOffset = offset; + const centralSize = central.reduce((sum, chunk) => sum + chunk.length, 0); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(0, 4); + end.writeUInt16LE(0, 6); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(centralSize, 12); + end.writeUInt32LE(centralOffset, 16); + end.writeUInt16LE(0, 20); + + return Buffer.concat([...chunks, ...central, end]); +} + async function writeFakeAppleLibrariesZip(dir) { - const { createZipBuffer } = await import('../dist/lib/build/archive.js'); const zipPath = join(dir, 'love-apple-libraries.zip'); - writeFileSync(zipPath, createZipBuffer([ - { name: 'iOS/libraries/liblove-test.a', data: Buffer.from('ios lib') }, - { name: 'shared/test-shared.txt', data: Buffer.from('shared lib') }, + writeFileSync(zipPath, createDataDescriptorZipBuffer([ + { name: 'love-apple-dependencies/iOS/libraries/liblove-test.a', data: Buffer.from('ios lib') }, + { name: 'love-apple-dependencies/macOS/Frameworks/test.framework/test', data: Buffer.from('mac lib') }, + { name: '__MACOSX/love-apple-dependencies/iOS/libraries/._ignored', data: Buffer.from('metadata') }, ])); return zipPath; } @@ -809,7 +921,10 @@ const fs = require('node:fs'); const path = require('node:path'); const args = process.argv.slice(2); const derivedData = args[args.indexOf('-derivedDataPath') + 1]; -const app = path.join(derivedData, 'Build', 'Products', 'Debug-iphonesimulator', 'love-ios.app'); +const configuration = args[args.indexOf('-configuration') + 1] || 'Release'; +const sdk = args[args.indexOf('-sdk') + 1] || 'iphonesimulator'; +const sdkFolder = sdk.startsWith('iphoneos') ? 'iphoneos' : 'iphonesimulator'; +const app = path.join(derivedData, 'Build', 'Products', configuration + '-' + sdkFolder, 'love-ios.app'); fs.mkdirSync(app, { recursive: true }); fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ argv: args }, null, 2)); @@ -824,9 +939,12 @@ process.exit(0); assert.ok(outputOf(result).includes('Launched ios')); const records = JSON.parse(readFileSync(xcrun.recordPath, 'utf8')); assert.deepEqual(records.map((entry) => entry.args), [ + ['simctl', 'terminate', 'SIM-123', 'com.example.runios'], + ['simctl', 'uninstall', 'SIM-123', 'com.example.runios'], ['simctl', 'install', 'SIM-123', join(dir, 'builds', 'run-ios-1.0.0-ios.app')], ['simctl', 'launch', 'SIM-123', 'com.example.runios'], ]); + assert.equal(existsSync(join(dir, 'builds', 'run-ios-1.0.0-ios.app', 'game.love')), true); }); test('run --target ios: missing xcrun produces doctor guidance', () => { @@ -843,7 +961,10 @@ const fs = require('node:fs'); const path = require('node:path'); const args = process.argv.slice(2); const derivedData = args[args.indexOf('-derivedDataPath') + 1]; -const app = path.join(derivedData, 'Build', 'Products', 'Debug-iphonesimulator', 'love-ios.app'); +const configuration = args[args.indexOf('-configuration') + 1] || 'Release'; +const sdk = args[args.indexOf('-sdk') + 1] || 'iphonesimulator'; +const sdkFolder = sdk.startsWith('iphoneos') ? 'iphoneos' : 'iphonesimulator'; +const app = path.join(derivedData, 'Build', 'Products', configuration + '-' + sdkFolder, 'love-ios.app'); fs.mkdirSync(app, { recursive: true }); fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); process.exit(0); @@ -1203,6 +1324,7 @@ test('doctor --production fails unmanaged embedded runtime', () => { test('build web: creates love archive, love.js html package, zip, and manifest', () => { const dir = makeTmp(); writeGame(dir); + writeFakeLoveJs(dir); writeBuildConfig(dir, { name: 'Command Game', version: '1.2.3', @@ -1474,7 +1596,8 @@ test('build vendor add ios --json: clones vendor, installs Apple libraries, and assert.equal(parsed.vendors[0].target, 'ios'); assert.equal(parsed.vendors[0].relativePath, 'vendor/love-ios'); assert.equal(existsSync(join(dir, 'vendor', 'love-ios', 'platform', 'xcode', 'ios', 'libraries', 'liblove-test.a')), true); - assert.equal(existsSync(join(dir, 'vendor', 'love-ios', 'platform', 'xcode', 'shared', 'test-shared.txt')), true); + assert.equal(existsSync(join(dir, 'vendor', 'love-ios', 'platform', 'xcode', 'macosx', 'Frameworks', 'test.framework', 'test')), true); + assert.equal(existsSync(join(dir, 'vendor', 'love-ios', 'platform', 'xcode', 'ios', 'libraries', '._ignored')), false); const config = JSON.parse(readFileSync(join(dir, 'feather.build.json'), 'utf8')); assert.equal(config.targets.ios.loveIosDir, 'vendor/love-ios'); const records = JSON.parse(readFileSync(recordPath, 'utf8')); @@ -1983,13 +2106,23 @@ const fs = require('node:fs'); const path = require('node:path'); const args = process.argv.slice(2); const derivedData = args[args.indexOf('-derivedDataPath') + 1]; -const app = path.join(derivedData, 'Build', 'Products', 'Debug-iphonesimulator', 'love-ios.app'); +const configuration = args[args.indexOf('-configuration') + 1] || 'Release'; +const sdk = args[args.indexOf('-sdk') + 1] || 'iphonesimulator'; +const sdkFolder = sdk.startsWith('iphoneos') ? 'iphoneos' : 'iphonesimulator'; +const app = path.join(derivedData, 'Build', 'Products', configuration + '-' + sdkFolder, 'love-ios.app'); fs.mkdirSync(app, { recursive: true }); fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); +const project = fs.readFileSync(path.join(process.cwd(), 'platform', 'xcode', 'love.xcodeproj', 'project.pbxproj'), 'utf8'); +const plist = fs.readFileSync(path.join(process.cwd(), 'platform', 'xcode', 'ios', 'love-ios.plist'), 'utf8'); +const iosResources = project.match(/666666666666666666666666 \\/\\* Resources \\*\\/ = \\{[\\s\\S]*?\\n \\};/)?.[0] || ''; +const macosResources = project.match(/222222222222222222222222 \\/\\* Resources \\*\\/ = \\{[\\s\\S]*?\\n \\};/)?.[0] || ''; fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ argv: args, gameLoveExists: fs.existsSync(path.join(process.cwd(), 'platform', 'xcode', 'game.love')), - projectContainsGameLove: fs.readFileSync(path.join(process.cwd(), 'platform', 'xcode', 'love.xcodeproj', 'project.pbxproj'), 'utf8').includes('game.love'), + projectContainsGameLove: project.includes('game.love'), + iosResourcesContainGameLove: iosResources.includes('FEATHERGAMELOVE000000000001 /* game.love in Resources */'), + macosResourcesContainGameLove: macosResources.includes('FEATHERGAMELOVE000000000001 /* game.love in Resources */'), + plistSupportsIndirectInput: plist.includes('UIApplicationSupportsIndirectInputEvents') && plist.includes(''), }, null, 2)); console.log('fake xcodebuild ' + args.join(' ')); process.exit(0); @@ -2003,6 +2136,7 @@ process.exit(0); assert.equal(parsed.target, 'ios'); assert.equal(existsSync(join(dir, 'builds', 'ios-game-5.0.0.love')), true); assert.equal(existsSync(join(dir, 'builds', 'ios-game-5.0.0-ios.app')), true); + assert.equal(existsSync(join(dir, 'builds', 'ios-game-5.0.0-ios.app', 'game.love')), true); const record = JSON.parse(readFileSync(recordPath, 'utf8')); assert.ok(record.argv.includes('-scheme')); assert.ok(record.argv.includes('FeatherGame')); @@ -2016,6 +2150,9 @@ process.exit(0); assert.ok(record.argv.includes('DEVELOPMENT_TEAM=ABC123XYZ')); assert.equal(record.gameLoveExists, true); assert.equal(record.projectContainsGameLove, true); + assert.equal(record.iosResourcesContainGameLove, true); + assert.equal(record.macosResourcesContainGameLove, false); + assert.equal(record.plistSupportsIndirectInput, true); const entries = readStoredZipEntries(join(dir, 'builds', 'ios-game-5.0.0.love')); assert.equal(entries.has('main.lua'), true); assert.equal(entries.has('.feather-main.lua'), true); @@ -2046,7 +2183,10 @@ const fs = require('node:fs'); const path = require('node:path'); const args = process.argv.slice(2); const derivedData = args[args.indexOf('-derivedDataPath') + 1]; -const app = path.join(derivedData, 'Build', 'Products', 'Debug-iphonesimulator', 'love-ios.app'); +const configuration = args[args.indexOf('-configuration') + 1] || 'Release'; +const sdk = args[args.indexOf('-sdk') + 1] || 'iphonesimulator'; +const sdkFolder = sdk.startsWith('iphoneos') ? 'iphoneos' : 'iphonesimulator'; +const app = path.join(derivedData, 'Build', 'Products', configuration + '-' + sdkFolder, 'love-ios.app'); fs.mkdirSync(app, { recursive: true }); fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); const previous = fs.existsSync(${JSON.stringify(recordPath)}) @@ -2100,7 +2240,10 @@ const fs = require('node:fs'); const path = require('node:path'); const args = process.argv.slice(2); const derivedData = args[args.indexOf('-derivedDataPath') + 1]; -const app = path.join(derivedData, 'Build', 'Products', 'Debug-iphonesimulator', 'love-ios.app'); +const configuration = args[args.indexOf('-configuration') + 1] || 'Release'; +const sdk = args[args.indexOf('-sdk') + 1] || 'iphonesimulator'; +const sdkFolder = sdk.startsWith('iphoneos') ? 'iphoneos' : 'iphonesimulator'; +const app = path.join(derivedData, 'Build', 'Products', configuration + '-' + sdkFolder, 'love-ios.app'); fs.mkdirSync(app, { recursive: true }); fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); console.log('fake xcodebuild ' + args.join(' ')); @@ -2193,6 +2336,62 @@ process.exit(0); assert.ok(record.exportOptions.includes('Release Profile')); }); +test('build ios --release: packages unsigned IPA from archive when no signing team is configured', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Unsigned iOS', + version: '1.2.3', + targets: { + ios: { + loveIosDir: 'love-ios', + bundleIdentifier: 'com.example.unsignedios', + release: { + archivePath: 'builds/unsigned.xcarchive', + exportPath: 'builds/unsigned-export', + }, + }, + }, + }); + const recordPath = join(dir, 'xcodebuild-unsigned-record.json'); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +const previous = fs.existsSync(${JSON.stringify(recordPath)}) + ? JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)}, 'utf8')) + : { records: [] }; +if (args.includes('archive')) { + const archivePath = args[args.indexOf('-archivePath') + 1]; + const app = path.join(archivePath, 'Products', 'Applications', 'love-ios.app'); + fs.mkdirSync(app, { recursive: true }); + fs.writeFileSync(path.join(archivePath, 'Info.plist'), 'fake archive'); + fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); + fs.writeFileSync(path.join(app, 'love-ios'), 'fake executable'); +} +if (args.includes('-exportArchive')) { + throw new Error('unsigned release should not call -exportArchive'); +} +previous.records.push({ argv: args }); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify(previous, null, 2)); +process.exit(0); +`); + + const result = run(['build', 'ios', '--dir', dir, '--release', '--json'], { + env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }), + }); + + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'ipa'), true); + assert.equal(existsSync(join(dir, 'builds', 'unsigned-ios-1.2.3-ios.ipa')), true); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.equal(record.records.length, 1); + assert.ok(record.records[0].argv.includes('archive')); + assert.ok(record.records[0].argv.includes('CODE_SIGNING_ALLOWED=NO')); +}); + test('build mobile: missing native template paths fail with actionable errors', () => { const dir = makeTmp(); writeGame(dir); diff --git a/src-lua/feather/core/debug_overlay.lua b/src-lua/feather/core/debug_overlay.lua index 7bc5df3d..5caede9f 100644 --- a/src-lua/feather/core/debug_overlay.lua +++ b/src-lua/feather/core/debug_overlay.lua @@ -8,6 +8,7 @@ local DEFAULTS = { hideKey = "f12", touchToggle = true, corner = "top-right", + text = true, } local function withDefault(value, fallback) @@ -29,6 +30,10 @@ function DebugOverlay:init(feather, config) self.hideKey = config.hideKey or DEFAULTS.hideKey self.touchToggle = withDefault(config.touchToggle, DEFAULTS.touchToggle) self.corner = config.corner or DEFAULTS.corner + self.text = withDefault(config.text, DEFAULTS.text) + if love and love.system and love.system.getOS and love.system.getOS() == "iOS" and config.text == nil then + self.text = false + end self._lastTouchTime = 0 self._doubleTapWindow = config.doubleTapWindow or 0.35 self._touchSize = config.touchSize or 96 @@ -113,8 +118,8 @@ function DebugOverlay:onDraw() local label = "Feather debugger enabled · " .. text local font = g.getFont() local paddingX, paddingY = 8, 5 - local textWidth = font and font:getWidth(label) or (#label * 7) - local textHeight = font and font:getHeight() or 14 + local textWidth = self.text and font and font:getWidth(label) or (self.text and #label * 7 or 0) + local textHeight = self.text and font and font:getHeight() or 14 local width = textWidth + paddingX * 2 local height = textHeight + paddingY * 2 local screenWidth = g.getWidth() @@ -128,8 +133,10 @@ function DebugOverlay:onDraw() g.rectangle("fill", x, y, width, height, 5, 5) g.setColor(accent) g.rectangle("fill", x, y, 4, height, 5, 5) - g.setColor(1, 1, 1, 0.92) - g.print(label, x + paddingX, y + paddingY) + if self.text then + g.setColor(1, 1, 1, 0.92) + g.print(label, x + paddingX, y + paddingY) + end if g.pop then g.pop() end From 71dad28c68d5a5c7ce0f8712213683aac6ea7395 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 19:35:02 -0400 Subject: [PATCH 50/68] cli: improve e2e structure --- .gitignore | 1 + cli/package.json | 4 +- cli/test/commands.test.mjs | 2809 ---------------------- cli/test/commands/build-android.test.mjs | 399 +++ cli/test/commands/build-ios.test.mjs | 396 +++ cli/test/commands/build-vendor.test.mjs | 204 ++ cli/test/commands/build.test.mjs | 208 ++ cli/test/commands/doctor.test.mjs | 405 ++++ cli/test/commands/help.test.mjs | 15 + cli/test/commands/helpers.mjs | 600 +++++ cli/test/commands/init.test.mjs | 117 + cli/test/{ => commands}/package.test.mjs | 119 +- cli/test/commands/plugins.test.mjs | 169 ++ cli/test/commands/run.test.mjs | 559 +++++ cli/test/commands/runtime.test.mjs | 145 ++ cli/test/commands/upload.test.mjs | 111 + cli/test/e2e.mjs | 219 -- package.json | 2 +- 18 files changed, 3434 insertions(+), 3048 deletions(-) delete mode 100644 cli/test/commands.test.mjs create mode 100644 cli/test/commands/build-android.test.mjs create mode 100644 cli/test/commands/build-ios.test.mjs create mode 100644 cli/test/commands/build-vendor.test.mjs create mode 100644 cli/test/commands/build.test.mjs create mode 100644 cli/test/commands/doctor.test.mjs create mode 100644 cli/test/commands/help.test.mjs create mode 100644 cli/test/commands/helpers.mjs create mode 100644 cli/test/commands/init.test.mjs rename cli/test/{ => commands}/package.test.mjs (91%) create mode 100644 cli/test/commands/plugins.test.mjs create mode 100644 cli/test/commands/run.test.mjs create mode 100644 cli/test/commands/runtime.test.mjs create mode 100644 cli/test/commands/upload.test.mjs delete mode 100644 cli/test/e2e.mjs diff --git a/.gitignore b/.gitignore index 9aa7c03a..f2ef53e3 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ test-results/ .env builds/ vendor/ +feather.build.json \ No newline at end of file diff --git a/cli/package.json b/cli/package.json index c3ea47b6..e0562ff0 100644 --- a/cli/package.json +++ b/cli/package.json @@ -35,8 +35,8 @@ "bundle:lua": "bash ../scripts/bundle-lua.sh", "prepack": "npm run bundle:lua && npm run build", "typecheck": "tsc --noEmit", - "test": "node --test test/*.test.mjs", - "test:e2e": "npm run build && node --test test/*.test.mjs", + "test": "node --test test/commands/*.test.mjs", + "test:e2e": "npm run build && node --test test/commands/*.test.mjs", "package:add": "tsx --tsconfig scripts/tsconfig.json scripts/add-package.tsx", "package:add-url": "tsx --tsconfig scripts/tsconfig.json scripts/add-package-url.tsx", "package:update": "tsx --tsconfig scripts/tsconfig.json scripts/update-package.tsx", diff --git a/cli/test/commands.test.mjs b/cli/test/commands.test.mjs deleted file mode 100644 index e2f0c77e..00000000 --- a/cli/test/commands.test.mjs +++ /dev/null @@ -1,2809 +0,0 @@ -/* eslint-disable no-undef */ -/** - * Focused compiled-CLI coverage for non-package commands. - */ - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { spawn, spawnSync } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import { - chmodSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - symlinkSync, - writeFileSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; -import { delimiter, dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const CLI = fileURLToPath(new URL('../dist/index.js', import.meta.url)); -const ROOT = fileURLToPath(new URL('../..', import.meta.url)); -const LOCAL_SRC = join(ROOT, 'src-lua'); -// eslint-disable-next-line no-control-regex -const ANSI_RE = /\x1B\[[0-?]*[ -/]*[@-~]/; -const sha256 = (value) => createHash('sha256').update(value).digest('hex'); - -function makeTmp() { - return mkdtempSync(join(tmpdir(), 'feather-command-test-')); -} - -function run(args, extra = {}) { - const result = spawnSync(process.execPath, [CLI, ...args], { - encoding: 'utf8', - env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, - ...extra, - }); - return { - stdout: result.stdout ?? '', - stderr: result.stderr ?? '', - exitCode: result.status ?? 1, - }; -} - -function spawnCli(args, extra = {}) { - const child = spawn(process.execPath, [CLI, ...args], { - env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, - ...extra, - }); - child.stdout.setEncoding('utf8'); - child.stderr.setEncoding('utf8'); - return child; -} - -function waitForOutput(child, pattern, timeoutMs = 10000) { - return new Promise((resolveWait, rejectWait) => { - let output = ''; - const timer = setTimeout(() => { - rejectWait(new Error(`Timed out waiting for ${pattern}. Output:\n${output}`)); - }, timeoutMs); - const onData = (chunk) => { - output += chunk; - if (pattern.test(output)) { - clearTimeout(timer); - cleanup(); - resolveWait(output); - } - }; - const onExit = (code) => { - clearTimeout(timer); - cleanup(); - rejectWait(new Error(`Process exited with ${code} before ${pattern}. Output:\n${output}`)); - }; - const cleanup = () => { - child.stdout.off('data', onData); - child.stderr.off('data', onData); - child.off('exit', onExit); - }; - child.stdout.on('data', onData); - child.stderr.on('data', onData); - child.once('exit', onExit); - }); -} - -function stopChild(child) { - return new Promise((resolveStop) => { - if (child.exitCode !== null || child.signalCode !== null) { - resolveStop(); - return; - } - child.once('exit', () => resolveStop()); - child.kill('SIGTERM'); - setTimeout(() => { - if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); - }, 2000).unref(); - }); -} - -function outputOf(result) { - return `${result.stdout}\n${result.stderr}`; -} - -function writeGame(dir) { - mkdirSync(dir, { recursive: true }); - writeFileSync( - join(dir, 'main.lua'), - `function love.update(dt) -end - -function love.draw() -end -`, - ); -} - -function writeMinimalRuntime(dir) { - mkdirSync(join(dir, 'feather', 'lib'), { recursive: true }); - writeFileSync(join(dir, 'feather', 'init.lua'), 'FEATHER_VERSION_NAME = "test"\nreturn {}\n'); - writeFileSync(join(dir, 'feather', 'auto.lua'), 'return {}\n'); - writeFileSync(join(dir, 'feather', 'lib', 'ws.lua'), 'return {}\n'); - writeFileSync(join(dir, 'feather', 'plugin_manager.lua'), 'return {}\n'); -} - -function writeLocalPluginSource(root, id, options = {}) { - const sourceDir = options.sourceDir ?? id.replace(/\./g, '/'); - const pluginDir = join(root, 'plugins', sourceDir); - mkdirSync(pluginDir, { recursive: true }); - const manifestId = options.manifestId ?? id; - const versionLine = options.version === null ? '' : ` version = "${options.version ?? '1.0.0'}",\n`; - writeFileSync( - join(pluginDir, 'manifest.lua'), - `return { - id = "${manifestId}", - name = "${options.name ?? manifestId}", -${versionLine}} -`, - ); - writeFileSync(join(pluginDir, 'init.lua'), 'return {}\n'); - return pluginDir; -} - -function writeLock(dir, packages) { - writeFileSync(join(dir, 'feather.lock.json'), JSON.stringify({ lockfileVersion: 1, packages }, null, 2)); -} - -function writeFakeLove(dir, options = {}) { - const recordPath = options.recordPath ?? join(dir, 'fake-love-record.json'); - const exitCode = options.exitCode ?? 0; - const fakePath = join(dir, 'fake-love.cjs'); - writeFileSync( - fakePath, - `#!/usr/bin/env node -const fs = require('node:fs'); -const path = require('node:path'); -const shimDir = process.argv[2] || ''; -fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ - argv: process.argv.slice(2), - env: { - FEATHER_GAME_PATH: process.env.FEATHER_GAME_PATH, - FEATHER_SESSION_NAME: process.env.FEATHER_SESSION_NAME, - }, - shimMainExists: shimDir ? fs.existsSync(path.join(shimDir, 'main.lua')) : false, - featherAutoExists: shimDir ? fs.existsSync(path.join(shimDir, 'feather', 'auto.lua')) : false, - shimMain: shimDir ? fs.readFileSync(path.join(shimDir, 'main.lua'), 'utf8') : '', -}, null, 2)); -process.exit(${JSON.stringify(exitCode)}); -`, - ); - chmodSync(fakePath, 0o755); - return { fakePath, recordPath }; -} - -function writeFakeAdb(dir, options = {}) { - const recordPath = options.recordPath ?? join(dir, 'adb-record.json'); - const { binDir } = writeFakeCommand(dir, 'adb', ` -const fs = require('node:fs'); -const args = process.argv.slice(2); -const previous = fs.existsSync(${JSON.stringify(recordPath)}) - ? JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)}, 'utf8')) - : []; -previous.push({ args }); -fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify(previous, null, 2)); -if (${JSON.stringify(options.failInstall ?? false)} && args.includes('install')) { - console.error('install failed'); - process.exit(42); -} -process.exit(0); -`); - return { binDir, recordPath }; -} - -function writeFakeXcrun(dir) { - const recordPath = join(dir, 'xcrun-record.json'); - const { binDir } = writeFakeCommand(dir, 'xcrun', ` -const fs = require('node:fs'); -const args = process.argv.slice(2); -const previous = fs.existsSync(${JSON.stringify(recordPath)}) - ? JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)}, 'utf8')) - : []; -previous.push({ args }); -fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify(previous, null, 2)); -process.exit(0); -`); - return { binDir, recordPath }; -} - -function writeFakeCommand(dir, name, script) { - const binDir = join(dir, 'bin'); - mkdirSync(binDir, { recursive: true }); - const commandPath = join(binDir, name); - writeFileSync(commandPath, `#!/usr/bin/env node\n${script}\n`); - chmodSync(commandPath, 0o755); - return { binDir, commandPath }; -} - -function writeFakeVendorGit(dir) { - const recordPath = join(dir, 'git-record.json'); - const { binDir } = writeFakeCommand(dir, 'git', ` -const fs = require('fs'); -const path = require('path'); -const args = process.argv.slice(2); -const recordPath = ${JSON.stringify(recordPath)}; -const records = fs.existsSync(recordPath) ? JSON.parse(fs.readFileSync(recordPath, 'utf8')) : []; -records.push({ args }); -fs.writeFileSync(recordPath, JSON.stringify(records, null, 2)); -if (args[0] === '--version') { - console.log('git version test'); - process.exit(0); -} -if (args[0] !== 'clone') { - console.error('unexpected git args ' + args.join(' ')); - process.exit(1); -} -const target = args[args.length - 1]; -const repo = args[args.length - 2]; -fs.mkdirSync(target, { recursive: true }); -if (repo.includes('love.js')) { - fs.writeFileSync(path.join(target, 'index.html'), ''); - fs.writeFileSync(path.join(target, 'player.js'), 'console.log("love.js");\\n'); -} else if (repo.includes('love-android')) { - fs.writeFileSync(path.join(target, 'gradlew'), '#!/bin/sh\\n'); - fs.mkdirSync(path.join(target, 'app', 'src', 'embed', 'assets'), { recursive: true }); -} else if (repo.includes('love')) { - fs.mkdirSync(path.join(target, 'platform', 'xcode', 'love.xcodeproj'), { recursive: true }); - fs.writeFileSync(path.join(target, 'platform', 'xcode', 'love.xcodeproj', 'project.pbxproj'), ''); -} -process.exit(0); -`); - return { binDir, recordPath }; -} - -function envWithPath(binDir, extra = {}) { - return { - ...process.env, - NO_COLOR: '1', - FORCE_COLOR: '0', - PATH: `${binDir}${delimiter}${process.env.PATH ?? ''}`, - ...extra, - }; -} - -function writeFakeLoveJs(dir) { - const loveJsDir = join(dir, 'love.js'); - mkdirSync(loveJsDir, { recursive: true }); - writeFileSync( - join(loveJsDir, 'index.html'), - 'löve.js', - ); - writeFileSync(join(loveJsDir, 'player.min.js'), 'console.log("love.js");\n'); - return loveJsDir; -} - -function writeFakeLoveAndroid(dir, options = {}) { - const recordPath = options.recordPath ?? join(dir, 'gradle-record.json'); - const apkRel = options.apkRel ?? 'app/build/outputs/apk/embed/debug/app-embed-debug.apk'; - const aabRel = options.aabRel ?? 'app/build/outputs/bundle/embedRelease/app-embed-release.aab'; - const manifestPermission = options.recordAudioPermission - ? ' \n' - : ''; - const root = join(dir, 'love-android'); - mkdirSync(join(root, 'app', 'src', 'main', 'res', 'values'), { recursive: true }); - mkdirSync(join(root, 'app', 'src', 'main'), { recursive: true }); - mkdirSync(join(root, 'love', 'src', 'main', 'java', 'org', 'love2d', 'android'), { recursive: true }); - writeFileSync( - join(root, 'app', 'build.gradle'), - `android { - namespace "org.love2d.android" - defaultConfig { - applicationId "org.love2d.android" - versionCode 1 - versionName "0.0.0" - } -} -`, - ); - writeFileSync( - join(root, 'gradle.properties'), - `app.name_byte_array=76,195,150,86,69 -app.application_id=org.love2d.android -app.orientation=landscape -app.version_code=31 -app.version_name=11.5 -`, - ); - writeFileSync( - join(root, 'app', 'src', 'main', 'AndroidManifest.xml'), - ` -${manifestPermission} - - - - -`, - ); - writeFileSync(join(root, 'app', 'src', 'main', 'res', 'values', 'strings.xml'), 'LÖVE\n'); - writeFileSync( - join(root, 'love', 'src', 'main', 'java', 'org', 'love2d', 'android', 'GameActivity.java'), - `package org.love2d.android; -import java.io.IOException; -import java.io.InputStream; -class GameActivity { - boolean embed; - boolean needToCopyGameInArchive; - Object getResources() { return null; } - Object getAssets() { return null; } - void onCreate() { - embed = getResources().getBoolean(R.bool.embed); - } -} -`, - ); - const gradlew = join(root, 'gradlew'); - writeFileSync( - gradlew, - `#!/usr/bin/env node -const fs = require('node:fs'); -const path = require('node:path'); -const cwd = process.cwd(); -const apk = path.join(cwd, ${JSON.stringify(apkRel)}); -fs.mkdirSync(path.dirname(apk), { recursive: true }); -fs.writeFileSync(apk, 'fake apk'); -const aab = path.join(cwd, ${JSON.stringify(aabRel)}); -fs.mkdirSync(path.dirname(aab), { recursive: true }); -fs.writeFileSync(aab, 'fake aab'); -const previous = fs.existsSync(${JSON.stringify(recordPath)}) - ? JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)}, 'utf8')) - : { records: [] }; -const entry = { - argv: process.argv.slice(2), - cwd, - embeddedLoveExists: fs.existsSync(path.join(cwd, 'app', 'src', 'embed', 'assets', 'game.love')), - gradle: fs.readFileSync(path.join(cwd, 'app', 'build.gradle'), 'utf8'), - gradleProperties: fs.readFileSync(path.join(cwd, 'gradle.properties'), 'utf8'), - manifest: fs.readFileSync(path.join(cwd, 'app', 'src', 'main', 'AndroidManifest.xml'), 'utf8'), - gameActivity: fs.existsSync(path.join(cwd, 'love', 'src', 'main', 'java', 'org', 'love2d', 'android', 'GameActivity.java')) - ? fs.readFileSync(path.join(cwd, 'love', 'src', 'main', 'java', 'org', 'love2d', 'android', 'GameActivity.java'), 'utf8') - : '', - signingProperties: fs.existsSync(path.join(cwd, 'feather-signing.properties')) - ? fs.readFileSync(path.join(cwd, 'feather-signing.properties'), 'utf8') - : '', -}; -previous.records.push(entry); -fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ ...entry, records: previous.records }, null, 2)); -console.log('fake gradle ' + process.argv.slice(2).join(' ')); -process.exit(0); -`, - ); - chmodSync(gradlew, 0o755); - return { root, recordPath }; -} - -function writeFakeLoveIos(dir) { - const root = join(dir, 'love-ios'); - const projectDir = join(root, 'platform', 'xcode', 'love.xcodeproj'); - const plistDir = join(root, 'platform', 'xcode', 'ios'); - mkdirSync(projectDir, { recursive: true }); - mkdirSync(plistDir, { recursive: true }); - writeFileSync( - join(plistDir, 'love-ios.plist'), - ` - - - - CFBundleExecutable - love - CFBundleIdentifier - org.love2d.love - CFBundleName - love - CFBundleShortVersionString - 11.5 - CFBundleVersion - 11.5 - - -`, - ); - writeFileSync( - join(projectDir, 'project.pbxproj'), - `// !$*UTF8*$! -{ -/* Begin PBXBuildFile section */ -/* End PBXBuildFile section */ -/* Begin PBXFileReference section */ -/* End PBXFileReference section */ -/* Begin PBXNativeTarget section */ - 111111111111111111111111 /* love-macosx */ = { - isa = PBXNativeTarget; - buildPhases = ( - 222222222222222222222222 /* Resources */, - ); - name = "love-macosx"; - }; - 333333333333333333333333 /* love-ios */ = { - isa = PBXNativeTarget; - buildPhases = ( - 444444444444444444444444 /* Sources */, - 555555555555555555555555 /* Frameworks */, - 666666666666666666666666 /* Resources */, - ); - name = "love-ios"; - }; -/* End PBXNativeTarget section */ -/* Begin PBXResourcesBuildPhase section */ - 222222222222222222222222 /* Resources */ = { - isa = PBXResourcesBuildPhase; - files = ( - ); - }; - 666666666666666666666666 /* Resources */ = { - isa = PBXResourcesBuildPhase; - files = ( - 777777777777777777777777 /* Launch Screen.xib in Resources */, - ); - }; -/* End PBXResourcesBuildPhase section */ -} -`, - ); - return root; -} - -function writeBuildConfig(dir, config) { - writeFileSync(join(dir, 'feather.build.json'), `${JSON.stringify(config, null, 2)}\n`); -} - -function readStoredZipEntries(zipPath) { - const buffer = readFileSync(zipPath); - const entries = new Map(); - let offset = 0; - while (offset + 30 < buffer.length && buffer.readUInt32LE(offset) === 0x04034b50) { - const method = buffer.readUInt16LE(offset + 8); - const compressedSize = buffer.readUInt32LE(offset + 18); - const fileNameLength = buffer.readUInt16LE(offset + 26); - const extraLength = buffer.readUInt16LE(offset + 28); - const nameStart = offset + 30; - const dataStart = nameStart + fileNameLength + extraLength; - const name = buffer.subarray(nameStart, nameStart + fileNameLength).toString('utf8'); - const data = buffer.subarray(dataStart, dataStart + compressedSize); - if (method !== 0) throw new Error(`Test zip reader only supports stored entries: ${name}`); - entries.set(name, data); - offset = dataStart + compressedSize; - } - return entries; -} - -function createDataDescriptorZipBuffer(entries) { - const chunks = []; - const central = []; - let offset = 0; - - for (const entry of entries) { - const name = Buffer.from(entry.name); - const data = Buffer.from(entry.data); - const local = Buffer.alloc(30); - local.writeUInt32LE(0x04034b50, 0); - local.writeUInt16LE(20, 4); - local.writeUInt16LE(0x08, 6); - local.writeUInt16LE(0, 8); - local.writeUInt32LE(0, 10); - local.writeUInt32LE(0, 14); - local.writeUInt32LE(0, 18); - local.writeUInt32LE(0, 22); - local.writeUInt16LE(name.length, 26); - local.writeUInt16LE(0, 28); - - const descriptor = Buffer.alloc(16); - descriptor.writeUInt32LE(0x08074b50, 0); - descriptor.writeUInt32LE(0, 4); - descriptor.writeUInt32LE(data.length, 8); - descriptor.writeUInt32LE(data.length, 12); - - const centralEntry = Buffer.alloc(46); - centralEntry.writeUInt32LE(0x02014b50, 0); - centralEntry.writeUInt16LE(20, 4); - centralEntry.writeUInt16LE(20, 6); - centralEntry.writeUInt16LE(0x08, 8); - centralEntry.writeUInt16LE(0, 10); - centralEntry.writeUInt32LE(0, 12); - centralEntry.writeUInt32LE(0, 16); - centralEntry.writeUInt32LE(data.length, 20); - centralEntry.writeUInt32LE(data.length, 24); - centralEntry.writeUInt16LE(name.length, 28); - centralEntry.writeUInt16LE(0, 30); - centralEntry.writeUInt16LE(0, 32); - centralEntry.writeUInt16LE(0, 34); - centralEntry.writeUInt16LE(0, 36); - centralEntry.writeUInt32LE(0, 38); - centralEntry.writeUInt32LE(offset, 42); - - chunks.push(local, name, data, descriptor); - central.push(centralEntry, name); - offset += local.length + name.length + data.length + descriptor.length; - } - - const centralOffset = offset; - const centralSize = central.reduce((sum, chunk) => sum + chunk.length, 0); - const end = Buffer.alloc(22); - end.writeUInt32LE(0x06054b50, 0); - end.writeUInt16LE(0, 4); - end.writeUInt16LE(0, 6); - end.writeUInt16LE(entries.length, 8); - end.writeUInt16LE(entries.length, 10); - end.writeUInt32LE(centralSize, 12); - end.writeUInt32LE(centralOffset, 16); - end.writeUInt16LE(0, 20); - - return Buffer.concat([...chunks, ...central, end]); -} - -async function writeFakeAppleLibrariesZip(dir) { - const zipPath = join(dir, 'love-apple-libraries.zip'); - writeFileSync(zipPath, createDataDescriptorZipBuffer([ - { name: 'love-apple-dependencies/iOS/libraries/liblove-test.a', data: Buffer.from('ios lib') }, - { name: 'love-apple-dependencies/macOS/Frameworks/test.framework/test', data: Buffer.from('mac lib') }, - { name: '__MACOSX/love-apple-dependencies/iOS/libraries/._ignored', data: Buffer.from('metadata') }, - ])); - return zipPath; -} - -function parseDoctorJson(dir, extra = []) { - const result = run(['doctor', dir, '--json', ...extra]); - assert.equal(result.exitCode, 0, outputOf(result)); - assert.equal(ANSI_RE.test(result.stdout), false); - assert.equal(result.stdout.trim().startsWith('{'), true); - return JSON.parse(result.stdout); -} - -function parseDoctorJsonResult(dir, extra = []) { - const result = run(['doctor', dir, '--json', ...extra]); - assert.equal(ANSI_RE.test(result.stdout), false); - assert.equal(result.stdout.trim().startsWith('{'), true, outputOf(result)); - return { result, parsed: JSON.parse(result.stdout) }; -} - -test('run: non-interactive missing game path exits with compact error', () => { - const result = run(['run']); - assert.equal(result.exitCode, 1); - assert.ok(outputOf(result).includes('Game path is required')); - assert.equal(outputOf(result).includes('Raw mode is not supported'), false); - assert.equal(outputOf(result).includes('Error:'), false); -}); - -test('run: missing game path and missing main.lua render compact errors', () => { - const dir = makeTmp(); - const missingPath = join(dir, 'missing-game'); - const emptyGame = join(dir, 'empty-game'); - mkdirSync(emptyGame, { recursive: true }); - - const missing = run(['run', missingPath]); - assert.equal(missing.exitCode, 1); - assert.ok(outputOf(missing).includes(`Game path not found: ${resolve(missingPath)}`)); - assert.equal(outputOf(missing).includes('Error:'), false); - - const noMain = run(['run', emptyGame]); - assert.equal(noMain.exitCode, 1); - assert.ok(outputOf(noMain).includes(`No main.lua found in: ${resolve(emptyGame)}`)); - assert.equal(outputOf(noMain).includes('Error:'), false); -}); - -test('run: fake love receives shim, args, env, and exit code is propagated', () => { - const dir = makeTmp(); - const gameDir = join(dir, 'game'); - writeGame(gameDir); - const { fakePath, recordPath } = writeFakeLove(dir, { exitCode: 7 }); - - const result = run([ - 'run', - '--love', - fakePath, - '--session-name', - 'Command Test', - '--no-plugins', - '--feather-path', - join(LOCAL_SRC, 'feather'), - gameDir, - '--', - '--level', - '2', - ]); - - assert.equal(result.exitCode, 7); - const record = JSON.parse(readFileSync(recordPath, 'utf8')); - assert.equal(record.env.FEATHER_GAME_PATH, resolve(gameDir)); - assert.equal(record.env.FEATHER_SESSION_NAME, 'Command Test'); - assert.equal(record.shimMainExists, true); - assert.equal(record.featherAutoExists, true); - assert.equal(existsSync(record.argv[0]), false, 'shim should be cleaned after love exits'); - assert.deepEqual(record.argv.slice(1), ['--level', '2']); -}); - -test('run: source checkout build exposes feather.auto without a bundled cli/lua directory', () => { - const dir = makeTmp(); - const gameDir = join(dir, 'game'); - writeGame(gameDir); - const { fakePath, recordPath } = writeFakeLove(dir); - - const result = run(['run', '--love', fakePath, gameDir]); - - assert.equal(result.exitCode, 0, outputOf(result)); - const record = JSON.parse(readFileSync(recordPath, 'utf8')); - assert.equal(record.featherAutoExists, true); -}); - -test('run: accepts configPath aliases and recovers npm-stripped config path argument', () => { - const dir = makeTmp(); - const gameDir = join(dir, 'game'); - writeGame(gameDir); - const configPath = join(gameDir, 'feather.config.lua'); - writeFileSync(configPath, 'return { sessionName = "From Config Alias" }\n'); - - const alias = writeFakeLove(dir, { recordPath: join(dir, 'alias-record.json') }); - const aliasResult = run(['run', '--love', alias.fakePath, '--configPath', configPath, gameDir]); - assert.equal(aliasResult.exitCode, 0, outputOf(aliasResult)); - const aliasRecord = JSON.parse(readFileSync(alias.recordPath, 'utf8')); - assert.equal(aliasRecord.env.FEATHER_SESSION_NAME, 'From Config Alias'); - assert.deepEqual(aliasRecord.argv.slice(1), []); - - const stripped = writeFakeLove(dir, { recordPath: join(dir, 'stripped-record.json') }); - const strippedResult = run(['run', '--love', stripped.fakePath, gameDir, configPath]); - assert.equal(strippedResult.exitCode, 0, outputOf(strippedResult)); - const strippedRecord = JSON.parse(readFileSync(stripped.recordPath, 'utf8')); - assert.equal(strippedRecord.env.FEATHER_SESSION_NAME, 'From Config Alias'); - assert.deepEqual(strippedRecord.argv.slice(1), []); -}); - -test('run: shim preloads config before requiring feather.auto', () => { - const dir = makeTmp(); - const gameDir = join(dir, 'game'); - writeGame(gameDir); - const configPath = join(gameDir, 'feather.config.lua'); - writeFileSync(configPath, 'return { __DANGEROUS_INSECURE_CONNECTION__ = true, sessionName = "Security Config" }\n'); - const { fakePath, recordPath } = writeFakeLove(dir); - - const result = run(['run', '--love', fakePath, gameDir, '--config', configPath]); - - assert.equal(result.exitCode, 0, outputOf(result)); - const record = JSON.parse(readFileSync(recordPath, 'utf8')); - assert.ok(record.shimMain.includes('FEATHER_AUTO_CONFIG = {')); - assert.ok(record.shimMain.includes('__DANGEROUS_INSECURE_CONNECTION__ = true')); - assert.equal(record.shimMain.includes('require("feather.auto").setup'), false); - assert.ok(record.shimMain.includes('require("feather.auto")')); -}); - -test('run --no-debugger: launches game directly without Feather shim', () => { - const dir = makeTmp(); - const gameDir = join(dir, 'game'); - writeGame(gameDir); - const { fakePath, recordPath } = writeFakeLove(dir); - - const result = run(['run', '--love', fakePath, '--no-debugger', gameDir, '--', '--plain']); - - assert.equal(result.exitCode, 0, outputOf(result)); - assert.ok(outputOf(result).includes('Debugger disabled')); - const record = JSON.parse(readFileSync(recordPath, 'utf8')); - assert.equal(record.argv[0], resolve(gameDir)); - assert.deepEqual(record.argv.slice(1), ['--plain']); - assert.equal(record.featherAutoExists, false); - assert.equal(record.shimMain, readFileSync(join(gameDir, 'main.lua'), 'utf8')); -}); - -test('run --disable-debugger: aliases debugger-disabled desktop launch', () => { - const dir = makeTmp(); - const gameDir = join(dir, 'game'); - writeGame(gameDir); - const { fakePath, recordPath } = writeFakeLove(dir); - - const result = run(['run', '--love', fakePath, '--disable-debugger', gameDir]); - - assert.equal(result.exitCode, 0, outputOf(result)); - const record = JSON.parse(readFileSync(recordPath, 'utf8')); - assert.equal(record.argv[0], resolve(gameDir)); -}); - -test('run --target android: builds, installs, sets adb reverse, launches, and supports device selection', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveAndroid(dir); - writeBuildConfig(dir, { - name: 'Run Android', - version: '1.0.0', - productId: 'com.example.runandroid', - targets: { android: { loveAndroidDir: 'love-android' } }, - }); - writeFileSync(join(dir, 'feather.config.lua'), 'return { port = 4010 }\n'); - const { binDir, recordPath } = writeFakeAdb(dir); - - const result = run(['run', dir, '--target', 'android', '--device', 'emulator-5554'], { env: envWithPath(binDir) }); - assert.equal(result.exitCode, 0, outputOf(result)); - assert.ok(outputOf(result).includes('Launched android')); - assert.ok(outputOf(result).includes('com.example.runandroid')); - const records = JSON.parse(readFileSync(recordPath, 'utf8')); - assert.deepEqual(records.map((entry) => entry.args), [ - ['-s', 'emulator-5554', 'version'], - ['-s', 'emulator-5554', 'install', '-r', join(dir, 'builds', 'run-android-1.0.0-android.apk')], - ['-s', 'emulator-5554', 'shell', 'am', 'force-stop', 'com.example.runandroid'], - ['-s', 'emulator-5554', 'reverse', 'tcp:4010', 'tcp:4010'], - ['-s', 'emulator-5554', 'shell', 'monkey', '-p', 'com.example.runandroid', '-c', 'android.intent.category.LAUNCHER', '1'], - ]); - const entries = readStoredZipEntries(join(dir, 'builds', 'run-android-1.0.0.love')); - assert.equal(entries.has('feather/auto.lua'), true); - assert.match(entries.get('feather.config.lua').toString('utf8'), /port\s*=\s*4010/); -}); - -test('run --target android --config: embeds selected raw Feather config in mobile love archive', () => { - const dir = makeTmp(); - const gameDir = join(dir, 'game'); - writeGame(gameDir); - const configPath = join(gameDir, 'custom-feather.config.lua'); - writeFileSync(configPath, `return { - sessionName = "Mobile Custom", - __DANGEROUS_INSECURE_CONNECTION__ = true, - debugger = { - hotReload = { - enabled = true, - allow = { "game.*" }, - }, - }, -} -`); - writeFakeLoveAndroid(dir); - writeBuildConfig(dir, { - name: 'Run Android Config', - version: '1.0.0', - productId: 'com.example.runandroidconfig', - sourceDir: 'game', - targets: { android: { loveAndroidDir: 'love-android' } }, - }); - const { binDir } = writeFakeAdb(dir); - - const result = run(['run', gameDir, '--target', 'android', '--config', configPath, '--no-adb-reverse'], { - cwd: dir, - env: envWithPath(binDir), - }); - assert.equal(result.exitCode, 0, outputOf(result)); - const entries = readStoredZipEntries(join(dir, 'builds', 'run-android-config-1.0.0.love')); - assert.equal(entries.has('main.lua'), true); - assert.equal(entries.has('.feather-main.lua'), true); - assert.equal(entries.has('feather/core/debug_overlay.lua'), true); - const config = entries.get('feather.config.lua').toString('utf8'); - assert.match(config, /sessionName\s*=\s*"Mobile Custom"/); - assert.match(config, /hotReload\s*=\s*\{/); - assert.match(config, /allow\s*=\s*\{\s*"game\.\*"\s*\}/); -}); - -test('run --target android: uses root build config for a nested game path', () => { - const dir = makeTmp(); - const gameDir = join(dir, 'src-lua', 'example', 'test_cli'); - writeGame(gameDir); - const { recordPath: gradleRecordPath } = writeFakeLoveAndroid(dir); - writeBuildConfig(dir, { - name: 'Nested Android', - version: '1.0.0', - productId: 'com.example.nestedandroid', - targets: { android: { loveAndroidDir: 'love-android' } }, - }); - const { binDir } = writeFakeAdb(dir); - - const result = run(['run', gameDir, '--target', 'android', '--no-adb-reverse'], { - cwd: dir, - env: envWithPath(binDir), - }); - - assert.equal(result.exitCode, 0, outputOf(result)); - assert.ok(outputOf(result).includes('Launched android')); - assert.ok(outputOf(result).includes(gameDir)); - assert.ok(existsSync(join(dir, 'builds', 'nested-android-1.0.0-android.apk'))); - const gradleRecord = JSON.parse(readFileSync(gradleRecordPath, 'utf8')); - assert.equal(gradleRecord.embeddedLoveExists, true); -}); - -test('run --target android --no-adb-reverse skips reverse setup', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveAndroid(dir); - writeBuildConfig(dir, { - name: 'No Reverse', - version: '1.0.0', - productId: 'com.example.noreverse', - targets: { android: { loveAndroidDir: 'love-android' } }, - }); - const { binDir, recordPath } = writeFakeAdb(dir); - - const result = run(['run', dir, '--target', 'android', '--no-adb-reverse'], { env: envWithPath(binDir) }); - assert.equal(result.exitCode, 0, outputOf(result)); - const records = JSON.parse(readFileSync(recordPath, 'utf8')); - assert.equal(records.some((entry) => entry.args.includes('reverse')), false); -}); - -test('run --target android --no-debugger skips adb reverse setup', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveAndroid(dir); - writeBuildConfig(dir, { - name: 'No Debugger Android', - version: '1.0.0', - productId: 'com.example.nodebuggerandroid', - targets: { android: { loveAndroidDir: 'love-android' } }, - }); - const { binDir, recordPath } = writeFakeAdb(dir); - - const result = run(['run', dir, '--target', 'android', '--no-debugger'], { env: envWithPath(binDir) }); - assert.equal(result.exitCode, 0, outputOf(result)); - const records = JSON.parse(readFileSync(recordPath, 'utf8')); - assert.equal(records.some((entry) => entry.args.includes('reverse')), false); - assert.equal(outputOf(result).includes('ADB reverse disabled'), true); - const entries = readStoredZipEntries(join(dir, 'builds', 'no-debugger-android-1.0.0.love')); - assert.equal(entries.has('feather/auto.lua'), false); - assert.equal(entries.has('.feather-main.lua'), false); -}); - -test('run --target android --no-cache forwards cache option to mobile build', () => { - const dir = makeTmp(); - writeGame(dir); - const { recordPath: gradleRecordPath } = writeFakeLoveAndroid(dir); - writeBuildConfig(dir, { - name: 'Run No Cache', - version: '1.0.0', - productId: 'com.example.runnocache', - targets: { android: { loveAndroidDir: 'love-android' } }, - }); - const { binDir } = writeFakeAdb(dir); - - const first = run(['run', dir, '--target', 'android', '--no-adb-reverse', '--no-cache'], { env: envWithPath(binDir) }); - const second = run(['run', dir, '--target', 'android', '--no-adb-reverse', '--no-cache'], { env: envWithPath(binDir) }); - assert.equal(first.exitCode, 0, outputOf(first)); - assert.equal(second.exitCode, 0, outputOf(second)); - assert.equal(existsSync(join(dir, 'builds', '.feather-cache')), false); - const records = JSON.parse(readFileSync(gradleRecordPath, 'utf8')).records; - assert.equal(records.length, 2); - assert.notEqual(records[0].cwd, records[1].cwd); -}); - -test('run --target android: missing adb produces doctor guidance', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveAndroid(dir); - writeBuildConfig(dir, { - name: 'Missing Adb', - version: '1.0.0', - productId: 'com.example.missingadb', - targets: { android: { loveAndroidDir: 'love-android' } }, - }); - - const result = run(['run', dir, '--target', 'android'], { - env: { - ...process.env, - NO_COLOR: '1', - FORCE_COLOR: '0', - PATH: dirname(process.execPath), - }, - }); - assert.equal(result.exitCode, 1); - assert.ok(outputOf(result).includes('adb not found')); - assert.ok(outputOf(result).includes('feather doctor --build-target android')); -}); - -test('run --target android: failed install exits with compact error', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveAndroid(dir); - writeBuildConfig(dir, { - name: 'Install Fail', - version: '1.0.0', - productId: 'com.example.installfail', - targets: { android: { loveAndroidDir: 'love-android' } }, - }); - const { binDir } = writeFakeAdb(dir, { failInstall: true }); - - const result = run(['run', dir, '--target', 'android'], { env: envWithPath(binDir) }); - assert.equal(result.exitCode, 1); - assert.ok(outputOf(result).includes('Android install failed')); - assert.ok(outputOf(result).includes('install failed')); -}); - -test('run --target ios: builds app, installs simulator app, and launches bundle id', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveIos(dir); - writeBuildConfig(dir, { - name: 'Run iOS', - version: '1.0.0', - targets: { - ios: { - loveIosDir: 'love-ios', - bundleIdentifier: 'com.example.runios', - derivedDataPath: 'builds/ios-run-derived-data', - }, - }, - }); - const recordPath = join(dir, 'xcodebuild-run-record.json'); - const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` -if (process.argv.includes('-version')) { - console.log('Xcode 99.0'); - process.exit(0); -} -const fs = require('node:fs'); -const path = require('node:path'); -const args = process.argv.slice(2); -const derivedData = args[args.indexOf('-derivedDataPath') + 1]; -const configuration = args[args.indexOf('-configuration') + 1] || 'Release'; -const sdk = args[args.indexOf('-sdk') + 1] || 'iphonesimulator'; -const sdkFolder = sdk.startsWith('iphoneos') ? 'iphoneos' : 'iphonesimulator'; -const app = path.join(derivedData, 'Build', 'Products', configuration + '-' + sdkFolder, 'love-ios.app'); -fs.mkdirSync(app, { recursive: true }); -fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); -fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ argv: args }, null, 2)); -process.exit(0); -`); - const xcrun = writeFakeXcrun(dir); - - const result = run(['run', dir, '--target', 'ios', '--device', 'SIM-123'], { - env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }), - }); - assert.equal(result.exitCode, 0, outputOf(result)); - assert.ok(outputOf(result).includes('Launched ios')); - const records = JSON.parse(readFileSync(xcrun.recordPath, 'utf8')); - assert.deepEqual(records.map((entry) => entry.args), [ - ['simctl', 'terminate', 'SIM-123', 'com.example.runios'], - ['simctl', 'uninstall', 'SIM-123', 'com.example.runios'], - ['simctl', 'install', 'SIM-123', join(dir, 'builds', 'run-ios-1.0.0-ios.app')], - ['simctl', 'launch', 'SIM-123', 'com.example.runios'], - ]); - assert.equal(existsSync(join(dir, 'builds', 'run-ios-1.0.0-ios.app', 'game.love')), true); -}); - -test('run --target ios: missing xcrun produces doctor guidance', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveIos(dir); - writeBuildConfig(dir, { - name: 'Missing Xcrun', - version: '1.0.0', - targets: { ios: { loveIosDir: 'love-ios', bundleIdentifier: 'com.example.missingxcrun' } }, - }); - const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` -const fs = require('node:fs'); -const path = require('node:path'); -const args = process.argv.slice(2); -const derivedData = args[args.indexOf('-derivedDataPath') + 1]; -const configuration = args[args.indexOf('-configuration') + 1] || 'Release'; -const sdk = args[args.indexOf('-sdk') + 1] || 'iphonesimulator'; -const sdkFolder = sdk.startsWith('iphoneos') ? 'iphoneos' : 'iphonesimulator'; -const app = path.join(derivedData, 'Build', 'Products', configuration + '-' + sdkFolder, 'love-ios.app'); -fs.mkdirSync(app, { recursive: true }); -fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); -process.exit(0); -`); - - const result = run(['run', dir, '--target', 'ios'], { - env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1', PATH: `${binDir}${delimiter}${dirname(process.execPath)}` }), - }); - assert.equal(result.exitCode, 1); - assert.ok(outputOf(result).includes('xcrun not found')); - assert.ok(outputOf(result).includes('feather doctor --build-target ios')); -}); - -test('run mobile: forwarded game arguments are rejected', () => { - const dir = makeTmp(); - writeGame(dir); - - const result = run(['run', dir, '--target', 'android', '--', '--level', 'dev']); - assert.equal(result.exitCode, 1); - assert.ok(outputOf(result).includes('Mobile run does not support forwarded game arguments yet')); -}); - -test('plugin list: missing plugin directory is a clean empty state', () => { - const dir = makeTmp(); - const result = run(['plugin', 'list', dir]); - assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('No plugins directory found')); -}); - -test('plugin install: local source copies console manifest', () => { - const dir = makeTmp(); - const result = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); - assert.equal(result.exitCode, 0, outputOf(result)); - const manifest = readFileSync(join(dir, 'feather', 'plugins', 'console', 'manifest.lua'), 'utf8'); - assert.ok(manifest.includes('id = "console"')); - assert.ok(outputOf(result).includes('Installed console')); -}); - -test('plugin update: explicit local update refreshes damaged files', () => { - const dir = makeTmp(); - run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); - const installedInit = join(dir, 'feather', 'plugins', 'console', 'init.lua'); - writeFileSync(installedInit, 'damaged'); - - const result = run(['plugin', 'update', 'console', '--local-src', LOCAL_SRC, '--dir', dir, '--yes']); - assert.equal(result.exitCode, 0, outputOf(result)); - assert.equal(readFileSync(installedInit, 'utf8'), readFileSync(join(LOCAL_SRC, 'plugins', 'console', 'init.lua'), 'utf8')); - assert.ok(outputOf(result).includes('Updated console')); -}); - -test('plugin update: local --yes updates all installed plugins without selection', () => { - const dir = makeTmp(); - run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); - run(['plugin', 'install', 'hot-reload', '--local-src', LOCAL_SRC, '--dir', dir]); - const consoleInit = join(dir, 'feather', 'plugins', 'console', 'init.lua'); - const hotReloadInit = join(dir, 'feather', 'plugins', 'hot-reload', 'init.lua'); - writeFileSync(consoleInit, 'damaged console'); - writeFileSync(hotReloadInit, 'damaged hot reload'); - - const result = run(['plugin', 'update', '--local-src', LOCAL_SRC, '--dir', dir, '--yes']); - assert.equal(result.exitCode, 0, outputOf(result)); - assert.equal(readFileSync(consoleInit, 'utf8'), readFileSync(join(LOCAL_SRC, 'plugins', 'console', 'init.lua'), 'utf8')); - assert.equal(readFileSync(hotReloadInit, 'utf8'), readFileSync(join(LOCAL_SRC, 'plugins', 'hot-reload', 'init.lua'), 'utf8')); -}); - -test('plugin install: unknown local plugin exits 1', () => { - const dir = makeTmp(); - const result = run(['plugin', 'install', 'zzz-missing', '--local-src', LOCAL_SRC, '--dir', dir]); - assert.equal(result.exitCode, 1); - assert.ok(outputOf(result).includes('Unknown plugin: zzz-missing')); -}); - -test('plugin install: local manifest is validated before copying', () => { - const dir = makeTmp(); - const source = join(makeTmp(), 'src-lua'); - writeLocalPluginSource(source, 'bad-plugin', { version: null }); - - const result = run(['plugin', 'install', 'bad-plugin', '--local-src', source, '--dir', dir]); - assert.equal(result.exitCode, 1); - assert.ok(outputOf(result).includes('Plugin manifest is missing version: bad-plugin')); - assert.equal(existsSync(join(dir, 'feather', 'plugins', 'bad-plugin')), false); -}); - -test('plugin install: local manifest id must match plugin path', () => { - const dir = makeTmp(); - const source = join(makeTmp(), 'src-lua'); - writeLocalPluginSource(source, 'console', { manifestId: 'other-plugin' }); - - const result = run(['plugin', 'install', 'console', '--local-src', source, '--dir', dir]); - assert.equal(result.exitCode, 1); - assert.ok(outputOf(result).includes('Plugin manifest id mismatch: expected console, found other-plugin')); - assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console')), false); -}); - -test('plugin install: rejects path traversal plugin ids', () => { - const dir = makeTmp(); - const result = run(['plugin', 'install', '../escape', '--local-src', LOCAL_SRC, '--dir', dir]); - assert.equal(result.exitCode, 1); - assert.ok(outputOf(result).includes('Invalid plugin id: ../escape')); -}); - -test('plugin install: refuses install directory symlink escaping project', () => { - const dir = makeTmp(); - const outside = join(makeTmp(), 'outside-runtime'); - mkdirSync(outside, { recursive: true }); - symlinkSync(outside, join(dir, 'feather'), 'dir'); - - const result = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); - assert.equal(result.exitCode, 1); - assert.ok(outputOf(result).includes('Plugin install target resolves outside project root')); - assert.equal(existsSync(join(outside, 'plugins', 'console')), false); -}); - -test('plugin update: explicit local update fails on invalid manifest', () => { - const dir = makeTmp(); - const source = join(makeTmp(), 'src-lua'); - writeLocalPluginSource(source, 'bad-plugin', { version: 'not valid' }); - - const result = run(['plugin', 'update', 'bad-plugin', '--local-src', source, '--dir', dir, '--yes']); - assert.equal(result.exitCode, 1); - assert.ok(outputOf(result).includes('Plugin manifest has invalid version: bad-plugin')); - assert.equal(existsSync(join(dir, 'feather', 'plugins', 'bad-plugin')), false); -}); - -test('plugin remove: refuses plugin directory symlink escaping project', () => { - const dir = makeTmp(); - const outside = join(makeTmp(), 'outside-runtime'); - writeLocalPluginSource(outside, 'console'); - symlinkSync(outside, join(dir, 'feather'), 'dir'); - - const result = run(['plugin', 'remove', 'console', '--dir', dir, '--yes']); - assert.equal(result.exitCode, 1); - assert.ok(outputOf(result).includes('Plugin remove target resolves outside project root')); - assert.equal(existsSync(join(outside, 'plugins', 'console', 'manifest.lua')), true); -}); - -test('remove: refuses runtime symlink escaping project', () => { - const dir = makeTmp(); - const outside = join(makeTmp(), 'outside-runtime'); - writeGame(dir); - writeMinimalRuntime(outside); - symlinkSync(join(outside, 'feather'), join(dir, 'feather'), 'dir'); - writeFileSync(join(dir, 'feather.config.lua'), [ - '-- FEATHER-MANAGED-BEGIN', - '-- mode: auto', - '-- installDir: feather', - '-- manualEntrypoint: (none)', - '-- FEATHER-MANAGED-END', - 'return { appId = "feather-app-test" }', - '', - ].join('\n')); - - const result = run(['remove', dir, '--yes']); - assert.equal(result.exitCode, 1); - assert.ok(outputOf(result).includes('Runtime remove target resolves outside project root')); - assert.equal(existsSync(join(outside, 'feather', 'init.lua')), true); -}); - -test('plugin list: malformed manifests do not crash and use directory fallback id', () => { - const dir = makeTmp(); - const pluginDir = join(dir, 'feather', 'plugins', 'bad-plugin'); - mkdirSync(pluginDir, { recursive: true }); - writeFileSync(join(pluginDir, 'manifest.lua'), 'return { name = "Bad Plugin", version = "0.0.1" }\n'); - - const result = run(['plugin', 'list', dir]); - assert.equal(result.exitCode, 0, outputOf(result)); - assert.ok(result.stdout.includes('bad-plugin')); - assert.ok(result.stdout.includes('Bad Plugin')); -}); - -test('doctor --json reports unknown installed plugin trust', () => { - const dir = makeTmp(); - writeGame(dir); - writeMinimalRuntime(dir); - writeLocalPluginSource(dir, 'custom-plugin'); - - const parsed = parseDoctorJson(dir); - const labels = new Map(parsed.checks.map((check) => [check.label, check])); - const trustCheck = labels.get('Plugin custom-plugin'); - assert.equal(trustCheck.severity, 'warn'); - assert.ok(trustCheck.detail.includes('unknown trust')); - assert.ok(trustCheck.fix.includes('feather plugin remove custom-plugin')); -}); - -test('doctor --json reports dangerous bundled plugin trust', () => { - const dir = makeTmp(); - writeGame(dir); - writeMinimalRuntime(dir); - const result = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); - assert.equal(result.exitCode, 0, outputOf(result)); - - const parsed = parseDoctorJson(dir); - const labels = new Map(parsed.checks.map((check) => [check.label, check])); - const trustCheck = labels.get('Plugin console trust'); - assert.equal(trustCheck.severity, 'warn'); - assert.ok(trustCheck.detail.includes('development-only')); -}); - -test('doctor --json warns about runtime symlinks escaping project', () => { - const dir = makeTmp(); - const outside = join(makeTmp(), 'outside-runtime'); - writeGame(dir); - writeMinimalRuntime(outside); - symlinkSync(join(outside, 'feather'), join(dir, 'feather'), 'dir'); - - const parsed = parseDoctorJson(dir); - const symlinkCheck = parsed.checks.find((check) => check.label === 'Symlink escape'); - assert.equal(symlinkCheck.severity, 'warn'); - assert.ok(symlinkCheck.detail.includes('outside-runtime')); -}); - -test('doctor --json remains decoration-free and reports missing plugin directory', () => { - const dir = makeTmp(); - writeGame(dir); - writeMinimalRuntime(dir); - const parsed = parseDoctorJson(dir); - const labels = new Map(parsed.checks.map((check) => [check.label, check])); - assert.equal(labels.get('Plugin directory').severity, 'info'); - assert.equal(labels.get('Plugin directory').detail, 'not installed'); -}); - -test('doctor --json reports malformed plugin manifests with recovery text', () => { - const dir = makeTmp(); - writeGame(dir); - writeMinimalRuntime(dir); - mkdirSync(join(dir, 'feather', 'plugins', 'bad-plugin'), { recursive: true }); - writeFileSync(join(dir, 'feather', 'plugins', 'bad-plugin', 'manifest.lua'), 'return { name = "Bad Plugin" }\n'); - - const parsed = parseDoctorJson(dir); - const labels = new Map(parsed.checks.map((check) => [check.label, check])); - const manifestCheck = labels.get('Plugin manifests'); - assert.equal(manifestCheck.severity, 'warn'); - assert.ok(manifestCheck.fix.includes('feather plugin update')); -}); - -test('doctor: human output honors NO_COLOR', () => { - const dir = makeTmp(); - writeGame(dir); - writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test", include = { "console" } }\n'); - const result = run(['doctor', dir]); - assert.equal(result.exitCode, 0); - assert.equal(ANSI_RE.test(result.stdout), false); - assert.ok(result.stdout.includes('Plugin console')); - assert.ok(result.stdout.includes('feather plugin install console')); -}); - -test('doctor --production fails unsafe remote-control and production settings', () => { - const dir = makeTmp(); - writeGame(dir); - writeFileSync( - join(dir, 'feather.config.lua'), - `return { - __DANGEROUS_INSECURE_CONNECTION__ = true, - host = "0.0.0.0", - include = { "console", "hot-reload" }, - apiKey = "dev", - captureScreenshot = true, - writeToDisk = true, - debugger = { - enabled = true, - hotReload = { - enabled = true, - allow = { "game.*" }, - persistToDisk = true, - }, - }, -} -`, - ); - - const { result, parsed } = parseDoctorJsonResult(dir, ['--production']); - assert.equal(result.exitCode, 1); - assert.equal(parsed.production, true); - const labels = new Map(parsed.checks.map((check) => [check.label, check])); - for (const label of [ - '__DANGEROUS_INSECURE_CONNECTION__', - 'Desktop App ID', - 'captureScreenshot', - 'Hot reload', - 'Hot reload allowlist', - 'Hot reload persistence', - 'Console API key', - 'Step debugger', - 'Disk logging', - 'Network host exposure', - ]) { - assert.equal(labels.get(label)?.severity, 'fail', `${label} should fail in production`); - } -}); - -test('doctor --json keeps unsafe settings warning-oriented outside production', () => { - const dir = makeTmp(); - writeGame(dir); - writeFileSync( - join(dir, 'feather.config.lua'), - `return { - __DANGEROUS_INSECURE_CONNECTION__ = true, - host = "0.0.0.0", - include = { "console" }, - apiKey = "dev", -} -`, - ); - - const { result, parsed } = parseDoctorJsonResult(dir); - assert.equal(result.exitCode, 0, outputOf(result)); - assert.equal(parsed.production, false); - const labels = new Map(parsed.checks.map((check) => [check.label, check])); - assert.equal(labels.get('__DANGEROUS_INSECURE_CONNECTION__')?.severity, 'warn'); - assert.equal(labels.get('Console API key')?.severity, 'warn'); - assert.equal(labels.get('Network host exposure')?.severity, 'warn'); -}); - -test('doctor --security --json emits a sterile security report without secrets', () => { - const dir = makeTmp(); - const secret = 'StrongSecretValue1234567890!'; - writeGame(dir); - writeFileSync( - join(dir, 'feather.config.lua'), - `return { - appId = "feather-app-test-1234567890", - host = "127.0.0.1", - include = { "console" }, - apiKey = "${secret}", -} -`, - ); - - const result = run(['doctor', dir, '--security', '--json']); - assert.equal(result.exitCode, 0, outputOf(result)); - assert.equal(ANSI_RE.test(result.stdout), false); - assert.equal(result.stdout.includes('✔'), false); - assert.equal(result.stdout.includes(secret), false); - - const parsed = JSON.parse(result.stdout); - assert.equal(parsed.security, true); - assert.equal(parsed.report.config.apiKeyStatus, 'configured'); - assert.equal(parsed.report.config.consoleIncluded, true); - assert.equal(parsed.report.network.exposure, 'loopback'); - assert.ok(parsed.checks.every((check) => ['Safety', 'Plugins', 'Packages', 'Runtime', 'Project'].includes(check.group))); - assert.equal(parsed.checks.some((check) => check.label === 'Node.js'), false); -}); - -test('doctor --production fails unmanaged embedded runtime', () => { - const dir = makeTmp(); - writeGame(dir); - writeMinimalRuntime(dir); - writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890", mode = "socket" }\n'); - - const { result, parsed } = parseDoctorJsonResult(dir, ['--production']); - assert.equal(result.exitCode, 1); - const labels = new Map(parsed.checks.map((check) => [check.label, check])); - assert.equal(labels.get('Managed runtime')?.severity, 'fail'); - assert.ok(labels.get('Managed runtime')?.fix.includes('feather init')); -}); - -test('build web: creates love archive, love.js html package, zip, and manifest', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveJs(dir); - writeBuildConfig(dir, { - name: 'Command Game', - version: '1.2.3', - targets: { web: { loveJsDir: 'love.js' } }, - upload: { itch: { project: 'tester/command-game', channels: { web: 'html5' } } }, - }); - - const result = run(['build', 'web', '--dir', dir, '--json']); - assert.equal(result.exitCode, 0, outputOf(result)); - assert.equal(ANSI_RE.test(result.stdout), false); - const parsed = JSON.parse(result.stdout); - assert.equal(parsed.ok, true); - assert.equal(parsed.target, 'web'); - assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'love'), true); - assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'zip'), true); - assert.equal(existsSync(join(dir, 'builds', 'command-game-1.2.3.love')), true); - assert.equal(existsSync(join(dir, 'builds', 'command-game-1.2.3-html.zip')), true); - const index = readFileSync(join(dir, 'builds', 'command-game-1.2.3-html', 'index.html'), 'utf8'); - assert.ok(index.includes('Command Game')); - assert.ok(index.includes('player.min.js?g=game.love')); - assert.equal(index.includes('href="/play/"'), false); - const manifest = JSON.parse(readFileSync(join(dir, 'builds', 'feather-build-manifest.json'), 'utf8')); - assert.equal(manifest.target, 'web'); -}); - -test('run --target web: builds, embeds Feather, serves generated html, and stays running', async () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveJs(dir); - writeFileSync(join(dir, 'feather.config.lua'), `return { - sessionName = "Web Custom", - __DANGEROUS_INSECURE_CONNECTION__ = true, - debugOverlay = { visible = false }, -} -`); - writeBuildConfig(dir, { - name: 'Run Web', - version: '1.0.0', - targets: { web: { loveJsDir: 'love.js' } }, - }); - - const child = spawnCli(['run', dir, '--target', 'web', '--web-port', '0', '--config', join(dir, 'feather.config.lua')], { - env: envWithPath('', { FEATHER_TEST_WEB_RUN_NO_SERVER: '1' }), - }); - try { - const output = await waitForOutput(child, /Debugger\s+enabled/); - assert.match(output, /Serving web build/); - assert.match(output, /Debugger\s+enabled/); - assert.equal(existsSync(join(dir, 'builds', 'run-web-1.0.0-html', 'index.html')), true); - const entries = readStoredZipEntries(join(dir, 'builds', 'run-web-1.0.0.love')); - assert.equal(entries.has('main.lua'), true); - assert.equal(entries.has('.feather-main.lua'), true); - assert.equal(entries.has('feather/auto.lua'), true); - assert.equal(entries.has('feather/core/debug_overlay.lua'), true); - assert.match(entries.get('feather.config.lua').toString('utf8'), /sessionName\s*=\s*"Web Custom"/); - assert.match(entries.get('feather.config.lua').toString('utf8'), /debugOverlay\s*=\s*\{/); - } finally { - await stopChild(child); - } -}); - -test('run --target web --no-debugger: builds and serves raw source', async () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveJs(dir); - writeBuildConfig(dir, { - name: 'Run Web Raw', - version: '1.0.0', - targets: { web: { loveJsDir: 'love.js' } }, - }); - - const child = spawnCli(['run', dir, '--target', 'web', '--web-port', '0', '--no-debugger'], { - env: envWithPath('', { FEATHER_TEST_WEB_RUN_NO_SERVER: '1' }), - }); - try { - const output = await waitForOutput(child, /Debugger\s+disabled/); - assert.match(output, /Debugger\s+disabled/); - const entries = readStoredZipEntries(join(dir, 'builds', 'run-web-raw-1.0.0.love')); - assert.equal(entries.has('main.lua'), true); - assert.equal(entries.has('.feather-main.lua'), false); - assert.equal(entries.has('feather/auto.lua'), false); - } finally { - await stopChild(child); - } -}); - -test('run --target web: rejects forwarded game arguments', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveJs(dir); - writeBuildConfig(dir, { - name: 'Run Web Args', - version: '1.0.0', - targets: { web: { loveJsDir: 'love.js' } }, - }); - - const result = run(['run', dir, '--target', 'web', '--', '--foo']); - assert.equal(result.exitCode, 1); - assert.ok(outputOf(result).includes('Web run does not support forwarded game arguments yet.')); -}); - -test('run --target web: missing love.js config exits with web build guidance', () => { - const dir = makeTmp(); - writeGame(dir); - - const result = run(['run', dir, '--target', 'web']); - assert.equal(result.exitCode, 1); - assert.ok(outputOf(result).includes('Web build requires targets.web.loveJsDir')); -}); - -test('build linux: delegates desktop packaging to love-release and writes manifest', () => { - const dir = makeTmp(); - writeGame(dir); - writeBuildConfig(dir, { name: 'Desktop Game', version: '2.0.0' }); -const recordPath = join(dir, 'love-release-record.json'); - const { binDir } = writeFakeCommand(dir, 'love-release', ` -if (process.argv.length === 3 && process.argv[2] === '--version') { - console.log('love-release test'); - process.exit(0); -} -const fs = require('node:fs'); -fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ argv: process.argv.slice(2) }, null, 2)); -process.exit(0); -`); - - const result = run(['build', 'linux', '--dir', dir, '--json'], { env: envWithPath(binDir) }); - assert.equal(result.exitCode, 0, outputOf(result)); - const parsed = JSON.parse(result.stdout); - assert.equal(parsed.ok, true); - assert.equal(existsSync(join(dir, 'builds', 'desktop-game-2.0.0.love')), true); - const record = JSON.parse(readFileSync(recordPath, 'utf8')); - assert.ok(record.argv.includes('--target')); - assert.ok(record.argv.includes('linux')); - assert.ok(record.argv.includes('--name')); - assert.ok(record.argv.includes('Desktop Game')); -}); - -test('build validation: rejects bad mobile config values and unsafe native paths', async () => { - const { validateAndroidBuildConfig, validateIosBuildConfig } = await import('../dist/lib/build/validation.js'); - const { resolveWorkspacePath } = await import('../dist/lib/build/native.js'); - const baseConfig = { - configPath: '/tmp/feather.build.json', - projectDir: '/tmp/game', - sourceDir: '/tmp/game', - outDir: '/tmp/game/builds', - name: 'Validation Game', - version: '1.0.0', - include: [], - exclude: [], - includeRuntime: false, - targets: {}, - upload: {}, - }; - - const androidIssues = validateAndroidBuildConfig({ - ...baseConfig, - productId: 'not a product id', - targets: { android: { versionCode: 0, orientation: 'sideways', gradleTask: 'assemble debug' } }, - }); - assert.deepEqual(androidIssues.map((issue) => issue.field), [ - 'productId', - 'targets.android.versionCode', - 'targets.android.orientation', - 'targets.android.gradleTask', - ]); - const androidReleaseIssues = validateAndroidBuildConfig({ - ...baseConfig, - targets: { - android: { - release: { - bundleTask: 'bundle release', - apkArtifactPath: '', - storePasswordEnv: '1BAD_ENV', - keyPasswordEnv: 'GOOD_ENV', - }, - }, - }, - }, true); - assert.ok(androidReleaseIssues.some((issue) => issue.field === 'targets.android.release.bundleTask')); - assert.ok(androidReleaseIssues.some((issue) => issue.field === 'targets.android.release.apkArtifactPath')); - assert.ok(androidReleaseIssues.some((issue) => issue.field === 'targets.android.release.storePasswordEnv')); - - const iosIssues = validateIosBuildConfig({ - ...baseConfig, - targets: { ios: { bundleIdentifier: 'bad id', scheme: 'bad;', derivedDataPath: '../escape' } }, - }); - assert.deepEqual(iosIssues.map((issue) => issue.field), [ - 'bundleIdentifier', - 'targets.ios.scheme', - 'targets.ios.derivedDataPath', - ]); - const iosReleaseIssues = validateIosBuildConfig({ - ...baseConfig, - targets: { ios: { release: { exportMethod: 'side-load', signingStyle: 'sometimes', teamId: 'BAD TEAM' } } }, - }, true); - assert.ok(iosReleaseIssues.some((issue) => issue.field === 'targets.ios.release.exportMethod')); - assert.ok(iosReleaseIssues.some((issue) => issue.field === 'targets.ios.release.signingStyle')); - assert.ok(iosReleaseIssues.some((issue) => issue.field === 'targets.ios.release.teamId')); - assert.throws(() => resolveWorkspacePath('/tmp/native-work', '../escape.apk', 'Artifact'), /native build workspace/); -}); - -test('build release: non-mobile targets fail cleanly', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveJs(dir); - writeBuildConfig(dir, { name: 'Web Release', version: '1.0.0', targets: { web: { loveJsDir: 'love.js' } } }); - - const result = run(['build', 'web', '--dir', dir, '--release', '--json']); - assert.equal(result.exitCode, 1); - assert.ok(outputOf(result).includes('Release mode is currently supported only for android and ios')); -}); - -test('build vendor add android --json: clones vendor and updates config', () => { - const dir = makeTmp(); - writeGame(dir); - writeBuildConfig(dir, { name: 'Vendor Android', version: '1.0.0', loveVersion: '11.5' }); - const { binDir, recordPath } = writeFakeVendorGit(dir); - - const result = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--json'], { env: envWithPath(binDir) }); - assert.equal(result.exitCode, 0, outputOf(result)); - assert.equal(ANSI_RE.test(result.stdout), false); - const parsed = JSON.parse(result.stdout); - assert.equal(parsed.vendors[0].target, 'android'); - assert.equal(parsed.vendors[0].relativePath, 'vendor/love-android'); - assert.equal(parsed.vendors[0].configUpdated, true); - assert.equal(existsSync(join(dir, 'vendor', 'love-android', 'gradlew')), true); - const config = JSON.parse(readFileSync(join(dir, 'feather.build.json'), 'utf8')); - assert.equal(config.targets.android.loveAndroidDir, 'vendor/love-android'); - const records = JSON.parse(readFileSync(recordPath, 'utf8')); - assert.ok(records.some((record) => record.args.includes('--recurse-submodules'))); - assert.ok(records.some((record) => record.args.includes('https://github.com/love2d/love-android'))); -}); - -test('build vendor add web --json: clones love.js vendor and updates config', () => { - const dir = makeTmp(); - writeGame(dir); - writeBuildConfig(dir, { name: 'Vendor Web', version: '1.0.0', loveVersion: '11.5' }); - const { binDir, recordPath } = writeFakeVendorGit(dir); - - const result = run(['build', 'vendor', 'add', 'web', '--dir', dir, '--json'], { env: envWithPath(binDir) }); - assert.equal(result.exitCode, 0, outputOf(result)); - assert.equal(ANSI_RE.test(result.stdout), false); - const parsed = JSON.parse(result.stdout); - assert.equal(parsed.vendors[0].target, 'web'); - assert.equal(parsed.vendors[0].relativePath, 'vendor/love.js'); - assert.equal(parsed.vendors[0].ref, 'main'); - assert.equal(parsed.vendors[0].configUpdated, true); - assert.equal(existsSync(join(dir, 'vendor', 'love.js', 'index.html')), true); - assert.equal(existsSync(join(dir, 'vendor', 'love.js', 'player.js')), true); - const config = JSON.parse(readFileSync(join(dir, 'feather.build.json'), 'utf8')); - assert.equal(config.targets.web.loveJsDir, 'vendor/love.js'); - const records = JSON.parse(readFileSync(recordPath, 'utf8')); - assert.ok(records.some((record) => record.args.includes('https://github.com/2dengine/love.js'))); -}); - -test('build vendor add ios --json: clones vendor, installs Apple libraries, and updates config', async () => { - const dir = makeTmp(); - writeGame(dir); - writeBuildConfig(dir, { name: 'Vendor iOS', version: '1.0.0', loveVersion: '11.5' }); - const { binDir, recordPath } = writeFakeVendorGit(dir); - const zipPath = await writeFakeAppleLibrariesZip(dir); - - const result = run(['build', 'vendor', 'add', 'ios', '--dir', dir, '--json'], { - env: envWithPath(binDir, { FEATHER_TEST_LOVE_APPLE_LIBRARIES_ZIP: zipPath }), - }); - assert.equal(result.exitCode, 0, outputOf(result)); - assert.equal(ANSI_RE.test(result.stdout), false); - const parsed = JSON.parse(result.stdout); - assert.equal(parsed.vendors[0].target, 'ios'); - assert.equal(parsed.vendors[0].relativePath, 'vendor/love-ios'); - assert.equal(existsSync(join(dir, 'vendor', 'love-ios', 'platform', 'xcode', 'ios', 'libraries', 'liblove-test.a')), true); - assert.equal(existsSync(join(dir, 'vendor', 'love-ios', 'platform', 'xcode', 'macosx', 'Frameworks', 'test.framework', 'test')), true); - assert.equal(existsSync(join(dir, 'vendor', 'love-ios', 'platform', 'xcode', 'ios', 'libraries', '._ignored')), false); - const config = JSON.parse(readFileSync(join(dir, 'feather.build.json'), 'utf8')); - assert.equal(config.targets.ios.loveIosDir, 'vendor/love-ios'); - const records = JSON.parse(readFileSync(recordPath, 'utf8')); - assert.ok(records.some((record) => record.args.includes('https://github.com/love2d/love'))); -}); - -test('build vendor add mobile --dry-run --json: reports planned vendors without writing', () => { - const dir = makeTmp(); - writeGame(dir); - - const result = run(['build', 'vendor', 'add', 'mobile', '--dir', dir, '--dry-run', '--json']); - assert.equal(result.exitCode, 0, outputOf(result)); - assert.equal(ANSI_RE.test(result.stdout), false); - const parsed = JSON.parse(result.stdout); - assert.deepEqual(parsed.vendors.map((vendor) => vendor.target), ['android', 'ios']); - assert.equal(existsSync(join(dir, 'vendor')), false); - assert.equal(existsSync(join(dir, 'feather.build.json')), false); -}); - -test('build vendor add all --dry-run --json: includes web and mobile vendors', () => { - const dir = makeTmp(); - writeGame(dir); - - const result = run(['build', 'vendor', 'add', 'all', '--dir', dir, '--dry-run', '--json']); - assert.equal(result.exitCode, 0, outputOf(result)); - assert.equal(ANSI_RE.test(result.stdout), false); - const parsed = JSON.parse(result.stdout); - assert.deepEqual(parsed.vendors.map((vendor) => vendor.target), ['android', 'ios', 'web']); - assert.equal(existsSync(join(dir, 'vendor')), false); -}); - -test('build vendor add --no-config: fetches vendor without writing build config', () => { - const dir = makeTmp(); - writeGame(dir); - const { binDir } = writeFakeVendorGit(dir); - - const result = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--no-config', '--json'], { env: envWithPath(binDir) }); - assert.equal(result.exitCode, 0, outputOf(result)); - assert.equal(existsSync(join(dir, 'vendor', 'love-android', 'gradlew')), true); - assert.equal(existsSync(join(dir, 'feather.build.json')), false); - const parsed = JSON.parse(result.stdout); - assert.equal(parsed.vendors[0].configUpdated, false); -}); - -test('build vendor add: existing directories and conflicting config require --force', () => { - const dir = makeTmp(); - writeGame(dir); - mkdirSync(join(dir, 'vendor', 'love-android'), { recursive: true }); - - const existing = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--json']); - assert.equal(existing.exitCode, 1); - assert.ok(outputOf(existing).includes('--force')); - - writeBuildConfig(dir, { targets: { android: { loveAndroidDir: 'native/love-android' } } }); - const conflict = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--dry-run', '--json']); - assert.equal(conflict.exitCode, 1); - assert.ok(outputOf(conflict).includes('already configured')); - - const { binDir } = writeFakeVendorGit(dir); - const forced = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--force', '--json'], { env: envWithPath(binDir) }); - assert.equal(forced.exitCode, 0, outputOf(forced)); - const config = JSON.parse(readFileSync(join(dir, 'feather.build.json'), 'utf8')); - assert.equal(config.targets.android.loveAndroidDir, 'vendor/love-android'); -}); - -test('build vendor list --json: reports configured, missing, and valid vendors', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveJs(dir); - writeFakeLoveAndroid(dir); - writeBuildConfig(dir, { - targets: { - web: { loveJsDir: 'love.js' }, - android: { loveAndroidDir: 'love-android' }, - ios: { loveIosDir: 'vendor/love-ios' }, - }, - }); - - const result = run(['build', 'vendor', 'list', '--dir', dir, '--json']); - assert.equal(result.exitCode, 0, outputOf(result)); - assert.equal(ANSI_RE.test(result.stdout), false); - const parsed = JSON.parse(result.stdout); - const labels = new Map(parsed.vendors.map((vendor) => [vendor.target, vendor])); - assert.equal(labels.get('web').valid, true); - assert.equal(labels.get('android').valid, true); - assert.equal(labels.get('ios').exists, false); - assert.equal(labels.get('ios').detail, 'missing'); -}); - -test('build vendor add: missing git produces compact actionable error', () => { - const dir = makeTmp(); - writeGame(dir); - - const result = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--json'], { - env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0', PATH: '' }, - }); - assert.equal(result.exitCode, 1); - assert.ok(outputOf(result).includes('git is required')); -}); - -test('build android: invalid config fails before staging or writing artifacts', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveAndroid(dir); - writeBuildConfig(dir, { - name: 'Bad Mobile Game', - version: '1.0.0', - productId: 'bad product id', - targets: { android: { loveAndroidDir: 'love-android', versionCode: 0 } }, - }); - - const result = run(['build', 'android', '--dir', dir, '--allow-unsafe', '--json']); - assert.equal(result.exitCode, 1); - assert.ok(outputOf(result).includes('Invalid android build config')); - assert.ok(outputOf(result).includes('productId')); - assert.equal(existsSync(join(dir, 'builds', 'bad-mobile-game-1.0.0.love')), false); -}); - -test('build android: injects game.love, runs Gradle, copies APK, and writes manifest', () => { - const dir = makeTmp(); - writeGame(dir); - const { recordPath } = writeFakeLoveAndroid(dir); - writeBuildConfig(dir, { - name: 'Mobile Game', - version: '1.2.3', - productId: 'com.example.mobilegame', - targets: { - android: { - loveAndroidDir: 'love-android', - displayName: 'Mobile Game Dev', - orientation: 'landscape', - recordAudio: true, - versionCode: 7, - versionName: '1.2.3-dev', - }, - }, - }); - - const result = run(['build', 'android', '--dir', dir, '--allow-unsafe', '--json']); - assert.equal(result.exitCode, 0, outputOf(result)); - assert.equal(ANSI_RE.test(result.stdout), false); - const parsed = JSON.parse(result.stdout); - assert.equal(parsed.ok, true); - assert.equal(parsed.target, 'android'); - assert.equal(existsSync(join(dir, 'builds', 'mobile-game-1.2.3.love')), true); - assert.equal(existsSync(join(dir, 'builds', 'mobile-game-1.2.3-android.apk')), true); - assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'apk'), true); - const record = JSON.parse(readFileSync(recordPath, 'utf8')); - assert.deepEqual(record.argv, ['assembleEmbedRecordDebug']); - assert.equal(record.embeddedLoveExists, true); - assert.ok(record.gradle.includes('applicationId "com.example.mobilegame"')); - assert.ok(record.gradle.includes('versionCode 7')); - assert.ok(record.gradle.includes('versionName "1.2.3-dev"')); - assert.ok(record.gradleProperties.includes('app.application_id=com.example.mobilegame')); - assert.ok(record.gradleProperties.includes('app.orientation=landscape')); - assert.ok(record.gradleProperties.includes('app.version_code=7')); - assert.ok(record.gradleProperties.includes('app.version_name=1.2.3-dev')); - assert.equal(record.gradleProperties.includes('app.name='), false); - assert.ok(record.gradleProperties.includes('app.name_byte_array=')); - assert.ok(record.manifest.includes('android:label="Mobile Game Dev"')); - assert.ok(record.manifest.includes('android:screenOrientation="landscape"')); - assert.ok(record.manifest.includes('android.permission.RECORD_AUDIO')); - assert.ok(record.gameActivity.includes('Feather: forcing embedded game.love from assets')); - const manifest = JSON.parse(readFileSync(join(dir, 'builds', 'feather-build-manifest.json'), 'utf8')); - assert.equal(manifest.target, 'android'); - assert.equal(manifest.artifacts.some((artifact) => artifact.type === 'apk'), true); -}); - -test('build android: embeds Feather debugger runtime and raw config in dev love archive', () => { - const dir = makeTmp(); - writeGame(dir); - writeFileSync( - join(dir, 'feather.config.lua'), - `return { - sessionName = "Nested Config", - __DANGEROUS_INSECURE_CONNECTION__ = true, - debugOverlay = { - enabled = true, - visible = false, - }, -} -`, - ); - writeFakeLoveAndroid(dir); - writeBuildConfig(dir, { - name: 'Embedded Android', - version: '1.0.0', - productId: 'com.example.embeddedandroid', - targets: { android: { loveAndroidDir: 'love-android' } }, - }); - - const result = run(['build', 'android', '--dir', dir, '--allow-unsafe', '--json']); - assert.equal(result.exitCode, 0, outputOf(result)); - const entries = readStoredZipEntries(join(dir, 'builds', 'embedded-android-1.0.0.love')); - assert.equal(entries.has('main.lua'), true); - assert.equal(entries.has('.feather-main.lua'), true); - assert.equal(entries.has('feather/auto.lua'), true); - assert.equal(entries.has('feather/core/debug_overlay.lua'), true); - assert.equal(entries.has('plugins/profiler/manifest.lua'), true); - assert.match(entries.get('main.lua').toString('utf8'), /require\("feather\.auto"\)/); - assert.match(entries.get('feather.config.lua').toString('utf8'), /debugOverlay\s*=\s*\{/); - assert.match(entries.get('feather.config.lua').toString('utf8'), /visible\s*=\s*false/); -}); - -test('build android --no-debugger: builds raw source without Feather embed', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveAndroid(dir); - writeBuildConfig(dir, { - name: 'Raw Android', - version: '1.0.0', - productId: 'com.example.rawandroid', - targets: { android: { loveAndroidDir: 'love-android' } }, - }); - - const result = run(['build', 'android', '--dir', dir, '--no-debugger', '--json']); - assert.equal(result.exitCode, 0, outputOf(result)); - const entries = readStoredZipEntries(join(dir, 'builds', 'raw-android-1.0.0.love')); - assert.equal(entries.has('main.lua'), true); - assert.equal(entries.has('.feather-main.lua'), false); - assert.equal(entries.has('feather/auto.lua'), false); - assert.equal(entries.has('feather.config.lua'), false); -}); - -test('build android --release: does not auto-embed Feather debugger runtime', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveAndroid(dir); - writeBuildConfig(dir, { - name: 'Release Raw Android', - version: '1.0.0', - productId: 'com.example.releaserawandroid', - targets: { android: { loveAndroidDir: 'love-android' } }, - }); - - const result = run(['build', 'android', '--dir', dir, '--release', '--json']); - assert.equal(result.exitCode, 0, outputOf(result)); - const entries = readStoredZipEntries(join(dir, 'builds', 'release-raw-android-1.0.0.love')); - assert.equal(entries.has('.feather-main.lua'), false); - assert.equal(entries.has('feather/auto.lua'), false); -}); - -test('build android: reuses dev native cache between builds', () => { - const dir = makeTmp(); - writeGame(dir); - const { recordPath } = writeFakeLoveAndroid(dir); - writeBuildConfig(dir, { - name: 'Cached Android', - version: '1.0.0', - productId: 'com.example.cachedandroid', - targets: { android: { loveAndroidDir: 'love-android' } }, - }); - - const first = run(['build', 'android', '--dir', dir, '--json']); - assert.equal(first.exitCode, 0, outputOf(first)); - const firstParsed = JSON.parse(first.stdout); - assert.equal(firstParsed.cache.enabled, true); - assert.equal(firstParsed.cache.hit, false); - assert.ok(firstParsed.cache.path.includes(join('builds', '.feather-cache', 'android'))); - assert.equal(existsSync(firstParsed.cache.path), true); - - const second = run(['build', 'android', '--dir', dir, '--json']); - assert.equal(second.exitCode, 0, outputOf(second)); - const secondParsed = JSON.parse(second.stdout); - assert.equal(secondParsed.cache.enabled, true); - assert.equal(secondParsed.cache.hit, true); - assert.equal(secondParsed.cache.key, firstParsed.cache.key); - assert.equal(secondParsed.cache.path, firstParsed.cache.path); - - const records = JSON.parse(readFileSync(recordPath, 'utf8')).records; - assert.equal(records.length, 2); - assert.equal(records[0].cwd, records[1].cwd); - assert.ok(records[0].cwd.includes(join('builds', '.feather-cache', 'android'))); -}); - -test('build android --no-cache uses fresh native workspaces', () => { - const dir = makeTmp(); - writeGame(dir); - const { recordPath } = writeFakeLoveAndroid(dir); - writeBuildConfig(dir, { - name: 'Uncached Android', - version: '1.0.0', - productId: 'com.example.uncachedandroid', - targets: { android: { loveAndroidDir: 'love-android' } }, - }); - - const first = run(['build', 'android', '--dir', dir, '--no-cache', '--json']); - const second = run(['build', 'android', '--dir', dir, '--no-cache', '--json']); - assert.equal(first.exitCode, 0, outputOf(first)); - assert.equal(second.exitCode, 0, outputOf(second)); - assert.equal(JSON.parse(first.stdout).cache.enabled, false); - assert.equal(JSON.parse(second.stdout).cache.enabled, false); - assert.equal(existsSync(join(dir, 'builds', '.feather-cache')), false); - const records = JSON.parse(readFileSync(recordPath, 'utf8')).records; - assert.equal(records.length, 2); - assert.notEqual(records[0].cwd, records[1].cwd); - assert.equal(records[0].cwd.includes('.feather-cache'), false); - assert.equal(records[1].cwd.includes('.feather-cache'), false); -}); - -test('build android: stale native cache missing Gradle wrapper is recopied', () => { - const dir = makeTmp(); - writeGame(dir); - const { recordPath } = writeFakeLoveAndroid(dir); - writeBuildConfig(dir, { - name: 'Stale Cached Android', - version: '1.0.0', - productId: 'com.example.stalecachedandroid', - targets: { android: { loveAndroidDir: 'love-android' } }, - }); - - const first = run(['build', 'android', '--dir', dir, '--json']); - assert.equal(first.exitCode, 0, outputOf(first)); - const firstParsed = JSON.parse(first.stdout); - rmSync(join(firstParsed.cache.path, 'love-android', process.platform === 'win32' ? 'gradlew.bat' : 'gradlew'), { force: true }); - - const second = run(['build', 'android', '--dir', dir, '--json']); - assert.equal(second.exitCode, 0, outputOf(second)); - const secondParsed = JSON.parse(second.stdout); - assert.equal(secondParsed.cache.enabled, true); - assert.equal(secondParsed.cache.hit, false); - assert.equal(secondParsed.cache.path, firstParsed.cache.path); - assert.equal(existsSync(join(secondParsed.cache.path, 'love-android', process.platform === 'win32' ? 'gradlew.bat' : 'gradlew')), true); - const records = JSON.parse(readFileSync(recordPath, 'utf8')).records; - assert.equal(records.length, 2); -}); - -test('build android --clean resets dev native cache', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveAndroid(dir); - writeBuildConfig(dir, { - name: 'Clean Cached Android', - version: '1.0.0', - productId: 'com.example.cleancachedandroid', - targets: { android: { loveAndroidDir: 'love-android' } }, - }); - - const first = run(['build', 'android', '--dir', dir, '--json']); - const second = run(['build', 'android', '--dir', dir, '--json']); - const cleaned = run(['build', 'android', '--dir', dir, '--clean', '--json']); - assert.equal(first.exitCode, 0, outputOf(first)); - assert.equal(second.exitCode, 0, outputOf(second)); - assert.equal(cleaned.exitCode, 0, outputOf(cleaned)); - assert.equal(JSON.parse(first.stdout).cache.hit, false); - assert.equal(JSON.parse(second.stdout).cache.hit, true); - assert.equal(JSON.parse(cleaned.stdout).cache.hit, false); -}); - -test('build android --verbose: shows native build steps and Gradle output', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveAndroid(dir); - writeBuildConfig(dir, { - name: 'Verbose Android', - version: '1.0.0', - productId: 'com.example.verboseandroid', - targets: { android: { loveAndroidDir: 'love-android' } }, - }); - - const result = run(['build', 'android', '--dir', dir, '--verbose']); - - assert.equal(result.exitCode, 0, outputOf(result)); - assert.ok(result.stdout.includes('Building android in verbose mode')); - assert.ok(result.stdout.includes('Staged')); - assert.ok(result.stdout.includes('Android workspace')); - assert.ok(result.stdout.includes('assembleEmbedNoRecordDebug')); - assert.ok(result.stdout.includes('fake gradle assembleEmbedNoRecordDebug')); -}); - -test('build android: honors gradleTask, custom artifactPath, and removes microphone permission', () => { - const dir = makeTmp(); - writeGame(dir); - const { recordPath } = writeFakeLoveAndroid(dir, { - recordAudioPermission: true, - apkRel: 'app/build/outputs/custom/custom-debug.apk', - }); - writeBuildConfig(dir, { - name: 'Custom Android', - version: '2.0.0', - productId: 'com.example.customandroid', - targets: { - android: { - loveAndroidDir: 'love-android', - gradleTask: ':app:assembleCustomDebug', - artifactPath: 'app/build/outputs/custom/custom-debug.apk', - recordAudio: false, - }, - }, - }); - - const result = run(['build', 'android', '--dir', dir, '--json']); - assert.equal(result.exitCode, 0, outputOf(result)); - assert.equal(existsSync(join(dir, 'builds', 'custom-android-2.0.0-android.apk')), true); - const record = JSON.parse(readFileSync(recordPath, 'utf8')); - assert.deepEqual(record.argv, [':app:assembleCustomDebug']); - assert.equal(record.manifest.includes('android.permission.RECORD_AUDIO'), false); - assert.equal(record.manifest.includes('android.permission.INTERNET'), true); -}); - -test('build android --release: runs bundle/apk tasks, copies artifacts, and keeps signing secrets out of output', () => { - const dir = makeTmp(); - writeGame(dir); - writeFileSync(join(dir, 'release.keystore'), 'fake keystore'); - const { recordPath } = writeFakeLoveAndroid(dir, { - recordAudioPermission: true, - aabRel: 'app/build/outputs/custom/store-release.aab', - apkRel: 'app/build/outputs/custom/store-release.apk', - }); - writeBuildConfig(dir, { - name: 'Store Android', - version: '3.0.0', - productId: 'com.example.storeandroid', - targets: { - android: { - loveAndroidDir: 'love-android', - recordAudio: true, - release: { - bundleTask: ':app:bundleStoreRelease', - apkTask: ':app:assembleStoreRelease', - bundleArtifactPath: 'app/build/outputs/custom/store-release.aab', - apkArtifactPath: 'app/build/outputs/custom/store-release.apk', - keystorePath: 'release.keystore', - keyAlias: 'release-key', - storePasswordEnv: 'FEATHER_TEST_STORE_PASSWORD', - keyPasswordEnv: 'FEATHER_TEST_KEY_PASSWORD', - }, - }, - }, - }); - - const result = run(['build', 'android', '--dir', dir, '--release', '--json'], { - env: { - ...process.env, - NO_COLOR: '1', - FORCE_COLOR: '0', - FEATHER_TEST_STORE_PASSWORD: 'store-secret', - FEATHER_TEST_KEY_PASSWORD: 'key-secret', - }, - }); - assert.equal(result.exitCode, 0, outputOf(result)); - assert.equal(ANSI_RE.test(result.stdout), false); - assert.equal(outputOf(result).includes('store-secret'), false); - assert.equal(outputOf(result).includes('key-secret'), false); - const parsed = JSON.parse(result.stdout); - assert.equal(parsed.cache.enabled, false); - assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'aab'), true); - assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'apk'), true); - assert.equal(existsSync(join(dir, 'builds', 'store-android-3.0.0-android.aab')), true); - assert.equal(existsSync(join(dir, 'builds', 'store-android-3.0.0-android.apk')), true); - const record = JSON.parse(readFileSync(recordPath, 'utf8')); - assert.deepEqual(record.records.map((entry) => entry.argv[0]), [':app:bundleStoreRelease', ':app:assembleStoreRelease']); - assert.ok(record.signingProperties.includes('keyAlias=release-key')); - assert.ok(record.signingProperties.includes('storePassword=store-secret')); -}); - -test('build ios: invalid bundle id fails before xcodebuild', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveIos(dir); - writeBuildConfig(dir, { - name: 'Bad iOS Game', - version: '1.0.0', - targets: { ios: { loveIosDir: 'love-ios', bundleIdentifier: 'bad id' } }, - }); - const recordPath = join(dir, 'xcodebuild-record.json'); - const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` -const fs = require('node:fs'); -fs.writeFileSync(${JSON.stringify(recordPath)}, 'ran'); -process.exit(0); -`); - - const result = run(['build', 'ios', '--dir', dir, '--json'], { env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }) }); - assert.equal(result.exitCode, 1); - assert.ok(outputOf(result).includes('Invalid ios build config')); - assert.equal(existsSync(recordPath), false); -}); - -test('build ios: injects game.love, runs xcodebuild, copies app, and writes manifest', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveIos(dir); - writeBuildConfig(dir, { - name: 'iOS Game', - version: '5.0.0', - targets: { - ios: { - loveIosDir: 'love-ios', - bundleIdentifier: 'com.example.iosgame', - displayName: 'iOS Game Dev', - scheme: 'FeatherGame', - configuration: 'Release', - sdk: 'iphonesimulator', - derivedDataPath: 'builds/ios-derived-data', - teamId: 'ABC123XYZ', - }, - }, - }); - const recordPath = join(dir, 'xcodebuild-record.json'); - const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` -if (process.argv.includes('-version')) { - console.log('Xcode 99.0'); - process.exit(0); -} -const fs = require('node:fs'); -const path = require('node:path'); -const args = process.argv.slice(2); -const derivedData = args[args.indexOf('-derivedDataPath') + 1]; -const configuration = args[args.indexOf('-configuration') + 1] || 'Release'; -const sdk = args[args.indexOf('-sdk') + 1] || 'iphonesimulator'; -const sdkFolder = sdk.startsWith('iphoneos') ? 'iphoneos' : 'iphonesimulator'; -const app = path.join(derivedData, 'Build', 'Products', configuration + '-' + sdkFolder, 'love-ios.app'); -fs.mkdirSync(app, { recursive: true }); -fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); -const project = fs.readFileSync(path.join(process.cwd(), 'platform', 'xcode', 'love.xcodeproj', 'project.pbxproj'), 'utf8'); -const plist = fs.readFileSync(path.join(process.cwd(), 'platform', 'xcode', 'ios', 'love-ios.plist'), 'utf8'); -const iosResources = project.match(/666666666666666666666666 \\/\\* Resources \\*\\/ = \\{[\\s\\S]*?\\n \\};/)?.[0] || ''; -const macosResources = project.match(/222222222222222222222222 \\/\\* Resources \\*\\/ = \\{[\\s\\S]*?\\n \\};/)?.[0] || ''; -fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ - argv: args, - gameLoveExists: fs.existsSync(path.join(process.cwd(), 'platform', 'xcode', 'game.love')), - projectContainsGameLove: project.includes('game.love'), - iosResourcesContainGameLove: iosResources.includes('FEATHERGAMELOVE000000000001 /* game.love in Resources */'), - macosResourcesContainGameLove: macosResources.includes('FEATHERGAMELOVE000000000001 /* game.love in Resources */'), - plistSupportsIndirectInput: plist.includes('UIApplicationSupportsIndirectInputEvents') && plist.includes(''), -}, null, 2)); -console.log('fake xcodebuild ' + args.join(' ')); -process.exit(0); -`); - - const result = run(['build', 'ios', '--dir', dir, '--no-cache', '--json'], { env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }) }); - assert.equal(result.exitCode, 0, outputOf(result)); - assert.equal(ANSI_RE.test(result.stdout), false); - const parsed = JSON.parse(result.stdout); - assert.equal(parsed.ok, true); - assert.equal(parsed.target, 'ios'); - assert.equal(existsSync(join(dir, 'builds', 'ios-game-5.0.0.love')), true); - assert.equal(existsSync(join(dir, 'builds', 'ios-game-5.0.0-ios.app')), true); - assert.equal(existsSync(join(dir, 'builds', 'ios-game-5.0.0-ios.app', 'game.love')), true); - const record = JSON.parse(readFileSync(recordPath, 'utf8')); - assert.ok(record.argv.includes('-scheme')); - assert.ok(record.argv.includes('FeatherGame')); - assert.ok(record.argv.includes('-configuration')); - assert.ok(record.argv.includes('Release')); - assert.ok(record.argv.includes('-sdk')); - assert.ok(record.argv.includes('iphonesimulator')); - assert.ok(record.argv.includes(join(dir, 'builds', 'ios-derived-data'))); - assert.ok(record.argv.includes('PRODUCT_BUNDLE_IDENTIFIER=com.example.iosgame')); - assert.ok(record.argv.includes('INFOPLIST_KEY_CFBundleDisplayName=iOS Game Dev')); - assert.ok(record.argv.includes('DEVELOPMENT_TEAM=ABC123XYZ')); - assert.equal(record.gameLoveExists, true); - assert.equal(record.projectContainsGameLove, true); - assert.equal(record.iosResourcesContainGameLove, true); - assert.equal(record.macosResourcesContainGameLove, false); - assert.equal(record.plistSupportsIndirectInput, true); - const entries = readStoredZipEntries(join(dir, 'builds', 'ios-game-5.0.0.love')); - assert.equal(entries.has('main.lua'), true); - assert.equal(entries.has('.feather-main.lua'), true); - assert.equal(entries.has('feather/auto.lua'), true); - assert.equal(entries.has('feather/core/debug_overlay.lua'), true); - const manifest = JSON.parse(readFileSync(join(dir, 'builds', 'feather-build-manifest.json'), 'utf8')); - assert.equal(manifest.target, 'ios'); - assert.equal(manifest.artifacts.some((artifact) => artifact.type === 'app'), true); -}); - -test('build ios: reuses dev native cache and cached DerivedData between builds', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveIos(dir); - writeBuildConfig(dir, { - name: 'Cached iOS', - version: '1.0.0', - targets: { - ios: { - loveIosDir: 'love-ios', - bundleIdentifier: 'com.example.cachedios', - }, - }, - }); - const recordPath = join(dir, 'xcodebuild-cache-record.json'); - const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` -const fs = require('node:fs'); -const path = require('node:path'); -const args = process.argv.slice(2); -const derivedData = args[args.indexOf('-derivedDataPath') + 1]; -const configuration = args[args.indexOf('-configuration') + 1] || 'Release'; -const sdk = args[args.indexOf('-sdk') + 1] || 'iphonesimulator'; -const sdkFolder = sdk.startsWith('iphoneos') ? 'iphoneos' : 'iphonesimulator'; -const app = path.join(derivedData, 'Build', 'Products', configuration + '-' + sdkFolder, 'love-ios.app'); -fs.mkdirSync(app, { recursive: true }); -fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); -const previous = fs.existsSync(${JSON.stringify(recordPath)}) - ? JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)}, 'utf8')) - : { records: [] }; -previous.records.push({ - argv: args, - cwd: process.cwd(), - derivedData, - gameLoveExists: fs.existsSync(path.join(process.cwd(), 'platform', 'xcode', 'game.love')), -}); -fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify(previous, null, 2)); -process.exit(0); -`); - const env = envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }); - - const first = run(['build', 'ios', '--dir', dir, '--json'], { env }); - assert.equal(first.exitCode, 0, outputOf(first)); - const firstParsed = JSON.parse(first.stdout); - assert.equal(firstParsed.cache.enabled, true); - assert.equal(firstParsed.cache.hit, false); - - const second = run(['build', 'ios', '--dir', dir, '--json'], { env }); - assert.equal(second.exitCode, 0, outputOf(second)); - const secondParsed = JSON.parse(second.stdout); - assert.equal(secondParsed.cache.enabled, true); - assert.equal(secondParsed.cache.hit, true); - assert.equal(secondParsed.cache.path, firstParsed.cache.path); - - const records = JSON.parse(readFileSync(recordPath, 'utf8')).records; - assert.equal(records.length, 2); - assert.equal(records[0].cwd, records[1].cwd); - assert.equal(records[0].derivedData, records[1].derivedData); - assert.ok(records[0].cwd.includes(join('builds', '.feather-cache', 'ios'))); - assert.ok(records[0].derivedData.includes(join('builds', '.feather-cache', 'ios'))); - assert.equal(records[0].gameLoveExists, true); - assert.equal(records[1].gameLoveExists, true); -}); - -test('build ios --verbose: shows native build steps and xcodebuild output', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveIos(dir); - writeBuildConfig(dir, { - name: 'Verbose iOS', - version: '1.0.0', - targets: { ios: { loveIosDir: 'love-ios', bundleIdentifier: 'com.example.verboseios' } }, - }); - const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` -const fs = require('node:fs'); -const path = require('node:path'); -const args = process.argv.slice(2); -const derivedData = args[args.indexOf('-derivedDataPath') + 1]; -const configuration = args[args.indexOf('-configuration') + 1] || 'Release'; -const sdk = args[args.indexOf('-sdk') + 1] || 'iphonesimulator'; -const sdkFolder = sdk.startsWith('iphoneos') ? 'iphoneos' : 'iphonesimulator'; -const app = path.join(derivedData, 'Build', 'Products', configuration + '-' + sdkFolder, 'love-ios.app'); -fs.mkdirSync(app, { recursive: true }); -fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); -console.log('fake xcodebuild ' + args.join(' ')); -process.exit(0); -`); - - const result = run(['build', 'ios', '--dir', dir, '--verbose'], { - env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }), - }); - - assert.equal(result.exitCode, 0, outputOf(result)); - assert.ok(result.stdout.includes('Building ios in verbose mode')); - assert.ok(result.stdout.includes('iOS workspace')); - assert.ok(result.stdout.includes('xcodebuild')); - assert.ok(result.stdout.includes('fake xcodebuild')); -}); - -test('build ios --release: archives, exports IPA, and writes release manifest artifacts', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveIos(dir); - writeBuildConfig(dir, { - name: 'Release iOS', - version: '6.0.0', - targets: { - ios: { - loveIosDir: 'love-ios', - bundleIdentifier: 'com.example.releaseios', - displayName: 'Release iOS', - scheme: 'ReleaseGame', - release: { - archivePath: 'builds/native-release.xcarchive', - exportPath: 'builds/native-export', - exportMethod: 'app-store-connect', - signingStyle: 'manual', - provisioningProfileSpecifier: 'Release Profile', - teamId: 'TEAM12345', - configuration: 'Release', - sdk: 'iphoneos', - }, - }, - }, - }); - const recordPath = join(dir, 'xcodebuild-release-record.json'); - const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` -if (process.argv.includes('-version')) { - console.log('Xcode 99.0'); - process.exit(0); -} -const fs = require('node:fs'); -const path = require('node:path'); -const args = process.argv.slice(2); -const previous = fs.existsSync(${JSON.stringify(recordPath)}) - ? JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)}, 'utf8')) - : { records: [] }; -if (args.includes('archive')) { - const archivePath = args[args.indexOf('-archivePath') + 1]; - fs.mkdirSync(archivePath, { recursive: true }); - fs.writeFileSync(path.join(archivePath, 'Info.plist'), 'fake archive'); -} -if (args.includes('-exportArchive')) { - const exportPath = args[args.indexOf('-exportPath') + 1]; - const exportOptions = args[args.indexOf('-exportOptionsPlist') + 1]; - fs.mkdirSync(exportPath, { recursive: true }); - fs.writeFileSync(path.join(exportPath, 'Release iOS.ipa'), 'fake ipa'); - previous.exportOptions = fs.readFileSync(exportOptions, 'utf8'); -} -previous.records.push({ argv: args }); -fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify(previous, null, 2)); -process.exit(0); -`); - - const result = run(['build', 'ios', '--dir', dir, '--release', '--json'], { - env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }), - }); - assert.equal(result.exitCode, 0, outputOf(result)); - assert.equal(ANSI_RE.test(result.stdout), false); - const parsed = JSON.parse(result.stdout); - assert.equal(parsed.cache.enabled, false); - assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'xcarchive'), true); - assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'ipa'), true); - assert.equal(existsSync(join(dir, 'builds', 'release-ios-6.0.0-ios.xcarchive')), true); - assert.equal(existsSync(join(dir, 'builds', 'release-ios-6.0.0-ios.ipa')), true); - const record = JSON.parse(readFileSync(recordPath, 'utf8')); - assert.ok(record.records[0].argv.includes('archive')); - assert.ok(record.records[0].argv.includes('DEVELOPMENT_TEAM=TEAM12345')); - assert.ok(record.records[1].argv.includes('-exportArchive')); - assert.ok(record.exportOptions.includes('app-store-connect')); - assert.ok(record.exportOptions.includes('manual')); - assert.ok(record.exportOptions.includes('Release Profile')); -}); - -test('build ios --release: packages unsigned IPA from archive when no signing team is configured', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveIos(dir); - writeBuildConfig(dir, { - name: 'Unsigned iOS', - version: '1.2.3', - targets: { - ios: { - loveIosDir: 'love-ios', - bundleIdentifier: 'com.example.unsignedios', - release: { - archivePath: 'builds/unsigned.xcarchive', - exportPath: 'builds/unsigned-export', - }, - }, - }, - }); - const recordPath = join(dir, 'xcodebuild-unsigned-record.json'); - const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` -const fs = require('node:fs'); -const path = require('node:path'); -const args = process.argv.slice(2); -const previous = fs.existsSync(${JSON.stringify(recordPath)}) - ? JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)}, 'utf8')) - : { records: [] }; -if (args.includes('archive')) { - const archivePath = args[args.indexOf('-archivePath') + 1]; - const app = path.join(archivePath, 'Products', 'Applications', 'love-ios.app'); - fs.mkdirSync(app, { recursive: true }); - fs.writeFileSync(path.join(archivePath, 'Info.plist'), 'fake archive'); - fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); - fs.writeFileSync(path.join(app, 'love-ios'), 'fake executable'); -} -if (args.includes('-exportArchive')) { - throw new Error('unsigned release should not call -exportArchive'); -} -previous.records.push({ argv: args }); -fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify(previous, null, 2)); -process.exit(0); -`); - - const result = run(['build', 'ios', '--dir', dir, '--release', '--json'], { - env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }), - }); - - assert.equal(result.exitCode, 0, outputOf(result)); - const parsed = JSON.parse(result.stdout); - assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'ipa'), true); - assert.equal(existsSync(join(dir, 'builds', 'unsigned-ios-1.2.3-ios.ipa')), true); - const record = JSON.parse(readFileSync(recordPath, 'utf8')); - assert.equal(record.records.length, 1); - assert.ok(record.records[0].argv.includes('archive')); - assert.ok(record.records[0].argv.includes('CODE_SIGNING_ALLOWED=NO')); -}); - -test('build mobile: missing native template paths fail with actionable errors', () => { - const dir = makeTmp(); - writeGame(dir); - writeBuildConfig(dir, { - name: 'Missing Mobile Template', - version: '1.0.0', - productId: 'com.example.missingmobiletemplate', - }); - - const android = run(['build', 'android', '--dir', dir, '--allow-unsafe', '--json']); - assert.equal(android.exitCode, 1); - assert.ok(outputOf(android).includes('targets.android.loveAndroidDir')); - - const ios = run(['build', 'ios', '--dir', dir, '--allow-unsafe', '--json'], { env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0', FEATHER_TEST_ALLOW_IOS_BUILD: '1' } }); - assert.equal(ios.exitCode, 1); - assert.ok(outputOf(ios).includes('targets.ios.loveIosDir')); -}); - -test('build ios: non-macOS hosts fail with setup guidance', { skip: process.platform === 'darwin' }, () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveIos(dir); - writeBuildConfig(dir, { - name: 'Non Mac iOS', - version: '1.0.0', - targets: { ios: { loveIosDir: 'love-ios', bundleIdentifier: 'com.example.nonmacios' } }, - }); - - const result = run(['build', 'ios', '--dir', dir, '--json']); - assert.equal(result.exitCode, 1); - assert.ok(outputOf(result).includes('iOS builds require macOS with Xcode')); -}); - -test('build: production preflight blocks unsafe Feather config unless explicitly allowed', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveJs(dir); - writeBuildConfig(dir, { name: 'Unsafe Game', version: '1.0.0', targets: { web: { loveJsDir: 'love.js' } } }); - writeFileSync(join(dir, 'feather.config.lua'), 'return { include = { "console" }, apiKey = "dev" }\n'); - - const blocked = run(['build', 'web', '--dir', dir, '--json']); - assert.equal(blocked.exitCode, 1); - assert.ok(outputOf(blocked).includes('Production build preflight failed')); - - const allowed = run(['build', 'web', '--dir', dir, '--json', '--allow-unsafe']); - assert.equal(allowed.exitCode, 0, outputOf(allowed)); - assert.equal(JSON.parse(allowed.stdout).ok, true); -}); - -test('upload itch: dry-run uses build manifest and configured channel', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveJs(dir); - writeBuildConfig(dir, { - name: 'Upload Game', - version: '3.4.5', - targets: { web: { loveJsDir: 'love.js' } }, - upload: { itch: { project: 'tester/upload-game', channels: { web: 'html5' } } }, - }); - const build = run(['build', 'web', '--dir', dir, '--json']); - assert.equal(build.exitCode, 0, outputOf(build)); - - const result = run(['upload', 'itch', 'web', '--dir', dir, '--dry-run', '--json']); - assert.equal(result.exitCode, 0, outputOf(result)); - assert.equal(ANSI_RE.test(result.stdout), false); - const parsed = JSON.parse(result.stdout); - assert.equal(parsed.ok, true); - assert.equal(parsed.dryRun, true); - assert.equal(parsed.project, 'tester/upload-game'); - assert.equal(parsed.channel, 'html5'); - assert.equal(parsed.userVersion, '3.4.5'); - assert.deepEqual(parsed.command.slice(0, 2), ['butler', 'push']); -}); - -test('upload itch: fake butler receives artifact, channel, version, and flags', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveJs(dir); - writeBuildConfig(dir, { - name: 'Butler Game', - version: '4.0.0', - targets: { web: { loveJsDir: 'love.js' } }, - upload: { itch: { project: 'tester/butler-game', channels: { web: 'html5' } } }, - }); - const build = run(['build', 'web', '--dir', dir, '--json']); - assert.equal(build.exitCode, 0, outputOf(build)); - const recordPath = join(dir, 'butler-record.json'); - const { binDir } = writeFakeCommand(dir, 'butler', ` -if (process.argv.includes('--version')) { - console.log('butler test'); - process.exit(0); -} -const fs = require('node:fs'); -fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ argv: process.argv.slice(2) }, null, 2)); -process.exit(0); -`); - - const result = run(['upload', 'itch', 'web', '--dir', dir, '--if-changed', '--hidden', '--json'], { env: envWithPath(binDir) }); - assert.equal(result.exitCode, 0, outputOf(result)); - const record = JSON.parse(readFileSync(recordPath, 'utf8')); - assert.equal(record.argv[0], 'push'); - assert.ok(record.argv[1].endsWith('butler-game-4.0.0.love') || record.argv[1].endsWith('butler-game-4.0.0-html.zip')); - assert.equal(record.argv[2], 'tester/butler-game:html5'); - assert.ok(record.argv.includes('--userversion')); - assert.ok(record.argv.includes('4.0.0')); - assert.ok(record.argv.includes('--if-changed')); - assert.ok(record.argv.includes('--hidden')); -}); - -test('upload steam: planned target fails cleanly', () => { - const dir = makeTmp(); - writeGame(dir); - const result = run(['upload', 'steam', '--dir', dir, '--json']); - assert.equal(result.exitCode, 1); - assert.ok(outputOf(result).includes('planned but not supported yet')); -}); - -test('doctor: build and upload target checks report missing and configured dependencies', () => { - const dir = makeTmp(); - writeGame(dir); - writeBuildConfig(dir, { - name: 'Doctor Build Game', - version: '1.0.0', - upload: { itch: { project: 'tester/doctor-build-game' } }, - }); - const { parsed: missing } = parseDoctorJsonResult(dir, ['--build-target', 'web', '--upload-target', 'itch']); - const missingLabels = new Map(missing.checks.map((check) => [check.label, check])); - assert.equal(missingLabels.get('love.js player')?.severity, 'fail'); - assert.equal(missingLabels.get('butler')?.severity, 'fail'); - assert.ok(missingLabels.get('love.js player')?.fix.includes('targets.web.loveJsDir')); - - writeFakeLoveJs(dir); - writeBuildConfig(dir, { - name: 'Doctor Build Game', - version: '1.0.0', - targets: { web: { loveJsDir: 'love.js' } }, - upload: { itch: { project: 'tester/doctor-build-game' } }, - }); - const { binDir } = writeFakeCommand(dir, 'butler', `console.log('butler test'); process.exit(0);`); - const configured = run(['doctor', dir, '--json', '--build-target', 'web', '--upload-target', 'itch'], { env: envWithPath(binDir, { BUTLER_API_KEY: 'test-key' }) }); - assert.equal(configured.exitCode, 0, outputOf(configured)); - const parsed = JSON.parse(configured.stdout); - const labels = new Map(parsed.checks.map((check) => [check.label, check])); - assert.equal(labels.get('love.js player')?.severity, 'pass'); - assert.equal(labels.get('butler')?.severity, 'pass'); - assert.equal(labels.get('BUTLER_API_KEY')?.severity, 'pass'); -}); - -test('doctor: android build target reports template and local tool setup', () => { - const dir = makeTmp(); - writeGame(dir); - writeBuildConfig(dir, { name: 'Android Doctor Game', version: '1.0.0' }); - const { parsed: missing } = parseDoctorJsonResult(dir, ['--build-target', 'android']); - const missingLabels = new Map(missing.checks.map((check) => [check.label, check])); - assert.equal(missingLabels.get('love-android template')?.severity, 'fail'); - assert.equal(missingLabels.get('Android Gradle wrapper')?.severity, 'fail'); - assert.ok(missingLabels.get('love-android template')?.fix.includes('targets.android.loveAndroidDir')); - assert.ok(missingLabels.get('love-android template')?.fix.includes('feather build vendor add android')); - - writeFakeLoveAndroid(dir); - writeBuildConfig(dir, { - name: 'Android Doctor Game', - version: '1.0.0', - productId: 'com.example.androiddoctor', - targets: { android: { loveAndroidDir: 'love-android' } }, - }); - const { binDir } = writeFakeCommand(dir, 'java', `console.error('java version "17.0.0"'); process.exit(0);`); - const configured = run(['doctor', dir, '--json', '--build-target', 'android'], { - env: envWithPath(binDir, { ANDROID_HOME: join(dir, 'android-sdk') }), - }); - assert.equal(configured.stdout.trim().startsWith('{'), true, outputOf(configured)); - const parsed = JSON.parse(configured.stdout); - const labels = new Map(parsed.checks.map((check) => [check.label, check])); - assert.equal(labels.get('love-android template')?.severity, 'pass'); - assert.equal(labels.get('Android Gradle wrapper')?.severity, 'pass'); - assert.equal(labels.get('JDK')?.severity, 'pass'); - assert.equal(labels.get('Android SDK')?.severity, 'pass'); - assert.equal(labels.get('Android product id')?.severity, 'pass'); - assert.equal(labels.get('Android signing')?.severity, 'warn'); -}); - -test('doctor: mobile build target reports config validation failures', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveAndroid(dir); - writeBuildConfig(dir, { - name: 'Doctor Bad Android', - version: '1.0.0', - productId: 'bad product id', - targets: { android: { loveAndroidDir: 'love-android', versionCode: 0 } }, - }); - - const { result, parsed } = parseDoctorJsonResult(dir, ['--build-target', 'android']); - assert.equal(result.exitCode, 1); - const labels = new Map(parsed.checks.map((check) => [check.label, check])); - assert.equal(labels.get('Android config')?.severity, 'fail'); - assert.ok(labels.get('Android config')?.detail.includes('versionCode')); - assert.equal(labels.get('Android product id')?.severity, 'fail'); -}); - -test('doctor: android release reports missing signing environment', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveAndroid(dir); - writeFileSync(join(dir, 'release.keystore'), 'fake keystore'); - writeBuildConfig(dir, { - name: 'Doctor Android Release', - version: '1.0.0', - productId: 'com.example.doctorandroidrelease', - targets: { - android: { - loveAndroidDir: 'love-android', - release: { - keystorePath: 'release.keystore', - keyAlias: 'release-key', - storePasswordEnv: 'FEATHER_MISSING_STORE_PASSWORD', - keyPasswordEnv: 'FEATHER_MISSING_KEY_PASSWORD', - }, - }, - }, - }); - - const { result, parsed } = parseDoctorJsonResult(dir, ['--build-target', 'android', '--release']); - assert.equal(result.exitCode, 1); - const labels = new Map(parsed.checks.map((check) => [check.label, check])); - assert.equal(labels.get('Android config')?.severity, 'pass'); - assert.equal(labels.get('Android signing')?.severity, 'fail'); - assert.ok(labels.get('Android signing')?.detail.includes('FEATHER_MISSING_STORE_PASSWORD')); -}); - -test('doctor: ios build target reports platform, template, Xcode, and signing hints', () => { - const dir = makeTmp(); - writeGame(dir); - writeBuildConfig(dir, { name: 'iOS Doctor Game', version: '1.0.0' }); - const { parsed: missing } = parseDoctorJsonResult(dir, ['--build-target', 'ios']); - const missingLabels = new Map(missing.checks.map((check) => [check.label, check])); - assert.equal(missingLabels.get('LÖVE iOS template')?.severity, 'fail'); - assert.equal(missingLabels.get('LÖVE iOS Xcode project')?.severity, 'fail'); - assert.ok(missingLabels.get('LÖVE iOS template')?.fix.includes('feather build vendor add ios')); - - writeFakeLoveIos(dir); - writeBuildConfig(dir, { - name: 'iOS Doctor Game', - version: '1.0.0', - targets: { ios: { loveIosDir: 'love-ios', bundleIdentifier: 'com.example.iosdoctor' } }, - }); - const { binDir } = writeFakeCommand(dir, 'xcodebuild', `console.log('Xcode 99.0'); process.exit(0);`); - const configured = run(['doctor', dir, '--json', '--build-target', 'ios'], { env: envWithPath(binDir) }); - assert.equal(configured.stdout.trim().startsWith('{'), true, outputOf(configured)); - const parsed = JSON.parse(configured.stdout); - const labels = new Map(parsed.checks.map((check) => [check.label, check])); - assert.equal(labels.get('xcodebuild')?.severity, 'pass'); - assert.equal(labels.get('LÖVE iOS template')?.severity, 'pass'); - assert.equal(labels.get('LÖVE iOS Xcode project')?.severity, 'pass'); - assert.equal(labels.get('iOS bundle id')?.severity, 'pass'); - assert.equal(labels.get('iOS signing team')?.severity, 'warn'); - if (process.platform === 'darwin') { - assert.equal(labels.get('macOS host')?.severity, 'pass'); - } else { - assert.equal(labels.get('macOS host')?.severity, 'fail'); - assert.equal(configured.exitCode, 1, outputOf(configured)); - } -}); - -test('doctor: ios release reports export options and release signing hints', () => { - const dir = makeTmp(); - writeGame(dir); - writeFakeLoveIos(dir); - writeBuildConfig(dir, { - name: 'Doctor iOS Release', - version: '1.0.0', - targets: { - ios: { - loveIosDir: 'love-ios', - bundleIdentifier: 'com.example.doctoriosrelease', - release: { - exportMethod: 'app-store-connect', - signingStyle: 'manual', - teamId: 'TEAM12345', - }, - }, - }, - }); - const { binDir } = writeFakeCommand(dir, 'xcodebuild', `console.log('Xcode 99.0'); process.exit(0);`); - const result = run(['doctor', dir, '--json', '--build-target', 'ios', '--release'], { env: envWithPath(binDir) }); - assert.equal(result.stdout.trim().startsWith('{'), true, outputOf(result)); - const parsed = JSON.parse(result.stdout); - const labels = new Map(parsed.checks.map((check) => [check.label, check])); - assert.equal(labels.get('iOS config')?.severity, 'pass'); - assert.equal(labels.get('iOS signing team')?.severity, 'pass'); - assert.equal(labels.get('iOS export options')?.severity, 'pass'); -}); - -test('command runtime redacts API keys from compact and debug errors', async () => { - const { runCliAction } = await import('../dist/lib/command.js'); - const originalError = console.error; - const previousExitCode = process.exitCode; - const previousDebug = process.env.FEATHER_DEBUG; - const secret = 'StrongSecretValue1234567890!'; - const lines = []; - process.env.FEATHER_DEBUG = '1'; - process.exitCode = undefined; - console.error = (line = '') => lines.push(String(line)); - try { - await runCliAction(async () => { - throw new Error(`Failed to parse config: apiKey = "${secret}"`); - }); - const output = lines.join('\n'); - assert.equal(process.exitCode, 1); - assert.equal(output.includes(secret), false); - assert.ok(output.includes('apiKey = "[redacted]"')); - } finally { - console.error = originalError; - process.exitCode = previousExitCode; - if (previousDebug === undefined) delete process.env.FEATHER_DEBUG; - else process.env.FEATHER_DEBUG = previousDebug; - } -}); - -test('command runtime: unexpected errors render compact stderr and exit 1', async () => { - const { runCliAction } = await import('../dist/lib/command.js'); - const originalError = console.error; - const lines = []; - const previousExitCode = process.exitCode; - const previousDebug = process.env.FEATHER_DEBUG; - delete process.env.FEATHER_DEBUG; - process.exitCode = undefined; - console.error = (line = '') => lines.push(String(line)); - try { - await runCliAction(async () => { - throw new Error('surprise failure'); - }); - assert.equal(process.exitCode, 1); - assert.ok(lines.join('\n').includes('surprise failure')); - assert.equal(lines.join('\n').includes('Error: surprise failure'), false); - } finally { - console.error = originalError; - process.exitCode = previousExitCode; - if (previousDebug === undefined) delete process.env.FEATHER_DEBUG; - else process.env.FEATHER_DEBUG = previousDebug; - } -}); - -test('command runtime: FEATHER_DEBUG includes stack for unexpected errors', async () => { - const { runCliAction } = await import('../dist/lib/command.js'); - const originalError = console.error; - const lines = []; - const previousExitCode = process.exitCode; - const previousDebug = process.env.FEATHER_DEBUG; - process.env.FEATHER_DEBUG = '1'; - process.exitCode = undefined; - console.error = (line = '') => lines.push(String(line)); - try { - await runCliAction(async () => { - throw new Error('debuggable failure'); - }); - assert.equal(process.exitCode, 1); - assert.ok(lines.join('\n').includes('debuggable failure')); - assert.ok(lines.join('\n').includes('Error: debuggable failure')); - } finally { - console.error = originalError; - process.exitCode = previousExitCode; - if (previousDebug === undefined) delete process.env.FEATHER_DEBUG; - else process.env.FEATHER_DEBUG = previousDebug; - } -}); - -test('json commands used by scripts stay parseable and decoration-free', () => { - const dir = makeTmp(); - writeGame(dir); - const content = 'return {}'; - mkdirSync(join(dir, 'lib'), { recursive: true }); - writeFileSync(join(dir, 'lib', 'helper.lua'), content); - writeLock(dir, { - helper: { - version: 'url', - trust: 'experimental', - source: { url: 'https://example.com/helper.lua' }, - files: [{ name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: sha256(content) }], - }, - }); - - const audit = run(['package', 'audit', '--json', '--dir', dir]); - assert.equal(audit.exitCode, 0); - assert.equal(ANSI_RE.test(audit.stdout), false); - const auditParsed = JSON.parse(audit.stdout); - assert.equal(auditParsed[0].status, 'verified'); - - const doctor = run(['doctor', dir, '--json']); - assert.equal(doctor.exitCode, 0); - assert.equal(ANSI_RE.test(doctor.stdout), false); - assert.equal(doctor.stdout.trim().startsWith('{'), true); - JSON.parse(doctor.stdout); -}); - -test('package remove: refuses lockfile target through symlink escaping project', () => { - const dir = makeTmp(); - const outside = join(makeTmp(), 'outside-lib'); - mkdirSync(outside, { recursive: true }); - writeFileSync(join(outside, 'helper.lua'), 'return {}\n'); - symlinkSync(outside, join(dir, 'lib'), 'dir'); - writeLock(dir, { - helper: { - version: 'url', - trust: 'experimental', - source: { url: 'https://example.com/helper.lua' }, - files: [{ name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: sha256('return {}\n') }], - }, - }); - - const result = run(['package', 'remove', 'helper', '--dir', dir, '--yes']); - assert.equal(result.exitCode, 1); - assert.ok(outputOf(result).includes('Refusing to remove unsafe package target: lib/helper.lua')); - assert.equal(existsSync(join(outside, 'helper.lua')), true); -}); diff --git a/cli/test/commands/build-android.test.mjs b/cli/test/commands/build-android.test.mjs new file mode 100644 index 00000000..b2cc161d --- /dev/null +++ b/cli/test/commands/build-android.test.mjs @@ -0,0 +1,399 @@ +/* eslint-disable no-undef */ +import { + ANSI_RE, + LOCAL_SRC, + assert, + chmodSync, + delimiter, + dirname, + envWithPath, + existsSync, + join, + makeTmp, + mkdirSync, + outputOf, + parseDoctorJson, + parseDoctorJsonResult, + readFileSync, + resolve, + rmSync, + run, + sha256, + spawnCli, + stopChild, + symlinkSync, + test, + waitForOutput, + writeBuildConfig, + writeFakeAdb, + writeFakeAppleLibrariesZip, + writeFakeCommand, + writeFakeLove, + writeFakeLoveAndroid, + writeFakeLoveIos, + writeFakeLoveJs, + writeFakeVendorGit, + writeFakeXcrun, + writeFileSync, + writeGame, + writeLocalPluginSource, + writeLock, + writeMinimalRuntime, + readStoredZipEntries, +} from './helpers.mjs'; + +test('build android: invalid config fails before staging or writing artifacts', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Bad Mobile Game', + version: '1.0.0', + productId: 'bad product id', + targets: { android: { loveAndroidDir: 'love-android', versionCode: 0 } }, + }); + + const result = run(['build', 'android', '--dir', dir, '--allow-unsafe', '--json']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Invalid android build config')); + assert.ok(outputOf(result).includes('productId')); + assert.equal(existsSync(join(dir, 'builds', 'bad-mobile-game-1.0.0.love')), false); +}); + +test('build android: injects game.love, runs Gradle, copies APK, and writes manifest', () => { + const dir = makeTmp(); + writeGame(dir); + const { recordPath } = writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Mobile Game', + version: '1.2.3', + productId: 'com.example.mobilegame', + targets: { + android: { + loveAndroidDir: 'love-android', + displayName: 'Mobile Game Dev', + orientation: 'landscape', + recordAudio: true, + versionCode: 7, + versionName: '1.2.3-dev', + }, + }, + }); + + const result = run(['build', 'android', '--dir', dir, '--allow-unsafe', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, true); + assert.equal(parsed.target, 'android'); + assert.equal(existsSync(join(dir, 'builds', 'mobile-game-1.2.3.love')), true); + assert.equal(existsSync(join(dir, 'builds', 'mobile-game-1.2.3-android.apk')), true); + assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'apk'), true); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.deepEqual(record.argv, ['assembleEmbedRecordDebug']); + assert.equal(record.embeddedLoveExists, true); + assert.ok(record.gradle.includes('applicationId "com.example.mobilegame"')); + assert.ok(record.gradle.includes('versionCode 7')); + assert.ok(record.gradle.includes('versionName "1.2.3-dev"')); + assert.ok(record.gradleProperties.includes('app.application_id=com.example.mobilegame')); + assert.ok(record.gradleProperties.includes('app.orientation=landscape')); + assert.ok(record.gradleProperties.includes('app.version_code=7')); + assert.ok(record.gradleProperties.includes('app.version_name=1.2.3-dev')); + assert.equal(record.gradleProperties.includes('app.name='), false); + assert.ok(record.gradleProperties.includes('app.name_byte_array=')); + assert.ok(record.manifest.includes('android:label="Mobile Game Dev"')); + assert.ok(record.manifest.includes('android:screenOrientation="landscape"')); + assert.ok(record.manifest.includes('android.permission.RECORD_AUDIO')); + assert.ok(record.gameActivity.includes('Feather: forcing embedded game.love from assets')); + const manifest = JSON.parse(readFileSync(join(dir, 'builds', 'feather-build-manifest.json'), 'utf8')); + assert.equal(manifest.target, 'android'); + assert.equal(manifest.artifacts.some((artifact) => artifact.type === 'apk'), true); +}); + +test('build android: embeds Feather debugger runtime and raw config in dev love archive', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + `return { + sessionName = "Nested Config", + __DANGEROUS_INSECURE_CONNECTION__ = true, + debugOverlay = { + enabled = true, + visible = false, + }, +} +`, + ); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Embedded Android', + version: '1.0.0', + productId: 'com.example.embeddedandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const result = run(['build', 'android', '--dir', dir, '--allow-unsafe', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + const entries = readStoredZipEntries(join(dir, 'builds', 'embedded-android-1.0.0.love')); + assert.equal(entries.has('main.lua'), true); + assert.equal(entries.has('.feather-main.lua'), true); + assert.equal(entries.has('feather/auto.lua'), true); + assert.equal(entries.has('feather/core/debug_overlay.lua'), true); + assert.equal(entries.has('plugins/profiler/manifest.lua'), true); + assert.match(entries.get('main.lua').toString('utf8'), /require\("feather\.auto"\)/); + assert.match(entries.get('feather.config.lua').toString('utf8'), /debugOverlay\s*=\s*\{/); + assert.match(entries.get('feather.config.lua').toString('utf8'), /visible\s*=\s*false/); +}); + +test('build android --no-debugger: builds raw source without Feather embed', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Raw Android', + version: '1.0.0', + productId: 'com.example.rawandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const result = run(['build', 'android', '--dir', dir, '--no-debugger', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + const entries = readStoredZipEntries(join(dir, 'builds', 'raw-android-1.0.0.love')); + assert.equal(entries.has('main.lua'), true); + assert.equal(entries.has('.feather-main.lua'), false); + assert.equal(entries.has('feather/auto.lua'), false); + assert.equal(entries.has('feather.config.lua'), false); +}); + +test('build android --release: does not auto-embed Feather debugger runtime', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Release Raw Android', + version: '1.0.0', + productId: 'com.example.releaserawandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const result = run(['build', 'android', '--dir', dir, '--release', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + const entries = readStoredZipEntries(join(dir, 'builds', 'release-raw-android-1.0.0.love')); + assert.equal(entries.has('.feather-main.lua'), false); + assert.equal(entries.has('feather/auto.lua'), false); +}); + +test('build android: reuses dev native cache between builds', () => { + const dir = makeTmp(); + writeGame(dir); + const { recordPath } = writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Cached Android', + version: '1.0.0', + productId: 'com.example.cachedandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const first = run(['build', 'android', '--dir', dir, '--json']); + assert.equal(first.exitCode, 0, outputOf(first)); + const firstParsed = JSON.parse(first.stdout); + assert.equal(firstParsed.cache.enabled, true); + assert.equal(firstParsed.cache.hit, false); + assert.ok(firstParsed.cache.path.includes(join('builds', '.feather-cache', 'android'))); + assert.equal(existsSync(firstParsed.cache.path), true); + + const second = run(['build', 'android', '--dir', dir, '--json']); + assert.equal(second.exitCode, 0, outputOf(second)); + const secondParsed = JSON.parse(second.stdout); + assert.equal(secondParsed.cache.enabled, true); + assert.equal(secondParsed.cache.hit, true); + assert.equal(secondParsed.cache.key, firstParsed.cache.key); + assert.equal(secondParsed.cache.path, firstParsed.cache.path); + + const records = JSON.parse(readFileSync(recordPath, 'utf8')).records; + assert.equal(records.length, 2); + assert.equal(records[0].cwd, records[1].cwd); + assert.ok(records[0].cwd.includes(join('builds', '.feather-cache', 'android'))); +}); + +test('build android --no-cache uses fresh native workspaces', () => { + const dir = makeTmp(); + writeGame(dir); + const { recordPath } = writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Uncached Android', + version: '1.0.0', + productId: 'com.example.uncachedandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const first = run(['build', 'android', '--dir', dir, '--no-cache', '--json']); + const second = run(['build', 'android', '--dir', dir, '--no-cache', '--json']); + assert.equal(first.exitCode, 0, outputOf(first)); + assert.equal(second.exitCode, 0, outputOf(second)); + assert.equal(JSON.parse(first.stdout).cache.enabled, false); + assert.equal(JSON.parse(second.stdout).cache.enabled, false); + assert.equal(existsSync(join(dir, 'builds', '.feather-cache')), false); + const records = JSON.parse(readFileSync(recordPath, 'utf8')).records; + assert.equal(records.length, 2); + assert.notEqual(records[0].cwd, records[1].cwd); + assert.equal(records[0].cwd.includes('.feather-cache'), false); + assert.equal(records[1].cwd.includes('.feather-cache'), false); +}); + +test('build android: stale native cache missing Gradle wrapper is recopied', () => { + const dir = makeTmp(); + writeGame(dir); + const { recordPath } = writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Stale Cached Android', + version: '1.0.0', + productId: 'com.example.stalecachedandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const first = run(['build', 'android', '--dir', dir, '--json']); + assert.equal(first.exitCode, 0, outputOf(first)); + const firstParsed = JSON.parse(first.stdout); + rmSync(join(firstParsed.cache.path, 'love-android', process.platform === 'win32' ? 'gradlew.bat' : 'gradlew'), { force: true }); + + const second = run(['build', 'android', '--dir', dir, '--json']); + assert.equal(second.exitCode, 0, outputOf(second)); + const secondParsed = JSON.parse(second.stdout); + assert.equal(secondParsed.cache.enabled, true); + assert.equal(secondParsed.cache.hit, false); + assert.equal(secondParsed.cache.path, firstParsed.cache.path); + assert.equal(existsSync(join(secondParsed.cache.path, 'love-android', process.platform === 'win32' ? 'gradlew.bat' : 'gradlew')), true); + const records = JSON.parse(readFileSync(recordPath, 'utf8')).records; + assert.equal(records.length, 2); +}); + +test('build android --clean resets dev native cache', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Clean Cached Android', + version: '1.0.0', + productId: 'com.example.cleancachedandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const first = run(['build', 'android', '--dir', dir, '--json']); + const second = run(['build', 'android', '--dir', dir, '--json']); + const cleaned = run(['build', 'android', '--dir', dir, '--clean', '--json']); + assert.equal(first.exitCode, 0, outputOf(first)); + assert.equal(second.exitCode, 0, outputOf(second)); + assert.equal(cleaned.exitCode, 0, outputOf(cleaned)); + assert.equal(JSON.parse(first.stdout).cache.hit, false); + assert.equal(JSON.parse(second.stdout).cache.hit, true); + assert.equal(JSON.parse(cleaned.stdout).cache.hit, false); +}); + +test('build android --verbose: shows native build steps and Gradle output', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Verbose Android', + version: '1.0.0', + productId: 'com.example.verboseandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const result = run(['build', 'android', '--dir', dir, '--verbose']); + + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(result.stdout.includes('Building android in verbose mode')); + assert.ok(result.stdout.includes('Staged')); + assert.ok(result.stdout.includes('Android workspace')); + assert.ok(result.stdout.includes('assembleEmbedNoRecordDebug')); + assert.ok(result.stdout.includes('fake gradle assembleEmbedNoRecordDebug')); +}); + +test('build android: honors gradleTask, custom artifactPath, and removes microphone permission', () => { + const dir = makeTmp(); + writeGame(dir); + const { recordPath } = writeFakeLoveAndroid(dir, { + recordAudioPermission: true, + apkRel: 'app/build/outputs/custom/custom-debug.apk', + }); + writeBuildConfig(dir, { + name: 'Custom Android', + version: '2.0.0', + productId: 'com.example.customandroid', + targets: { + android: { + loveAndroidDir: 'love-android', + gradleTask: ':app:assembleCustomDebug', + artifactPath: 'app/build/outputs/custom/custom-debug.apk', + recordAudio: false, + }, + }, + }); + + const result = run(['build', 'android', '--dir', dir, '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'builds', 'custom-android-2.0.0-android.apk')), true); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.deepEqual(record.argv, [':app:assembleCustomDebug']); + assert.equal(record.manifest.includes('android.permission.RECORD_AUDIO'), false); + assert.equal(record.manifest.includes('android.permission.INTERNET'), true); +}); + +test('build android --release: runs bundle/apk tasks, copies artifacts, and keeps signing secrets out of output', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'release.keystore'), 'fake keystore'); + const { recordPath } = writeFakeLoveAndroid(dir, { + recordAudioPermission: true, + aabRel: 'app/build/outputs/custom/store-release.aab', + apkRel: 'app/build/outputs/custom/store-release.apk', + }); + writeBuildConfig(dir, { + name: 'Store Android', + version: '3.0.0', + productId: 'com.example.storeandroid', + targets: { + android: { + loveAndroidDir: 'love-android', + recordAudio: true, + release: { + bundleTask: ':app:bundleStoreRelease', + apkTask: ':app:assembleStoreRelease', + bundleArtifactPath: 'app/build/outputs/custom/store-release.aab', + apkArtifactPath: 'app/build/outputs/custom/store-release.apk', + keystorePath: 'release.keystore', + keyAlias: 'release-key', + storePasswordEnv: 'FEATHER_TEST_STORE_PASSWORD', + keyPasswordEnv: 'FEATHER_TEST_KEY_PASSWORD', + }, + }, + }, + }); + + const result = run(['build', 'android', '--dir', dir, '--release', '--json'], { + env: { + ...process.env, + NO_COLOR: '1', + FORCE_COLOR: '0', + FEATHER_TEST_STORE_PASSWORD: 'store-secret', + FEATHER_TEST_KEY_PASSWORD: 'key-secret', + }, + }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + assert.equal(outputOf(result).includes('store-secret'), false); + assert.equal(outputOf(result).includes('key-secret'), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.cache.enabled, false); + assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'aab'), true); + assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'apk'), true); + assert.equal(existsSync(join(dir, 'builds', 'store-android-3.0.0-android.aab')), true); + assert.equal(existsSync(join(dir, 'builds', 'store-android-3.0.0-android.apk')), true); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.deepEqual(record.records.map((entry) => entry.argv[0]), [':app:bundleStoreRelease', ':app:assembleStoreRelease']); + assert.ok(record.signingProperties.includes('keyAlias=release-key')); + assert.ok(record.signingProperties.includes('storePassword=store-secret')); +}); diff --git a/cli/test/commands/build-ios.test.mjs b/cli/test/commands/build-ios.test.mjs new file mode 100644 index 00000000..9acab067 --- /dev/null +++ b/cli/test/commands/build-ios.test.mjs @@ -0,0 +1,396 @@ +/* eslint-disable no-undef */ +import { + ANSI_RE, + LOCAL_SRC, + assert, + chmodSync, + delimiter, + dirname, + envWithPath, + existsSync, + join, + makeTmp, + mkdirSync, + outputOf, + parseDoctorJson, + parseDoctorJsonResult, + readFileSync, + resolve, + rmSync, + run, + sha256, + spawnCli, + stopChild, + symlinkSync, + test, + waitForOutput, + writeBuildConfig, + writeFakeAdb, + writeFakeAppleLibrariesZip, + writeFakeCommand, + writeFakeLove, + writeFakeLoveAndroid, + writeFakeLoveIos, + writeFakeLoveJs, + writeFakeVendorGit, + writeFakeXcrun, + writeFileSync, + writeGame, + writeLocalPluginSource, + writeLock, + writeMinimalRuntime, + readStoredZipEntries, +} from './helpers.mjs'; + +test('build ios: invalid bundle id fails before xcodebuild', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Bad iOS Game', + version: '1.0.0', + targets: { ios: { loveIosDir: 'love-ios', bundleIdentifier: 'bad id' } }, + }); + const recordPath = join(dir, 'xcodebuild-record.json'); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` +const fs = require('node:fs'); +fs.writeFileSync(${JSON.stringify(recordPath)}, 'ran'); +process.exit(0); +`); + + const result = run(['build', 'ios', '--dir', dir, '--json'], { env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }) }); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Invalid ios build config')); + assert.equal(existsSync(recordPath), false); +}); + +test('build ios: injects game.love, runs xcodebuild, copies app, and writes manifest', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'iOS Game', + version: '5.0.0', + targets: { + ios: { + loveIosDir: 'love-ios', + bundleIdentifier: 'com.example.iosgame', + displayName: 'iOS Game Dev', + scheme: 'FeatherGame', + configuration: 'Release', + sdk: 'iphonesimulator', + derivedDataPath: 'builds/ios-derived-data', + teamId: 'ABC123XYZ', + }, + }, + }); + const recordPath = join(dir, 'xcodebuild-record.json'); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` +if (process.argv.includes('-version')) { + console.log('Xcode 99.0'); + process.exit(0); +} +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +const derivedData = args[args.indexOf('-derivedDataPath') + 1]; +const configuration = args[args.indexOf('-configuration') + 1] || 'Release'; +const sdk = args[args.indexOf('-sdk') + 1] || 'iphonesimulator'; +const sdkFolder = sdk.startsWith('iphoneos') ? 'iphoneos' : 'iphonesimulator'; +const app = path.join(derivedData, 'Build', 'Products', configuration + '-' + sdkFolder, 'love-ios.app'); +fs.mkdirSync(app, { recursive: true }); +fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); +const project = fs.readFileSync(path.join(process.cwd(), 'platform', 'xcode', 'love.xcodeproj', 'project.pbxproj'), 'utf8'); +const plist = fs.readFileSync(path.join(process.cwd(), 'platform', 'xcode', 'ios', 'love-ios.plist'), 'utf8'); +const iosResources = project.match(/666666666666666666666666 \\/\\* Resources \\*\\/ = \\{[\\s\\S]*?\\n \\};/)?.[0] || ''; +const macosResources = project.match(/222222222222222222222222 \\/\\* Resources \\*\\/ = \\{[\\s\\S]*?\\n \\};/)?.[0] || ''; +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ + argv: args, + gameLoveExists: fs.existsSync(path.join(process.cwd(), 'platform', 'xcode', 'game.love')), + projectContainsGameLove: project.includes('game.love'), + iosResourcesContainGameLove: iosResources.includes('FEATHERGAMELOVE000000000001 /* game.love in Resources */'), + macosResourcesContainGameLove: macosResources.includes('FEATHERGAMELOVE000000000001 /* game.love in Resources */'), + plistSupportsIndirectInput: plist.includes('UIApplicationSupportsIndirectInputEvents') && plist.includes(''), +}, null, 2)); +console.log('fake xcodebuild ' + args.join(' ')); +process.exit(0); +`); + + const result = run(['build', 'ios', '--dir', dir, '--no-cache', '--json'], { env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }) }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, true); + assert.equal(parsed.target, 'ios'); + assert.equal(existsSync(join(dir, 'builds', 'ios-game-5.0.0.love')), true); + assert.equal(existsSync(join(dir, 'builds', 'ios-game-5.0.0-ios.app')), true); + assert.equal(existsSync(join(dir, 'builds', 'ios-game-5.0.0-ios.app', 'game.love')), true); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.ok(record.argv.includes('-scheme')); + assert.ok(record.argv.includes('FeatherGame')); + assert.ok(record.argv.includes('-configuration')); + assert.ok(record.argv.includes('Release')); + assert.ok(record.argv.includes('-sdk')); + assert.ok(record.argv.includes('iphonesimulator')); + assert.ok(record.argv.includes(join(dir, 'builds', 'ios-derived-data'))); + assert.ok(record.argv.includes('PRODUCT_BUNDLE_IDENTIFIER=com.example.iosgame')); + assert.ok(record.argv.includes('INFOPLIST_KEY_CFBundleDisplayName=iOS Game Dev')); + assert.ok(record.argv.includes('DEVELOPMENT_TEAM=ABC123XYZ')); + assert.equal(record.gameLoveExists, true); + assert.equal(record.projectContainsGameLove, true); + assert.equal(record.iosResourcesContainGameLove, true); + assert.equal(record.macosResourcesContainGameLove, false); + assert.equal(record.plistSupportsIndirectInput, true); + const entries = readStoredZipEntries(join(dir, 'builds', 'ios-game-5.0.0.love')); + assert.equal(entries.has('main.lua'), true); + assert.equal(entries.has('.feather-main.lua'), true); + assert.equal(entries.has('feather/auto.lua'), true); + assert.equal(entries.has('feather/core/debug_overlay.lua'), true); + const manifest = JSON.parse(readFileSync(join(dir, 'builds', 'feather-build-manifest.json'), 'utf8')); + assert.equal(manifest.target, 'ios'); + assert.equal(manifest.artifacts.some((artifact) => artifact.type === 'app'), true); +}); + +test('build ios: reuses dev native cache and cached DerivedData between builds', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Cached iOS', + version: '1.0.0', + targets: { + ios: { + loveIosDir: 'love-ios', + bundleIdentifier: 'com.example.cachedios', + }, + }, + }); + const recordPath = join(dir, 'xcodebuild-cache-record.json'); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +const derivedData = args[args.indexOf('-derivedDataPath') + 1]; +const configuration = args[args.indexOf('-configuration') + 1] || 'Release'; +const sdk = args[args.indexOf('-sdk') + 1] || 'iphonesimulator'; +const sdkFolder = sdk.startsWith('iphoneos') ? 'iphoneos' : 'iphonesimulator'; +const app = path.join(derivedData, 'Build', 'Products', configuration + '-' + sdkFolder, 'love-ios.app'); +fs.mkdirSync(app, { recursive: true }); +fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); +const previous = fs.existsSync(${JSON.stringify(recordPath)}) + ? JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)}, 'utf8')) + : { records: [] }; +previous.records.push({ + argv: args, + cwd: process.cwd(), + derivedData, + gameLoveExists: fs.existsSync(path.join(process.cwd(), 'platform', 'xcode', 'game.love')), +}); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify(previous, null, 2)); +process.exit(0); +`); + const env = envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }); + + const first = run(['build', 'ios', '--dir', dir, '--json'], { env }); + assert.equal(first.exitCode, 0, outputOf(first)); + const firstParsed = JSON.parse(first.stdout); + assert.equal(firstParsed.cache.enabled, true); + assert.equal(firstParsed.cache.hit, false); + + const second = run(['build', 'ios', '--dir', dir, '--json'], { env }); + assert.equal(second.exitCode, 0, outputOf(second)); + const secondParsed = JSON.parse(second.stdout); + assert.equal(secondParsed.cache.enabled, true); + assert.equal(secondParsed.cache.hit, true); + assert.equal(secondParsed.cache.path, firstParsed.cache.path); + + const records = JSON.parse(readFileSync(recordPath, 'utf8')).records; + assert.equal(records.length, 2); + assert.equal(records[0].cwd, records[1].cwd); + assert.equal(records[0].derivedData, records[1].derivedData); + assert.ok(records[0].cwd.includes(join('builds', '.feather-cache', 'ios'))); + assert.ok(records[0].derivedData.includes(join('builds', '.feather-cache', 'ios'))); + assert.equal(records[0].gameLoveExists, true); + assert.equal(records[1].gameLoveExists, true); +}); + +test('build ios --verbose: shows native build steps and xcodebuild output', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Verbose iOS', + version: '1.0.0', + targets: { ios: { loveIosDir: 'love-ios', bundleIdentifier: 'com.example.verboseios' } }, + }); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +const derivedData = args[args.indexOf('-derivedDataPath') + 1]; +const configuration = args[args.indexOf('-configuration') + 1] || 'Release'; +const sdk = args[args.indexOf('-sdk') + 1] || 'iphonesimulator'; +const sdkFolder = sdk.startsWith('iphoneos') ? 'iphoneos' : 'iphonesimulator'; +const app = path.join(derivedData, 'Build', 'Products', configuration + '-' + sdkFolder, 'love-ios.app'); +fs.mkdirSync(app, { recursive: true }); +fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); +console.log('fake xcodebuild ' + args.join(' ')); +process.exit(0); +`); + + const result = run(['build', 'ios', '--dir', dir, '--verbose'], { + env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }), + }); + + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(result.stdout.includes('Building ios in verbose mode')); + assert.ok(result.stdout.includes('iOS workspace')); + assert.ok(result.stdout.includes('xcodebuild')); + assert.ok(result.stdout.includes('fake xcodebuild')); +}); + +test('build ios --release: archives, exports IPA, and writes release manifest artifacts', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Release iOS', + version: '6.0.0', + targets: { + ios: { + loveIosDir: 'love-ios', + bundleIdentifier: 'com.example.releaseios', + displayName: 'Release iOS', + scheme: 'ReleaseGame', + release: { + archivePath: 'builds/native-release.xcarchive', + exportPath: 'builds/native-export', + exportMethod: 'app-store-connect', + signingStyle: 'manual', + provisioningProfileSpecifier: 'Release Profile', + teamId: 'TEAM12345', + configuration: 'Release', + sdk: 'iphoneos', + }, + }, + }, + }); + const recordPath = join(dir, 'xcodebuild-release-record.json'); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` +if (process.argv.includes('-version')) { + console.log('Xcode 99.0'); + process.exit(0); +} +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +const previous = fs.existsSync(${JSON.stringify(recordPath)}) + ? JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)}, 'utf8')) + : { records: [] }; +if (args.includes('archive')) { + const archivePath = args[args.indexOf('-archivePath') + 1]; + fs.mkdirSync(archivePath, { recursive: true }); + fs.writeFileSync(path.join(archivePath, 'Info.plist'), 'fake archive'); +} +if (args.includes('-exportArchive')) { + const exportPath = args[args.indexOf('-exportPath') + 1]; + const exportOptions = args[args.indexOf('-exportOptionsPlist') + 1]; + fs.mkdirSync(exportPath, { recursive: true }); + fs.writeFileSync(path.join(exportPath, 'Release iOS.ipa'), 'fake ipa'); + previous.exportOptions = fs.readFileSync(exportOptions, 'utf8'); +} +previous.records.push({ argv: args }); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify(previous, null, 2)); +process.exit(0); +`); + + const result = run(['build', 'ios', '--dir', dir, '--release', '--json'], { + env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }), + }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.cache.enabled, false); + assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'xcarchive'), true); + assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'ipa'), true); + assert.equal(existsSync(join(dir, 'builds', 'release-ios-6.0.0-ios.xcarchive')), true); + assert.equal(existsSync(join(dir, 'builds', 'release-ios-6.0.0-ios.ipa')), true); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.ok(record.records[0].argv.includes('archive')); + assert.ok(record.records[0].argv.includes('DEVELOPMENT_TEAM=TEAM12345')); + assert.ok(record.records[1].argv.includes('-exportArchive')); + assert.ok(record.exportOptions.includes('app-store-connect')); + assert.ok(record.exportOptions.includes('manual')); + assert.ok(record.exportOptions.includes('Release Profile')); +}); + +test('build ios --release: packages unsigned IPA from archive when no signing team is configured', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Unsigned iOS', + version: '1.2.3', + targets: { + ios: { + loveIosDir: 'love-ios', + bundleIdentifier: 'com.example.unsignedios', + release: { + archivePath: 'builds/unsigned.xcarchive', + exportPath: 'builds/unsigned-export', + }, + }, + }, + }); + const recordPath = join(dir, 'xcodebuild-unsigned-record.json'); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +const previous = fs.existsSync(${JSON.stringify(recordPath)}) + ? JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)}, 'utf8')) + : { records: [] }; +if (args.includes('archive')) { + const archivePath = args[args.indexOf('-archivePath') + 1]; + const app = path.join(archivePath, 'Products', 'Applications', 'love-ios.app'); + fs.mkdirSync(app, { recursive: true }); + fs.writeFileSync(path.join(archivePath, 'Info.plist'), 'fake archive'); + fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); + fs.writeFileSync(path.join(app, 'love-ios'), 'fake executable'); +} +if (args.includes('-exportArchive')) { + throw new Error('unsigned release should not call -exportArchive'); +} +previous.records.push({ argv: args }); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify(previous, null, 2)); +process.exit(0); +`); + + const result = run(['build', 'ios', '--dir', dir, '--release', '--json'], { + env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }), + }); + + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'ipa'), true); + assert.equal(existsSync(join(dir, 'builds', 'unsigned-ios-1.2.3-ios.ipa')), true); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.equal(record.records.length, 1); + assert.ok(record.records[0].argv.includes('archive')); + assert.ok(record.records[0].argv.includes('CODE_SIGNING_ALLOWED=NO')); +}); + +test('build ios: non-macOS hosts fail with setup guidance', { skip: process.platform === 'darwin' }, () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Non Mac iOS', + version: '1.0.0', + targets: { ios: { loveIosDir: 'love-ios', bundleIdentifier: 'com.example.nonmacios' } }, + }); + + const result = run(['build', 'ios', '--dir', dir, '--json']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('iOS builds require macOS with Xcode')); +}); diff --git a/cli/test/commands/build-vendor.test.mjs b/cli/test/commands/build-vendor.test.mjs new file mode 100644 index 00000000..2e8a3dfe --- /dev/null +++ b/cli/test/commands/build-vendor.test.mjs @@ -0,0 +1,204 @@ +/* eslint-disable no-undef */ +import { + ANSI_RE, + LOCAL_SRC, + assert, + chmodSync, + delimiter, + dirname, + envWithPath, + existsSync, + join, + makeTmp, + mkdirSync, + outputOf, + parseDoctorJson, + parseDoctorJsonResult, + readFileSync, + resolve, + rmSync, + run, + sha256, + spawnCli, + stopChild, + symlinkSync, + test, + waitForOutput, + writeBuildConfig, + writeFakeAdb, + writeFakeAppleLibrariesZip, + writeFakeCommand, + writeFakeLove, + writeFakeLoveAndroid, + writeFakeLoveIos, + writeFakeLoveJs, + writeFakeVendorGit, + writeFakeXcrun, + writeFileSync, + writeGame, + writeLocalPluginSource, + writeLock, + writeMinimalRuntime, + readStoredZipEntries, +} from './helpers.mjs'; + +test('build vendor add android --json: clones vendor and updates config', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Vendor Android', version: '1.0.0', loveVersion: '11.5' }); + const { binDir, recordPath } = writeFakeVendorGit(dir); + + const result = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--json'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.vendors[0].target, 'android'); + assert.equal(parsed.vendors[0].relativePath, 'vendor/love-android'); + assert.equal(parsed.vendors[0].configUpdated, true); + assert.equal(existsSync(join(dir, 'vendor', 'love-android', 'gradlew')), true); + const config = JSON.parse(readFileSync(join(dir, 'feather.build.json'), 'utf8')); + assert.equal(config.targets.android.loveAndroidDir, 'vendor/love-android'); + const records = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.ok(records.some((record) => record.args.includes('--recurse-submodules'))); + assert.ok(records.some((record) => record.args.includes('https://github.com/love2d/love-android'))); +}); + +test('build vendor add web --json: clones love.js vendor and updates config', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Vendor Web', version: '1.0.0', loveVersion: '11.5' }); + const { binDir, recordPath } = writeFakeVendorGit(dir); + + const result = run(['build', 'vendor', 'add', 'web', '--dir', dir, '--json'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.vendors[0].target, 'web'); + assert.equal(parsed.vendors[0].relativePath, 'vendor/love.js'); + assert.equal(parsed.vendors[0].ref, 'main'); + assert.equal(parsed.vendors[0].configUpdated, true); + assert.equal(existsSync(join(dir, 'vendor', 'love.js', 'index.html')), true); + assert.equal(existsSync(join(dir, 'vendor', 'love.js', 'player.js')), true); + const config = JSON.parse(readFileSync(join(dir, 'feather.build.json'), 'utf8')); + assert.equal(config.targets.web.loveJsDir, 'vendor/love.js'); + const records = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.ok(records.some((record) => record.args.includes('https://github.com/2dengine/love.js'))); +}); + +test('build vendor add ios --json: clones vendor, installs Apple libraries, and updates config', async () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Vendor iOS', version: '1.0.0', loveVersion: '11.5' }); + const { binDir, recordPath } = writeFakeVendorGit(dir); + const zipPath = await writeFakeAppleLibrariesZip(dir); + + const result = run(['build', 'vendor', 'add', 'ios', '--dir', dir, '--json'], { + env: envWithPath(binDir, { FEATHER_TEST_LOVE_APPLE_LIBRARIES_ZIP: zipPath }), + }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.vendors[0].target, 'ios'); + assert.equal(parsed.vendors[0].relativePath, 'vendor/love-ios'); + assert.equal(existsSync(join(dir, 'vendor', 'love-ios', 'platform', 'xcode', 'ios', 'libraries', 'liblove-test.a')), true); + assert.equal(existsSync(join(dir, 'vendor', 'love-ios', 'platform', 'xcode', 'macosx', 'Frameworks', 'test.framework', 'test')), true); + assert.equal(existsSync(join(dir, 'vendor', 'love-ios', 'platform', 'xcode', 'ios', 'libraries', '._ignored')), false); + const config = JSON.parse(readFileSync(join(dir, 'feather.build.json'), 'utf8')); + assert.equal(config.targets.ios.loveIosDir, 'vendor/love-ios'); + const records = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.ok(records.some((record) => record.args.includes('https://github.com/love2d/love'))); +}); + +test('build vendor add mobile --dry-run --json: reports planned vendors without writing', () => { + const dir = makeTmp(); + writeGame(dir); + + const result = run(['build', 'vendor', 'add', 'mobile', '--dir', dir, '--dry-run', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.deepEqual(parsed.vendors.map((vendor) => vendor.target), ['android', 'ios']); + assert.equal(existsSync(join(dir, 'vendor')), false); + assert.equal(existsSync(join(dir, 'feather.build.json')), false); +}); + +test('build vendor add all --dry-run --json: includes web and mobile vendors', () => { + const dir = makeTmp(); + writeGame(dir); + + const result = run(['build', 'vendor', 'add', 'all', '--dir', dir, '--dry-run', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.deepEqual(parsed.vendors.map((vendor) => vendor.target), ['android', 'ios', 'web']); + assert.equal(existsSync(join(dir, 'vendor')), false); +}); + +test('build vendor add --no-config: fetches vendor without writing build config', () => { + const dir = makeTmp(); + writeGame(dir); + const { binDir } = writeFakeVendorGit(dir); + + const result = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--no-config', '--json'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(existsSync(join(dir, 'vendor', 'love-android', 'gradlew')), true); + assert.equal(existsSync(join(dir, 'feather.build.json')), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.vendors[0].configUpdated, false); +}); + +test('build vendor add: existing directories and conflicting config require --force', () => { + const dir = makeTmp(); + writeGame(dir); + mkdirSync(join(dir, 'vendor', 'love-android'), { recursive: true }); + + const existing = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--json']); + assert.equal(existing.exitCode, 1); + assert.ok(outputOf(existing).includes('--force')); + + writeBuildConfig(dir, { targets: { android: { loveAndroidDir: 'native/love-android' } } }); + const conflict = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--dry-run', '--json']); + assert.equal(conflict.exitCode, 1); + assert.ok(outputOf(conflict).includes('already configured')); + + const { binDir } = writeFakeVendorGit(dir); + const forced = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--force', '--json'], { env: envWithPath(binDir) }); + assert.equal(forced.exitCode, 0, outputOf(forced)); + const config = JSON.parse(readFileSync(join(dir, 'feather.build.json'), 'utf8')); + assert.equal(config.targets.android.loveAndroidDir, 'vendor/love-android'); +}); + +test('build vendor list --json: reports configured, missing, and valid vendors', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + targets: { + web: { loveJsDir: 'love.js' }, + android: { loveAndroidDir: 'love-android' }, + ios: { loveIosDir: 'vendor/love-ios' }, + }, + }); + + const result = run(['build', 'vendor', 'list', '--dir', dir, '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + const labels = new Map(parsed.vendors.map((vendor) => [vendor.target, vendor])); + assert.equal(labels.get('web').valid, true); + assert.equal(labels.get('android').valid, true); + assert.equal(labels.get('ios').exists, false); + assert.equal(labels.get('ios').detail, 'missing'); +}); + +test('build vendor add: missing git produces compact actionable error', () => { + const dir = makeTmp(); + writeGame(dir); + + const result = run(['build', 'vendor', 'add', 'android', '--dir', dir, '--json'], { + env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0', PATH: '' }, + }); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('git is required')); +}); diff --git a/cli/test/commands/build.test.mjs b/cli/test/commands/build.test.mjs new file mode 100644 index 00000000..74a0cc4f --- /dev/null +++ b/cli/test/commands/build.test.mjs @@ -0,0 +1,208 @@ +/* eslint-disable no-undef */ +import { + ANSI_RE, + LOCAL_SRC, + assert, + chmodSync, + delimiter, + dirname, + envWithPath, + existsSync, + join, + makeTmp, + mkdirSync, + outputOf, + parseDoctorJson, + parseDoctorJsonResult, + readFileSync, + resolve, + rmSync, + run, + sha256, + spawnCli, + stopChild, + symlinkSync, + test, + waitForOutput, + writeBuildConfig, + writeFakeAdb, + writeFakeAppleLibrariesZip, + writeFakeCommand, + writeFakeLove, + writeFakeLoveAndroid, + writeFakeLoveIos, + writeFakeLoveJs, + writeFakeVendorGit, + writeFakeXcrun, + writeFileSync, + writeGame, + writeLocalPluginSource, + writeLock, + writeMinimalRuntime, + readStoredZipEntries, +} from './helpers.mjs'; + +test('build web: creates love archive, love.js html package, zip, and manifest', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeBuildConfig(dir, { + name: 'Command Game', + version: '1.2.3', + targets: { web: { loveJsDir: 'love.js' } }, + upload: { itch: { project: 'tester/command-game', channels: { web: 'html5' } } }, + }); + + const result = run(['build', 'web', '--dir', dir, '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, true); + assert.equal(parsed.target, 'web'); + assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'love'), true); + assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'zip'), true); + assert.equal(existsSync(join(dir, 'builds', 'command-game-1.2.3.love')), true); + assert.equal(existsSync(join(dir, 'builds', 'command-game-1.2.3-html.zip')), true); + const index = readFileSync(join(dir, 'builds', 'command-game-1.2.3-html', 'index.html'), 'utf8'); + assert.ok(index.includes('Command Game')); + assert.ok(index.includes('player.min.js?g=game.love')); + assert.equal(index.includes('href="/play/"'), false); + const manifest = JSON.parse(readFileSync(join(dir, 'builds', 'feather-build-manifest.json'), 'utf8')); + assert.equal(manifest.target, 'web'); +}); + +test('build linux: delegates desktop packaging to love-release and writes manifest', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Desktop Game', version: '2.0.0' }); +const recordPath = join(dir, 'love-release-record.json'); + const { binDir } = writeFakeCommand(dir, 'love-release', ` +if (process.argv.length === 3 && process.argv[2] === '--version') { + console.log('love-release test'); + process.exit(0); +} +const fs = require('node:fs'); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ argv: process.argv.slice(2) }, null, 2)); +process.exit(0); +`); + + const result = run(['build', 'linux', '--dir', dir, '--json'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, true); + assert.equal(existsSync(join(dir, 'builds', 'desktop-game-2.0.0.love')), true); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.ok(record.argv.includes('--target')); + assert.ok(record.argv.includes('linux')); + assert.ok(record.argv.includes('--name')); + assert.ok(record.argv.includes('Desktop Game')); +}); + +test('build validation: rejects bad mobile config values and unsafe native paths', async () => { + const { validateAndroidBuildConfig, validateIosBuildConfig } = await import('../../dist/lib/build/validation.js'); + const { resolveWorkspacePath } = await import('../../dist/lib/build/native.js'); + const baseConfig = { + configPath: '/tmp/feather.build.json', + projectDir: '/tmp/game', + sourceDir: '/tmp/game', + outDir: '/tmp/game/builds', + name: 'Validation Game', + version: '1.0.0', + include: [], + exclude: [], + includeRuntime: false, + targets: {}, + upload: {}, + }; + + const androidIssues = validateAndroidBuildConfig({ + ...baseConfig, + productId: 'not a product id', + targets: { android: { versionCode: 0, orientation: 'sideways', gradleTask: 'assemble debug' } }, + }); + assert.deepEqual(androidIssues.map((issue) => issue.field), [ + 'productId', + 'targets.android.versionCode', + 'targets.android.orientation', + 'targets.android.gradleTask', + ]); + const androidReleaseIssues = validateAndroidBuildConfig({ + ...baseConfig, + targets: { + android: { + release: { + bundleTask: 'bundle release', + apkArtifactPath: '', + storePasswordEnv: '1BAD_ENV', + keyPasswordEnv: 'GOOD_ENV', + }, + }, + }, + }, true); + assert.ok(androidReleaseIssues.some((issue) => issue.field === 'targets.android.release.bundleTask')); + assert.ok(androidReleaseIssues.some((issue) => issue.field === 'targets.android.release.apkArtifactPath')); + assert.ok(androidReleaseIssues.some((issue) => issue.field === 'targets.android.release.storePasswordEnv')); + + const iosIssues = validateIosBuildConfig({ + ...baseConfig, + targets: { ios: { bundleIdentifier: 'bad id', scheme: 'bad;', derivedDataPath: '../escape' } }, + }); + assert.deepEqual(iosIssues.map((issue) => issue.field), [ + 'bundleIdentifier', + 'targets.ios.scheme', + 'targets.ios.derivedDataPath', + ]); + const iosReleaseIssues = validateIosBuildConfig({ + ...baseConfig, + targets: { ios: { release: { exportMethod: 'side-load', signingStyle: 'sometimes', teamId: 'BAD TEAM' } } }, + }, true); + assert.ok(iosReleaseIssues.some((issue) => issue.field === 'targets.ios.release.exportMethod')); + assert.ok(iosReleaseIssues.some((issue) => issue.field === 'targets.ios.release.signingStyle')); + assert.ok(iosReleaseIssues.some((issue) => issue.field === 'targets.ios.release.teamId')); + assert.throws(() => resolveWorkspacePath('/tmp/native-work', '../escape.apk', 'Artifact'), /native build workspace/); +}); + +test('build release: non-mobile targets fail cleanly', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeBuildConfig(dir, { name: 'Web Release', version: '1.0.0', targets: { web: { loveJsDir: 'love.js' } } }); + + const result = run(['build', 'web', '--dir', dir, '--release', '--json']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Release mode is currently supported only for android and ios')); +}); + +test('build mobile: missing native template paths fail with actionable errors', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { + name: 'Missing Mobile Template', + version: '1.0.0', + productId: 'com.example.missingmobiletemplate', + }); + + const android = run(['build', 'android', '--dir', dir, '--allow-unsafe', '--json']); + assert.equal(android.exitCode, 1); + assert.ok(outputOf(android).includes('targets.android.loveAndroidDir')); + + const ios = run(['build', 'ios', '--dir', dir, '--allow-unsafe', '--json'], { env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0', FEATHER_TEST_ALLOW_IOS_BUILD: '1' } }); + assert.equal(ios.exitCode, 1); + assert.ok(outputOf(ios).includes('targets.ios.loveIosDir')); +}); + +test('build: production preflight blocks unsafe Feather config unless explicitly allowed', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeBuildConfig(dir, { name: 'Unsafe Game', version: '1.0.0', targets: { web: { loveJsDir: 'love.js' } } }); + writeFileSync(join(dir, 'feather.config.lua'), 'return { include = { "console" }, apiKey = "dev" }\n'); + + const blocked = run(['build', 'web', '--dir', dir, '--json']); + assert.equal(blocked.exitCode, 1); + assert.ok(outputOf(blocked).includes('Production build preflight failed')); + + const allowed = run(['build', 'web', '--dir', dir, '--json', '--allow-unsafe']); + assert.equal(allowed.exitCode, 0, outputOf(allowed)); + assert.equal(JSON.parse(allowed.stdout).ok, true); +}); diff --git a/cli/test/commands/doctor.test.mjs b/cli/test/commands/doctor.test.mjs new file mode 100644 index 00000000..6f96a8fb --- /dev/null +++ b/cli/test/commands/doctor.test.mjs @@ -0,0 +1,405 @@ +/* eslint-disable no-undef */ +import { + ANSI_RE, + LOCAL_SRC, + assert, + chmodSync, + delimiter, + dirname, + envWithPath, + existsSync, + join, + makeTmp, + mkdirSync, + outputOf, + parseDoctorJson, + parseDoctorJsonResult, + readFileSync, + resolve, + rmSync, + run, + sha256, + spawnCli, + stopChild, + symlinkSync, + test, + waitForOutput, + writeBuildConfig, + writeFakeAdb, + writeFakeAppleLibrariesZip, + writeFakeCommand, + writeFakeLove, + writeFakeLoveAndroid, + writeFakeLoveIos, + writeFakeLoveJs, + writeFakeVendorGit, + writeFakeXcrun, + writeFileSync, + writeGame, + writeLocalPluginSource, + writeLock, + writeMinimalRuntime, + readStoredZipEntries, +} from './helpers.mjs'; + +test('doctor --json reports unknown installed plugin trust', () => { + const dir = makeTmp(); + writeGame(dir); + writeMinimalRuntime(dir); + writeLocalPluginSource(dir, 'custom-plugin'); + + const parsed = parseDoctorJson(dir); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + const trustCheck = labels.get('Plugin custom-plugin'); + assert.equal(trustCheck.severity, 'warn'); + assert.ok(trustCheck.detail.includes('unknown trust')); + assert.ok(trustCheck.fix.includes('feather plugin remove custom-plugin')); +}); + +test('doctor --json reports dangerous bundled plugin trust', () => { + const dir = makeTmp(); + writeGame(dir); + writeMinimalRuntime(dir); + const result = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + + const parsed = parseDoctorJson(dir); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + const trustCheck = labels.get('Plugin console trust'); + assert.equal(trustCheck.severity, 'warn'); + assert.ok(trustCheck.detail.includes('development-only')); +}); + +test('doctor --json warns about runtime symlinks escaping project', () => { + const dir = makeTmp(); + const outside = join(makeTmp(), 'outside-runtime'); + writeGame(dir); + writeMinimalRuntime(outside); + symlinkSync(join(outside, 'feather'), join(dir, 'feather'), 'dir'); + + const parsed = parseDoctorJson(dir); + const symlinkCheck = parsed.checks.find((check) => check.label === 'Symlink escape'); + assert.equal(symlinkCheck.severity, 'warn'); + assert.ok(symlinkCheck.detail.includes('outside-runtime')); +}); + +test('doctor --json remains decoration-free and reports missing plugin directory', () => { + const dir = makeTmp(); + writeGame(dir); + writeMinimalRuntime(dir); + const parsed = parseDoctorJson(dir); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('Plugin directory').severity, 'info'); + assert.equal(labels.get('Plugin directory').detail, 'not installed'); +}); + +test('doctor --json reports malformed plugin manifests with recovery text', () => { + const dir = makeTmp(); + writeGame(dir); + writeMinimalRuntime(dir); + mkdirSync(join(dir, 'feather', 'plugins', 'bad-plugin'), { recursive: true }); + writeFileSync(join(dir, 'feather', 'plugins', 'bad-plugin', 'manifest.lua'), 'return { name = "Bad Plugin" }\n'); + + const parsed = parseDoctorJson(dir); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + const manifestCheck = labels.get('Plugin manifests'); + assert.equal(manifestCheck.severity, 'warn'); + assert.ok(manifestCheck.fix.includes('feather plugin update')); +}); + +test('doctor: human output honors NO_COLOR', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test", include = { "console" } }\n'); + const result = run(['doctor', dir]); + assert.equal(result.exitCode, 0); + assert.equal(ANSI_RE.test(result.stdout), false); + assert.ok(result.stdout.includes('Plugin console')); + assert.ok(result.stdout.includes('feather plugin install console')); +}); + +test('doctor --production fails unsafe remote-control and production settings', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + `return { + __DANGEROUS_INSECURE_CONNECTION__ = true, + host = "0.0.0.0", + include = { "console", "hot-reload" }, + apiKey = "dev", + captureScreenshot = true, + writeToDisk = true, + debugger = { + enabled = true, + hotReload = { + enabled = true, + allow = { "game.*" }, + persistToDisk = true, + }, + }, +} +`, + ); + + const { result, parsed } = parseDoctorJsonResult(dir, ['--production']); + assert.equal(result.exitCode, 1); + assert.equal(parsed.production, true); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + for (const label of [ + '__DANGEROUS_INSECURE_CONNECTION__', + 'Desktop App ID', + 'captureScreenshot', + 'Hot reload', + 'Hot reload allowlist', + 'Hot reload persistence', + 'Console API key', + 'Step debugger', + 'Disk logging', + 'Network host exposure', + ]) { + assert.equal(labels.get(label)?.severity, 'fail', `${label} should fail in production`); + } +}); + +test('doctor --json keeps unsafe settings warning-oriented outside production', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + `return { + __DANGEROUS_INSECURE_CONNECTION__ = true, + host = "0.0.0.0", + include = { "console" }, + apiKey = "dev", +} +`, + ); + + const { result, parsed } = parseDoctorJsonResult(dir); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(parsed.production, false); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('__DANGEROUS_INSECURE_CONNECTION__')?.severity, 'warn'); + assert.equal(labels.get('Console API key')?.severity, 'warn'); + assert.equal(labels.get('Network host exposure')?.severity, 'warn'); +}); + +test('doctor --security --json emits a sterile security report without secrets', () => { + const dir = makeTmp(); + const secret = 'StrongSecretValue1234567890!'; + writeGame(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + `return { + appId = "feather-app-test-1234567890", + host = "127.0.0.1", + include = { "console" }, + apiKey = "${secret}", +} +`, + ); + + const result = run(['doctor', dir, '--security', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + assert.equal(result.stdout.includes('✔'), false); + assert.equal(result.stdout.includes(secret), false); + + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.security, true); + assert.equal(parsed.report.config.apiKeyStatus, 'configured'); + assert.equal(parsed.report.config.consoleIncluded, true); + assert.equal(parsed.report.network.exposure, 'loopback'); + assert.ok(parsed.checks.every((check) => ['Safety', 'Plugins', 'Packages', 'Runtime', 'Project'].includes(check.group))); + assert.equal(parsed.checks.some((check) => check.label === 'Node.js'), false); +}); + +test('doctor --production fails unmanaged embedded runtime', () => { + const dir = makeTmp(); + writeGame(dir); + writeMinimalRuntime(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890", mode = "socket" }\n'); + + const { result, parsed } = parseDoctorJsonResult(dir, ['--production']); + assert.equal(result.exitCode, 1); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('Managed runtime')?.severity, 'fail'); + assert.ok(labels.get('Managed runtime')?.fix.includes('feather init')); +}); + +test('doctor: build and upload target checks report missing and configured dependencies', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { + name: 'Doctor Build Game', + version: '1.0.0', + upload: { itch: { project: 'tester/doctor-build-game' } }, + }); + const { parsed: missing } = parseDoctorJsonResult(dir, ['--build-target', 'web', '--upload-target', 'itch']); + const missingLabels = new Map(missing.checks.map((check) => [check.label, check])); + assert.equal(missingLabels.get('love.js player')?.severity, 'fail'); + assert.equal(missingLabels.get('butler')?.severity, 'fail'); + assert.ok(missingLabels.get('love.js player')?.fix.includes('targets.web.loveJsDir')); + + writeFakeLoveJs(dir); + writeBuildConfig(dir, { + name: 'Doctor Build Game', + version: '1.0.0', + targets: { web: { loveJsDir: 'love.js' } }, + upload: { itch: { project: 'tester/doctor-build-game' } }, + }); + const { binDir } = writeFakeCommand(dir, 'butler', `console.log('butler test'); process.exit(0);`); + const configured = run(['doctor', dir, '--json', '--build-target', 'web', '--upload-target', 'itch'], { env: envWithPath(binDir, { BUTLER_API_KEY: 'test-key' }) }); + assert.equal(configured.exitCode, 0, outputOf(configured)); + const parsed = JSON.parse(configured.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('love.js player')?.severity, 'pass'); + assert.equal(labels.get('butler')?.severity, 'pass'); + assert.equal(labels.get('BUTLER_API_KEY')?.severity, 'pass'); +}); + +test('doctor: android build target reports template and local tool setup', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Android Doctor Game', version: '1.0.0' }); + const { parsed: missing } = parseDoctorJsonResult(dir, ['--build-target', 'android']); + const missingLabels = new Map(missing.checks.map((check) => [check.label, check])); + assert.equal(missingLabels.get('love-android template')?.severity, 'fail'); + assert.equal(missingLabels.get('Android Gradle wrapper')?.severity, 'fail'); + assert.ok(missingLabels.get('love-android template')?.fix.includes('targets.android.loveAndroidDir')); + assert.ok(missingLabels.get('love-android template')?.fix.includes('feather build vendor add android')); + + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Android Doctor Game', + version: '1.0.0', + productId: 'com.example.androiddoctor', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + const { binDir } = writeFakeCommand(dir, 'java', `console.error('java version "17.0.0"'); process.exit(0);`); + const configured = run(['doctor', dir, '--json', '--build-target', 'android'], { + env: envWithPath(binDir, { ANDROID_HOME: join(dir, 'android-sdk') }), + }); + assert.equal(configured.stdout.trim().startsWith('{'), true, outputOf(configured)); + const parsed = JSON.parse(configured.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('love-android template')?.severity, 'pass'); + assert.equal(labels.get('Android Gradle wrapper')?.severity, 'pass'); + assert.equal(labels.get('JDK')?.severity, 'pass'); + assert.equal(labels.get('Android SDK')?.severity, 'pass'); + assert.equal(labels.get('Android product id')?.severity, 'pass'); + assert.equal(labels.get('Android signing')?.severity, 'warn'); +}); + +test('doctor: mobile build target reports config validation failures', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Doctor Bad Android', + version: '1.0.0', + productId: 'bad product id', + targets: { android: { loveAndroidDir: 'love-android', versionCode: 0 } }, + }); + + const { result, parsed } = parseDoctorJsonResult(dir, ['--build-target', 'android']); + assert.equal(result.exitCode, 1); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('Android config')?.severity, 'fail'); + assert.ok(labels.get('Android config')?.detail.includes('versionCode')); + assert.equal(labels.get('Android product id')?.severity, 'fail'); +}); + +test('doctor: android release reports missing signing environment', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeFileSync(join(dir, 'release.keystore'), 'fake keystore'); + writeBuildConfig(dir, { + name: 'Doctor Android Release', + version: '1.0.0', + productId: 'com.example.doctorandroidrelease', + targets: { + android: { + loveAndroidDir: 'love-android', + release: { + keystorePath: 'release.keystore', + keyAlias: 'release-key', + storePasswordEnv: 'FEATHER_MISSING_STORE_PASSWORD', + keyPasswordEnv: 'FEATHER_MISSING_KEY_PASSWORD', + }, + }, + }, + }); + + const { result, parsed } = parseDoctorJsonResult(dir, ['--build-target', 'android', '--release']); + assert.equal(result.exitCode, 1); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('Android config')?.severity, 'pass'); + assert.equal(labels.get('Android signing')?.severity, 'fail'); + assert.ok(labels.get('Android signing')?.detail.includes('FEATHER_MISSING_STORE_PASSWORD')); +}); + +test('doctor: ios build target reports platform, template, Xcode, and signing hints', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'iOS Doctor Game', version: '1.0.0' }); + const { parsed: missing } = parseDoctorJsonResult(dir, ['--build-target', 'ios']); + const missingLabels = new Map(missing.checks.map((check) => [check.label, check])); + assert.equal(missingLabels.get('LÖVE iOS template')?.severity, 'fail'); + assert.equal(missingLabels.get('LÖVE iOS Xcode project')?.severity, 'fail'); + assert.ok(missingLabels.get('LÖVE iOS template')?.fix.includes('feather build vendor add ios')); + + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'iOS Doctor Game', + version: '1.0.0', + targets: { ios: { loveIosDir: 'love-ios', bundleIdentifier: 'com.example.iosdoctor' } }, + }); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', `console.log('Xcode 99.0'); process.exit(0);`); + const configured = run(['doctor', dir, '--json', '--build-target', 'ios'], { env: envWithPath(binDir) }); + assert.equal(configured.stdout.trim().startsWith('{'), true, outputOf(configured)); + const parsed = JSON.parse(configured.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('xcodebuild')?.severity, 'pass'); + assert.equal(labels.get('LÖVE iOS template')?.severity, 'pass'); + assert.equal(labels.get('LÖVE iOS Xcode project')?.severity, 'pass'); + assert.equal(labels.get('iOS bundle id')?.severity, 'pass'); + assert.equal(labels.get('iOS signing team')?.severity, 'warn'); + if (process.platform === 'darwin') { + assert.equal(labels.get('macOS host')?.severity, 'pass'); + } else { + assert.equal(labels.get('macOS host')?.severity, 'fail'); + assert.equal(configured.exitCode, 1, outputOf(configured)); + } +}); + +test('doctor: ios release reports export options and release signing hints', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Doctor iOS Release', + version: '1.0.0', + targets: { + ios: { + loveIosDir: 'love-ios', + bundleIdentifier: 'com.example.doctoriosrelease', + release: { + exportMethod: 'app-store-connect', + signingStyle: 'manual', + teamId: 'TEAM12345', + }, + }, + }, + }); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', `console.log('Xcode 99.0'); process.exit(0);`); + const result = run(['doctor', dir, '--json', '--build-target', 'ios', '--release'], { env: envWithPath(binDir) }); + assert.equal(result.stdout.trim().startsWith('{'), true, outputOf(result)); + const parsed = JSON.parse(result.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('iOS config')?.severity, 'pass'); + assert.equal(labels.get('iOS signing team')?.severity, 'pass'); + assert.equal(labels.get('iOS export options')?.severity, 'pass'); +}); diff --git a/cli/test/commands/help.test.mjs b/cli/test/commands/help.test.mjs new file mode 100644 index 00000000..9f7a54fd --- /dev/null +++ b/cli/test/commands/help.test.mjs @@ -0,0 +1,15 @@ +/* eslint-disable no-undef */ +import { assert, outputOf, run, test } from './helpers.mjs'; + +function runOk(args) { + const result = run(args); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(result.stdout.length > 0); + return result.stdout; +} + +test('help: core commands render help text', () => { + assert.match(runOk(['--help']), /Usage:/); + assert.match(runOk(['doctor', '--help']), /doctor/); + assert.match(runOk(['plugin', '--help']), /plugin/); +}); diff --git a/cli/test/commands/helpers.mjs b/cli/test/commands/helpers.mjs new file mode 100644 index 00000000..99a7ecbc --- /dev/null +++ b/cli/test/commands/helpers.mjs @@ -0,0 +1,600 @@ +/* eslint-disable no-undef */ +/** + * Focused compiled-CLI coverage for non-package commands. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { delimiter, dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const CLI = fileURLToPath(new URL('../../dist/index.js', import.meta.url)); +const ROOT = fileURLToPath(new URL('../../..', import.meta.url)); +const LOCAL_SRC = join(ROOT, 'src-lua'); +// eslint-disable-next-line no-control-regex +const ANSI_RE = /\x1B\[[0-?]*[ -/]*[@-~]/; +const sha256 = (value) => createHash('sha256').update(value).digest('hex'); + +function makeTmp() { + return mkdtempSync(join(tmpdir(), 'feather-command-test-')); +} + +function run(args, extra = {}) { + const result = spawnSync(process.execPath, [CLI, ...args], { + encoding: 'utf8', + env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, + ...extra, + }); + return { + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + exitCode: result.status ?? 1, + }; +} + +function spawnCli(args, extra = {}) { + const child = spawn(process.execPath, [CLI, ...args], { + env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, + ...extra, + }); + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + return child; +} + +function waitForOutput(child, pattern, timeoutMs = 10000) { + return new Promise((resolveWait, rejectWait) => { + let output = ''; + const timer = setTimeout(() => { + rejectWait(new Error(`Timed out waiting for ${pattern}. Output:\n${output}`)); + }, timeoutMs); + const onData = (chunk) => { + output += chunk; + if (pattern.test(output)) { + clearTimeout(timer); + cleanup(); + resolveWait(output); + } + }; + const onExit = (code) => { + clearTimeout(timer); + cleanup(); + rejectWait(new Error(`Process exited with ${code} before ${pattern}. Output:\n${output}`)); + }; + const cleanup = () => { + child.stdout.off('data', onData); + child.stderr.off('data', onData); + child.off('exit', onExit); + }; + child.stdout.on('data', onData); + child.stderr.on('data', onData); + child.once('exit', onExit); + }); +} + +function stopChild(child) { + return new Promise((resolveStop) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolveStop(); + return; + } + child.once('exit', () => resolveStop()); + child.kill('SIGTERM'); + setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + }, 2000).unref(); + }); +} + +function outputOf(result) { + return `${result.stdout}\n${result.stderr}`; +} + +function writeGame(dir) { + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'main.lua'), + `function love.update(dt) +end + +function love.draw() +end +`, + ); +} + +function writeMinimalRuntime(dir) { + mkdirSync(join(dir, 'feather', 'lib'), { recursive: true }); + writeFileSync(join(dir, 'feather', 'init.lua'), 'FEATHER_VERSION_NAME = "test"\nreturn {}\n'); + writeFileSync(join(dir, 'feather', 'auto.lua'), 'return {}\n'); + writeFileSync(join(dir, 'feather', 'lib', 'ws.lua'), 'return {}\n'); + writeFileSync(join(dir, 'feather', 'plugin_manager.lua'), 'return {}\n'); +} + +function writeLocalPluginSource(root, id, options = {}) { + const sourceDir = options.sourceDir ?? id.replace(/\./g, '/'); + const pluginDir = join(root, 'plugins', sourceDir); + mkdirSync(pluginDir, { recursive: true }); + const manifestId = options.manifestId ?? id; + const versionLine = options.version === null ? '' : ` version = "${options.version ?? '1.0.0'}",\n`; + writeFileSync( + join(pluginDir, 'manifest.lua'), + `return { + id = "${manifestId}", + name = "${options.name ?? manifestId}", +${versionLine}} +`, + ); + writeFileSync(join(pluginDir, 'init.lua'), 'return {}\n'); + return pluginDir; +} + +function writeLock(dir, packages) { + writeFileSync(join(dir, 'feather.lock.json'), JSON.stringify({ lockfileVersion: 1, packages }, null, 2)); +} + +function writeFakeLove(dir, options = {}) { + const recordPath = options.recordPath ?? join(dir, 'fake-love-record.json'); + const exitCode = options.exitCode ?? 0; + const fakePath = join(dir, 'fake-love.cjs'); + writeFileSync( + fakePath, + `#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +const shimDir = process.argv[2] || ''; +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ + argv: process.argv.slice(2), + env: { + FEATHER_GAME_PATH: process.env.FEATHER_GAME_PATH, + FEATHER_SESSION_NAME: process.env.FEATHER_SESSION_NAME, + }, + shimMainExists: shimDir ? fs.existsSync(path.join(shimDir, 'main.lua')) : false, + featherAutoExists: shimDir ? fs.existsSync(path.join(shimDir, 'feather', 'auto.lua')) : false, + shimMain: shimDir ? fs.readFileSync(path.join(shimDir, 'main.lua'), 'utf8') : '', +}, null, 2)); +process.exit(${JSON.stringify(exitCode)}); +`, + ); + chmodSync(fakePath, 0o755); + return { fakePath, recordPath }; +} + +function writeFakeAdb(dir, options = {}) { + const recordPath = options.recordPath ?? join(dir, 'adb-record.json'); + const { binDir } = writeFakeCommand(dir, 'adb', ` +const fs = require('node:fs'); +const args = process.argv.slice(2); +const previous = fs.existsSync(${JSON.stringify(recordPath)}) + ? JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)}, 'utf8')) + : []; +previous.push({ args }); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify(previous, null, 2)); +if (${JSON.stringify(options.failInstall ?? false)} && args.includes('install')) { + console.error('install failed'); + process.exit(42); +} +process.exit(0); +`); + return { binDir, recordPath }; +} + +function writeFakeXcrun(dir) { + const recordPath = join(dir, 'xcrun-record.json'); + const { binDir } = writeFakeCommand(dir, 'xcrun', ` +const fs = require('node:fs'); +const args = process.argv.slice(2); +const previous = fs.existsSync(${JSON.stringify(recordPath)}) + ? JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)}, 'utf8')) + : []; +previous.push({ args }); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify(previous, null, 2)); +process.exit(0); +`); + return { binDir, recordPath }; +} + +function writeFakeCommand(dir, name, script) { + const binDir = join(dir, 'bin'); + mkdirSync(binDir, { recursive: true }); + const commandPath = join(binDir, name); + writeFileSync(commandPath, `#!/usr/bin/env node\n${script}\n`); + chmodSync(commandPath, 0o755); + return { binDir, commandPath }; +} + +function writeFakeVendorGit(dir) { + const recordPath = join(dir, 'git-record.json'); + const { binDir } = writeFakeCommand(dir, 'git', ` +const fs = require('fs'); +const path = require('path'); +const args = process.argv.slice(2); +const recordPath = ${JSON.stringify(recordPath)}; +const records = fs.existsSync(recordPath) ? JSON.parse(fs.readFileSync(recordPath, 'utf8')) : []; +records.push({ args }); +fs.writeFileSync(recordPath, JSON.stringify(records, null, 2)); +if (args[0] === '--version') { + console.log('git version test'); + process.exit(0); +} +if (args[0] !== 'clone') { + console.error('unexpected git args ' + args.join(' ')); + process.exit(1); +} +const target = args[args.length - 1]; +const repo = args[args.length - 2]; +fs.mkdirSync(target, { recursive: true }); +if (repo.includes('love.js')) { + fs.writeFileSync(path.join(target, 'index.html'), ''); + fs.writeFileSync(path.join(target, 'player.js'), 'console.log("love.js");\\n'); +} else if (repo.includes('love-android')) { + fs.writeFileSync(path.join(target, 'gradlew'), '#!/bin/sh\\n'); + fs.mkdirSync(path.join(target, 'app', 'src', 'embed', 'assets'), { recursive: true }); +} else if (repo.includes('love')) { + fs.mkdirSync(path.join(target, 'platform', 'xcode', 'love.xcodeproj'), { recursive: true }); + fs.writeFileSync(path.join(target, 'platform', 'xcode', 'love.xcodeproj', 'project.pbxproj'), ''); +} +process.exit(0); +`); + return { binDir, recordPath }; +} + +function envWithPath(binDir, extra = {}) { + return { + ...process.env, + NO_COLOR: '1', + FORCE_COLOR: '0', + PATH: `${binDir}${delimiter}${process.env.PATH ?? ''}`, + ...extra, + }; +} + +function writeFakeLoveJs(dir) { + const loveJsDir = join(dir, 'love.js'); + mkdirSync(loveJsDir, { recursive: true }); + writeFileSync( + join(loveJsDir, 'index.html'), + 'löve.js', + ); + writeFileSync(join(loveJsDir, 'player.min.js'), 'console.log("love.js");\n'); + return loveJsDir; +} + +function writeFakeLoveAndroid(dir, options = {}) { + const recordPath = options.recordPath ?? join(dir, 'gradle-record.json'); + const apkRel = options.apkRel ?? 'app/build/outputs/apk/embed/debug/app-embed-debug.apk'; + const aabRel = options.aabRel ?? 'app/build/outputs/bundle/embedRelease/app-embed-release.aab'; + const manifestPermission = options.recordAudioPermission + ? ' \n' + : ''; + const root = join(dir, 'love-android'); + mkdirSync(join(root, 'app', 'src', 'main', 'res', 'values'), { recursive: true }); + mkdirSync(join(root, 'app', 'src', 'main'), { recursive: true }); + mkdirSync(join(root, 'love', 'src', 'main', 'java', 'org', 'love2d', 'android'), { recursive: true }); + writeFileSync( + join(root, 'app', 'build.gradle'), + `android { + namespace "org.love2d.android" + defaultConfig { + applicationId "org.love2d.android" + versionCode 1 + versionName "0.0.0" + } +} +`, + ); + writeFileSync( + join(root, 'gradle.properties'), + `app.name_byte_array=76,195,150,86,69 +app.application_id=org.love2d.android +app.orientation=landscape +app.version_code=31 +app.version_name=11.5 +`, + ); + writeFileSync( + join(root, 'app', 'src', 'main', 'AndroidManifest.xml'), + ` +${manifestPermission} + + + + +`, + ); + writeFileSync(join(root, 'app', 'src', 'main', 'res', 'values', 'strings.xml'), 'LÖVE\n'); + writeFileSync( + join(root, 'love', 'src', 'main', 'java', 'org', 'love2d', 'android', 'GameActivity.java'), + `package org.love2d.android; +import java.io.IOException; +import java.io.InputStream; +class GameActivity { + boolean embed; + boolean needToCopyGameInArchive; + Object getResources() { return null; } + Object getAssets() { return null; } + void onCreate() { + embed = getResources().getBoolean(R.bool.embed); + } +} +`, + ); + const gradlew = join(root, 'gradlew'); + writeFileSync( + gradlew, + `#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +const cwd = process.cwd(); +const apk = path.join(cwd, ${JSON.stringify(apkRel)}); +fs.mkdirSync(path.dirname(apk), { recursive: true }); +fs.writeFileSync(apk, 'fake apk'); +const aab = path.join(cwd, ${JSON.stringify(aabRel)}); +fs.mkdirSync(path.dirname(aab), { recursive: true }); +fs.writeFileSync(aab, 'fake aab'); +const previous = fs.existsSync(${JSON.stringify(recordPath)}) + ? JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)}, 'utf8')) + : { records: [] }; +const entry = { + argv: process.argv.slice(2), + cwd, + embeddedLoveExists: fs.existsSync(path.join(cwd, 'app', 'src', 'embed', 'assets', 'game.love')), + gradle: fs.readFileSync(path.join(cwd, 'app', 'build.gradle'), 'utf8'), + gradleProperties: fs.readFileSync(path.join(cwd, 'gradle.properties'), 'utf8'), + manifest: fs.readFileSync(path.join(cwd, 'app', 'src', 'main', 'AndroidManifest.xml'), 'utf8'), + gameActivity: fs.existsSync(path.join(cwd, 'love', 'src', 'main', 'java', 'org', 'love2d', 'android', 'GameActivity.java')) + ? fs.readFileSync(path.join(cwd, 'love', 'src', 'main', 'java', 'org', 'love2d', 'android', 'GameActivity.java'), 'utf8') + : '', + signingProperties: fs.existsSync(path.join(cwd, 'feather-signing.properties')) + ? fs.readFileSync(path.join(cwd, 'feather-signing.properties'), 'utf8') + : '', +}; +previous.records.push(entry); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ ...entry, records: previous.records }, null, 2)); +console.log('fake gradle ' + process.argv.slice(2).join(' ')); +process.exit(0); +`, + ); + chmodSync(gradlew, 0o755); + return { root, recordPath }; +} + +function writeFakeLoveIos(dir) { + const root = join(dir, 'love-ios'); + const projectDir = join(root, 'platform', 'xcode', 'love.xcodeproj'); + const plistDir = join(root, 'platform', 'xcode', 'ios'); + mkdirSync(projectDir, { recursive: true }); + mkdirSync(plistDir, { recursive: true }); + writeFileSync( + join(plistDir, 'love-ios.plist'), + ` + + + + CFBundleExecutable + love + CFBundleIdentifier + org.love2d.love + CFBundleName + love + CFBundleShortVersionString + 11.5 + CFBundleVersion + 11.5 + + +`, + ); + writeFileSync( + join(projectDir, 'project.pbxproj'), + `// !$*UTF8*$! +{ +/* Begin PBXBuildFile section */ +/* End PBXBuildFile section */ +/* Begin PBXFileReference section */ +/* End PBXFileReference section */ +/* Begin PBXNativeTarget section */ + 111111111111111111111111 /* love-macosx */ = { + isa = PBXNativeTarget; + buildPhases = ( + 222222222222222222222222 /* Resources */, + ); + name = "love-macosx"; + }; + 333333333333333333333333 /* love-ios */ = { + isa = PBXNativeTarget; + buildPhases = ( + 444444444444444444444444 /* Sources */, + 555555555555555555555555 /* Frameworks */, + 666666666666666666666666 /* Resources */, + ); + name = "love-ios"; + }; +/* End PBXNativeTarget section */ +/* Begin PBXResourcesBuildPhase section */ + 222222222222222222222222 /* Resources */ = { + isa = PBXResourcesBuildPhase; + files = ( + ); + }; + 666666666666666666666666 /* Resources */ = { + isa = PBXResourcesBuildPhase; + files = ( + 777777777777777777777777 /* Launch Screen.xib in Resources */, + ); + }; +/* End PBXResourcesBuildPhase section */ +} +`, + ); + return root; +} + +function writeBuildConfig(dir, config) { + writeFileSync(join(dir, 'feather.build.json'), `${JSON.stringify(config, null, 2)}\n`); +} + +function readStoredZipEntries(zipPath) { + const buffer = readFileSync(zipPath); + const entries = new Map(); + let offset = 0; + while (offset + 30 < buffer.length && buffer.readUInt32LE(offset) === 0x04034b50) { + const method = buffer.readUInt16LE(offset + 8); + const compressedSize = buffer.readUInt32LE(offset + 18); + const fileNameLength = buffer.readUInt16LE(offset + 26); + const extraLength = buffer.readUInt16LE(offset + 28); + const nameStart = offset + 30; + const dataStart = nameStart + fileNameLength + extraLength; + const name = buffer.subarray(nameStart, nameStart + fileNameLength).toString('utf8'); + const data = buffer.subarray(dataStart, dataStart + compressedSize); + if (method !== 0) throw new Error(`Test zip reader only supports stored entries: ${name}`); + entries.set(name, data); + offset = dataStart + compressedSize; + } + return entries; +} + +function createDataDescriptorZipBuffer(entries) { + const chunks = []; + const central = []; + let offset = 0; + + for (const entry of entries) { + const name = Buffer.from(entry.name); + const data = Buffer.from(entry.data); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0x08, 6); + local.writeUInt16LE(0, 8); + local.writeUInt32LE(0, 10); + local.writeUInt32LE(0, 14); + local.writeUInt32LE(0, 18); + local.writeUInt32LE(0, 22); + local.writeUInt16LE(name.length, 26); + local.writeUInt16LE(0, 28); + + const descriptor = Buffer.alloc(16); + descriptor.writeUInt32LE(0x08074b50, 0); + descriptor.writeUInt32LE(0, 4); + descriptor.writeUInt32LE(data.length, 8); + descriptor.writeUInt32LE(data.length, 12); + + const centralEntry = Buffer.alloc(46); + centralEntry.writeUInt32LE(0x02014b50, 0); + centralEntry.writeUInt16LE(20, 4); + centralEntry.writeUInt16LE(20, 6); + centralEntry.writeUInt16LE(0x08, 8); + centralEntry.writeUInt16LE(0, 10); + centralEntry.writeUInt32LE(0, 12); + centralEntry.writeUInt32LE(0, 16); + centralEntry.writeUInt32LE(data.length, 20); + centralEntry.writeUInt32LE(data.length, 24); + centralEntry.writeUInt16LE(name.length, 28); + centralEntry.writeUInt16LE(0, 30); + centralEntry.writeUInt16LE(0, 32); + centralEntry.writeUInt16LE(0, 34); + centralEntry.writeUInt16LE(0, 36); + centralEntry.writeUInt32LE(0, 38); + centralEntry.writeUInt32LE(offset, 42); + + chunks.push(local, name, data, descriptor); + central.push(centralEntry, name); + offset += local.length + name.length + data.length + descriptor.length; + } + + const centralOffset = offset; + const centralSize = central.reduce((sum, chunk) => sum + chunk.length, 0); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(0, 4); + end.writeUInt16LE(0, 6); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(centralSize, 12); + end.writeUInt32LE(centralOffset, 16); + end.writeUInt16LE(0, 20); + + return Buffer.concat([...chunks, ...central, end]); +} + +async function writeFakeAppleLibrariesZip(dir) { + const zipPath = join(dir, 'love-apple-libraries.zip'); + writeFileSync(zipPath, createDataDescriptorZipBuffer([ + { name: 'love-apple-dependencies/iOS/libraries/liblove-test.a', data: Buffer.from('ios lib') }, + { name: 'love-apple-dependencies/macOS/Frameworks/test.framework/test', data: Buffer.from('mac lib') }, + { name: '__MACOSX/love-apple-dependencies/iOS/libraries/._ignored', data: Buffer.from('metadata') }, + ])); + return zipPath; +} + +function parseDoctorJson(dir, extra = []) { + const result = run(['doctor', dir, '--json', ...extra]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + assert.equal(result.stdout.trim().startsWith('{'), true); + return JSON.parse(result.stdout); +} + +function parseDoctorJsonResult(dir, extra = []) { + const result = run(['doctor', dir, '--json', ...extra]); + assert.equal(ANSI_RE.test(result.stdout), false); + assert.equal(result.stdout.trim().startsWith('{'), true, outputOf(result)); + return { result, parsed: JSON.parse(result.stdout) }; +} + +export { + ANSI_RE, + LOCAL_SRC, + assert, + chmodSync, + delimiter, + dirname, + envWithPath, + existsSync, + join, + makeTmp, + mkdirSync, + outputOf, + parseDoctorJson, + parseDoctorJsonResult, + readFileSync, + resolve, + rmSync, + run, + sha256, + spawnCli, + stopChild, + symlinkSync, + test, + waitForOutput, + writeBuildConfig, + writeFakeAdb, + writeFakeAppleLibrariesZip, + writeFakeCommand, + writeFakeLove, + writeFakeLoveAndroid, + writeFakeLoveIos, + writeFakeLoveJs, + writeFakeVendorGit, + writeFakeXcrun, + writeFileSync, + writeGame, + writeLocalPluginSource, + writeLock, + writeMinimalRuntime, + readStoredZipEntries, +}; diff --git a/cli/test/commands/init.test.mjs b/cli/test/commands/init.test.mjs new file mode 100644 index 00000000..27e9e8f0 --- /dev/null +++ b/cli/test/commands/init.test.mjs @@ -0,0 +1,117 @@ +/* eslint-disable no-undef */ +import { + LOCAL_SRC, + assert, + existsSync, + join, + makeTmp, + mkdirSync, + outputOf, + readFileSync, + rmSync, + run, + test, + writeFileSync, +} from './helpers.mjs'; + +function runOk(args) { + const result = run(args); + assert.equal(result.exitCode, 0, outputOf(result)); + return result.stdout.trim(); +} + +function writeE2eGame(dir) { + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'main.lua'), + `local t = 0 + +function love.update(dt) + t = t + dt +end + +function love.draw() + love.graphics.print("CLI E2E " .. tostring(t), 10, 10) +end +`, + ); +} + +function doctorJson(dir, extra = []) { + const raw = runOk(['doctor', dir, '--json', ...extra]); + return JSON.parse(raw); +} + +test('init/remove e2e: auto mode installs runtime, doctor passes, and remove cleans up', () => { + const workspace = makeTmp(); + const project = join(workspace, 'auto-game'); + + try { + writeE2eGame(project); + + runOk([ + 'init', + project, + '--mode', + 'auto', + '--local-src', + LOCAL_SRC, + '--install-dir', + 'feather', + '--no-plugins', + '--yes', + ]); + + assert.equal(existsSync(join(project, 'feather', 'init.lua')), true); + assert.equal(existsSync(join(project, 'feather.config.lua')), true); + assert.match(readFileSync(join(project, 'main.lua'), 'utf8'), /FEATHER-INIT-BEGIN require/); + assert.match(readFileSync(join(project, 'main.lua'), 'utf8'), /USE_DEBUGGER/); + + const report = doctorJson(project); + assert.equal(report.failures, 0, JSON.stringify(report, null, 2)); + assert.ok(report.checks.some((check) => check.label === 'Embedded Feather runtime' && check.severity === 'pass')); + assert.ok(report.checks.some((check) => check.label === 'USE_DEBUGGER guard' && check.severity === 'pass')); + + runOk(['remove', project, '--yes']); + assert.equal(existsSync(join(project, 'feather')), false); + assert.equal(existsSync(join(project, 'feather.config.lua')), false); + assert.equal(readFileSync(join(project, 'main.lua'), 'utf8').includes('FEATHER-INIT'), false); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); + +test('init e2e: cli mode creates config without embedding runtime', () => { + const workspace = makeTmp(); + const project = join(workspace, 'cli-game'); + + try { + writeE2eGame(project); + + runOk([ + 'init', + project, + '--mode', + 'cli', + '--local-src', + LOCAL_SRC, + '--install-dir', + 'feather', + '--no-plugins', + '--yes', + ]); + + assert.equal(existsSync(join(project, 'feather.config.lua')), true); + assert.equal(existsSync(join(project, 'feather')), false); + + const report = doctorJson(project); + assert.equal(report.failures, 0, JSON.stringify(report, null, 2)); + assert.ok(report.checks.some((check) => check.label === 'Embedded Feather runtime' && check.severity === 'info')); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); diff --git a/cli/test/package.test.mjs b/cli/test/commands/package.test.mjs similarity index 91% rename from cli/test/package.test.mjs rename to cli/test/commands/package.test.mjs index 187b0949..473e90e3 100644 --- a/cli/test/package.test.mjs +++ b/cli/test/commands/package.test.mjs @@ -11,13 +11,22 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; -const CLI = fileURLToPath(new URL('../dist/index.js', import.meta.url)); -const ROOT = fileURLToPath(new URL('../..', import.meta.url)); +const CLI = fileURLToPath(new URL('../../dist/index.js', import.meta.url)); +const ROOT = fileURLToPath(new URL('../../..', import.meta.url)); const LOCAL_SRC = join(ROOT, 'src-lua'); const sha256 = (s) => createHash('sha256').update(s).digest('hex'); @@ -747,7 +756,7 @@ test('command errors: central handler writes compact stderr and exit code', () = }); test('registry validation rejects targets outside the project', async () => { - const { validateRegistry } = await import('../dist/lib/package/registry.js'); + const { validateRegistry } = await import('../../dist/lib/package/registry.js'); assert.throws( () => validateRegistry({ @@ -1060,7 +1069,7 @@ function initSetupState(overrides = {}) { } test('package add: repo plan converts to custom install input', async () => { - const { packageAddPlanFiles, toCustomRepoPackageInput } = await import('../dist/lib/package/add-plan.js'); + const { packageAddPlanFiles, toCustomRepoPackageInput } = await import('../../dist/lib/package/add-plan.js'); const lockfile = emptyLockfile(); const commitSha = '0123456789abcdef0123456789abcdef01234567'; const plan = { @@ -1094,7 +1103,7 @@ test('package add: repo plan converts to custom install input', async () => { }); test('package add: url plan converts to custom install input', async () => { - const { packageAddPlanFiles, toCustomUrlPackageInput } = await import('../dist/lib/package/add-plan.js'); + const { packageAddPlanFiles, toCustomUrlPackageInput } = await import('../../dist/lib/package/add-plan.js'); const lockfile = emptyLockfile(); const urlFiles = [ { @@ -1118,7 +1127,7 @@ test('package add: url plan converts to custom install input', async () => { test('package add: failed plan install does not write lockfile', async () => { const dir = makeTmp(); - const { installPackageAddPlan } = await import('../dist/lib/package/add-plan.js'); + const { installPackageAddPlan } = await import('../../dist/lib/package/add-plan.js'); await withFetchMock( async () => new Response('missing', { status: 404 }), @@ -1146,7 +1155,7 @@ test('package add: failed plan install does not write lockfile', async () => { }); test('init mode: config builder preserves cli and advanced setup values', async () => { - const { buildInitSetup } = await import('../dist/ui/init/config.js'); + const { buildInitSetup } = await import('../../dist/ui/init/config.js'); const setup = buildInitSetup( initSetupState({ mode: 'cli', @@ -1183,7 +1192,7 @@ test('init mode: config builder preserves cli and advanced setup values', async test('custom add: repo install writes selected files and lockfile metadata', async () => { const dir = makeTmp(); - const { installCustomRepoPackage } = await import('../dist/lib/package/custom-add.js'); + const { installCustomRepoPackage } = await import('../../dist/lib/package/custom-add.js'); const commitSha = '0123456789abcdef0123456789abcdef01234567'; const files = new Map([ [`https://raw.githubusercontent.com/me/pkg/${commitSha}/init.lua`, 'return "init"'], @@ -1224,7 +1233,7 @@ test('custom add: repo install writes selected files and lockfile metadata', asy test('custom add: URL install writes buffered files and lockfile metadata', async () => { const dir = makeTmp(); - const { installCustomUrlPackage } = await import('../dist/lib/package/custom-add.js'); + const { installCustomUrlPackage } = await import('../../dist/lib/package/custom-add.js'); const buffer = Buffer.from('return "helper"'); const otherBuffer = Buffer.from('return "other"'); const result = await installCustomUrlPackage({ @@ -1264,7 +1273,7 @@ test('custom add: URL install writes buffered files and lockfile metadata', asyn }); test('custom add: lockfile source validation rejects malformed optional provenance', async () => { - const { validateLockfileSource } = await import('../dist/lib/package/lockfile.js'); + const { validateLockfileSource } = await import('../../dist/lib/package/lockfile.js'); assert.throws( () => validateLockfileSource({ repo: 'me/pkg', tag: 'main', commitSha: 'abc123' }), @@ -1282,7 +1291,7 @@ test('custom add: lockfile source validation rejects malformed optional provenan test('custom add: invalid repo commit provenance is rejected before fetch or write', async () => { const dir = makeTmp(); - const { installCustomRepoPackage } = await import('../dist/lib/package/custom-add.js'); + const { installCustomRepoPackage } = await import('../../dist/lib/package/custom-add.js'); let fetchCalled = false; await withFetchMock( @@ -1313,7 +1322,7 @@ test('custom add: invalid repo commit provenance is rejected before fetch or wri test('restore: old url source-only lockfiles remain compatible', async () => { const dir = makeTmp(); - const { restorePackage } = await import('../dist/lib/package/install.js'); + const { restorePackage } = await import('../../dist/lib/package/install.js'); const content = 'return "old url"'; await withFetchMock( @@ -1342,7 +1351,7 @@ test('restore: old url source-only lockfiles remain compatible', async () => { test('restore: enriched url lockfiles still prefer per-file URLs', async () => { const dir = makeTmp(); - const { restorePackage } = await import('../dist/lib/package/install.js'); + const { restorePackage } = await import('../../dist/lib/package/install.js'); const files = new Map([ ['https://example.com/a.lua', 'return "a"'], ['https://example.com/b.lua', 'return "b"'], @@ -1385,7 +1394,7 @@ test('restore: enriched url lockfiles still prefer per-file URLs', async () => { test('restore: old repo lockfiles without commitSha remain compatible', async () => { const dir = makeTmp(); - const { restorePackage } = await import('../dist/lib/package/install.js'); + const { restorePackage } = await import('../../dist/lib/package/install.js'); const content = 'return "repo"'; await withFetchMock( @@ -1414,7 +1423,7 @@ test('restore: old repo lockfiles without commitSha remain compatible', async () test('custom add: escaping target is rejected before fetch or write', async () => { const dir = makeTmp(); - const { installCustomRepoPackage } = await import('../dist/lib/package/custom-add.js'); + const { installCustomRepoPackage } = await import('../../dist/lib/package/custom-add.js'); let fetchCalled = false; await withFetchMock( @@ -1445,7 +1454,7 @@ test('custom add: escaping target is rejected before fetch or write', async () = test('custom add: failed repo fetch does not write lockfile', async () => { const dir = makeTmp(); - const { installCustomRepoPackage } = await import('../dist/lib/package/custom-add.js'); + const { installCustomRepoPackage } = await import('../../dist/lib/package/custom-add.js'); await withFetchMock( async () => new Response('missing', { status: 500 }), @@ -1468,3 +1477,79 @@ test('custom add: failed repo fetch does not write lockfile', async () => { }, ); }); + +test('package registry: top-level packages resolve offline in dry-run mode', () => { + const registryPath = join(ROOT, 'cli', 'dist', 'generated', 'registry.json'); + assert.ok(existsSync(registryPath), 'cli/dist/generated/registry.json missing; run build first'); + const registry = JSON.parse(readFileSync(registryPath, 'utf8')); + const topLevel = Object.entries(registry.packages).filter(([, entry]) => !entry.parent); + assert.ok(topLevel.length > 0, 'registry should include top-level packages'); + + for (const [pkgId] of topLevel) { + const pkgDir = makeTmp(); + + const install = run(['package', 'install', pkgId, '--dry-run', '--offline', '--dir', pkgDir]); + assert.equal(install.exitCode, 0, `${pkgId} dry-run failed:\n${outputOf(install)}`); + assert.ok(install.stdout.includes(pkgId), `${pkgId}: dry-run output should mention package`); + assert.ok(install.stdout.includes('Dry run'), `${pkgId}: dry-run output should label the plan`); + assert.equal(existsSync(join(pkgDir, 'feather.lock.json')), false, `${pkgId}: dry-run must not write lockfile`); + + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(pkgDir, { recursive: true, force: true }); + } + } +}); + +test('remove: refuses runtime symlink escaping project', () => { + const dir = makeTmp(); + const outside = join(makeTmp(), 'outside-runtime'); + writeGame(dir); + mkdirSync(join(outside, 'feather', 'lib'), { recursive: true }); + writeFileSync(join(outside, 'feather', 'init.lua'), 'FEATHER_VERSION_NAME = "test"\nreturn {}\n'); + symlinkSync(join(outside, 'feather'), join(dir, 'feather'), 'dir'); + writeFileSync( + join(dir, 'feather.config.lua'), + [ + '-- FEATHER-MANAGED-BEGIN', + '-- mode: auto', + '-- installDir: feather', + '-- manualEntrypoint: (none)', + '-- FEATHER-MANAGED-END', + 'return { appId = "feather-app-test" }', + '', + ].join('\n'), + ); + + const result = run(['remove', dir, '--yes']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Runtime remove target resolves outside project root')); + assert.equal(existsSync(join(outside, 'feather', 'init.lua')), true); +}); + +test('package remove: refuses lockfile target through symlink escaping project', () => { + const dir = makeTmp(); + const outside = join(makeTmp(), 'outside-lib'); + mkdirSync(outside, { recursive: true }); + writeFileSync(join(outside, 'helper.lua'), 'return {}\n'); + symlinkSync(outside, join(dir, 'lib'), 'dir'); + writeLock(dir, { + helper: { + version: 'url', + trust: 'experimental', + source: { url: 'https://example.com/helper.lua' }, + files: [ + { + name: 'helper.lua', + url: 'https://example.com/helper.lua', + target: 'lib/helper.lua', + sha256: sha256('return {}\n'), + }, + ], + }, + }); + + const result = run(['package', 'remove', 'helper', '--dir', dir, '--yes']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Refusing to remove unsafe package target: lib/helper.lua')); + assert.equal(existsSync(join(outside, 'helper.lua')), true); +}); diff --git a/cli/test/commands/plugins.test.mjs b/cli/test/commands/plugins.test.mjs new file mode 100644 index 00000000..7981922d --- /dev/null +++ b/cli/test/commands/plugins.test.mjs @@ -0,0 +1,169 @@ +/* eslint-disable no-undef */ +import { + ANSI_RE, + LOCAL_SRC, + assert, + chmodSync, + delimiter, + dirname, + envWithPath, + existsSync, + join, + makeTmp, + mkdirSync, + outputOf, + parseDoctorJson, + parseDoctorJsonResult, + readFileSync, + resolve, + rmSync, + run, + sha256, + spawnCli, + stopChild, + symlinkSync, + test, + waitForOutput, + writeBuildConfig, + writeFakeAdb, + writeFakeAppleLibrariesZip, + writeFakeCommand, + writeFakeLove, + writeFakeLoveAndroid, + writeFakeLoveIos, + writeFakeLoveJs, + writeFakeVendorGit, + writeFakeXcrun, + writeFileSync, + writeGame, + writeLocalPluginSource, + writeLock, + writeMinimalRuntime, + readStoredZipEntries, +} from './helpers.mjs'; + +test('plugin list: missing plugin directory is a clean empty state', () => { + const dir = makeTmp(); + const result = run(['plugin', 'list', dir]); + assert.equal(result.exitCode, 0); + assert.ok(result.stdout.includes('No plugins directory found')); +}); + +test('plugin install: local source copies console manifest', () => { + const dir = makeTmp(); + const result = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + const manifest = readFileSync(join(dir, 'feather', 'plugins', 'console', 'manifest.lua'), 'utf8'); + assert.ok(manifest.includes('id = "console"')); + assert.ok(outputOf(result).includes('Installed console')); +}); + +test('plugin update: explicit local update refreshes damaged files', () => { + const dir = makeTmp(); + run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + const installedInit = join(dir, 'feather', 'plugins', 'console', 'init.lua'); + writeFileSync(installedInit, 'damaged'); + + const result = run(['plugin', 'update', 'console', '--local-src', LOCAL_SRC, '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(readFileSync(installedInit, 'utf8'), readFileSync(join(LOCAL_SRC, 'plugins', 'console', 'init.lua'), 'utf8')); + assert.ok(outputOf(result).includes('Updated console')); +}); + +test('plugin update: local --yes updates all installed plugins without selection', () => { + const dir = makeTmp(); + run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + run(['plugin', 'install', 'hot-reload', '--local-src', LOCAL_SRC, '--dir', dir]); + const consoleInit = join(dir, 'feather', 'plugins', 'console', 'init.lua'); + const hotReloadInit = join(dir, 'feather', 'plugins', 'hot-reload', 'init.lua'); + writeFileSync(consoleInit, 'damaged console'); + writeFileSync(hotReloadInit, 'damaged hot reload'); + + const result = run(['plugin', 'update', '--local-src', LOCAL_SRC, '--dir', dir, '--yes']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(readFileSync(consoleInit, 'utf8'), readFileSync(join(LOCAL_SRC, 'plugins', 'console', 'init.lua'), 'utf8')); + assert.equal(readFileSync(hotReloadInit, 'utf8'), readFileSync(join(LOCAL_SRC, 'plugins', 'hot-reload', 'init.lua'), 'utf8')); +}); + +test('plugin install: unknown local plugin exits 1', () => { + const dir = makeTmp(); + const result = run(['plugin', 'install', 'zzz-missing', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Unknown plugin: zzz-missing')); +}); + +test('plugin install: local manifest is validated before copying', () => { + const dir = makeTmp(); + const source = join(makeTmp(), 'src-lua'); + writeLocalPluginSource(source, 'bad-plugin', { version: null }); + + const result = run(['plugin', 'install', 'bad-plugin', '--local-src', source, '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Plugin manifest is missing version: bad-plugin')); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'bad-plugin')), false); +}); + +test('plugin install: local manifest id must match plugin path', () => { + const dir = makeTmp(); + const source = join(makeTmp(), 'src-lua'); + writeLocalPluginSource(source, 'console', { manifestId: 'other-plugin' }); + + const result = run(['plugin', 'install', 'console', '--local-src', source, '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Plugin manifest id mismatch: expected console, found other-plugin')); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'console')), false); +}); + +test('plugin install: rejects path traversal plugin ids', () => { + const dir = makeTmp(); + const result = run(['plugin', 'install', '../escape', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Invalid plugin id: ../escape')); +}); + +test('plugin install: refuses install directory symlink escaping project', () => { + const dir = makeTmp(); + const outside = join(makeTmp(), 'outside-runtime'); + mkdirSync(outside, { recursive: true }); + symlinkSync(outside, join(dir, 'feather'), 'dir'); + + const result = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Plugin install target resolves outside project root')); + assert.equal(existsSync(join(outside, 'plugins', 'console')), false); +}); + +test('plugin update: explicit local update fails on invalid manifest', () => { + const dir = makeTmp(); + const source = join(makeTmp(), 'src-lua'); + writeLocalPluginSource(source, 'bad-plugin', { version: 'not valid' }); + + const result = run(['plugin', 'update', 'bad-plugin', '--local-src', source, '--dir', dir, '--yes']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Plugin manifest has invalid version: bad-plugin')); + assert.equal(existsSync(join(dir, 'feather', 'plugins', 'bad-plugin')), false); +}); + +test('plugin remove: refuses plugin directory symlink escaping project', () => { + const dir = makeTmp(); + const outside = join(makeTmp(), 'outside-runtime'); + writeLocalPluginSource(outside, 'console'); + symlinkSync(outside, join(dir, 'feather'), 'dir'); + + const result = run(['plugin', 'remove', 'console', '--dir', dir, '--yes']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Plugin remove target resolves outside project root')); + assert.equal(existsSync(join(outside, 'plugins', 'console', 'manifest.lua')), true); +}); + +test('plugin list: malformed manifests do not crash and use directory fallback id', () => { + const dir = makeTmp(); + const pluginDir = join(dir, 'feather', 'plugins', 'bad-plugin'); + mkdirSync(pluginDir, { recursive: true }); + writeFileSync(join(pluginDir, 'manifest.lua'), 'return { name = "Bad Plugin", version = "0.0.1" }\n'); + + const result = run(['plugin', 'list', dir]); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(result.stdout.includes('bad-plugin')); + assert.ok(result.stdout.includes('Bad Plugin')); +}); diff --git a/cli/test/commands/run.test.mjs b/cli/test/commands/run.test.mjs new file mode 100644 index 00000000..1f6b6ae3 --- /dev/null +++ b/cli/test/commands/run.test.mjs @@ -0,0 +1,559 @@ +/* eslint-disable no-undef */ +import { + ANSI_RE, + LOCAL_SRC, + assert, + chmodSync, + delimiter, + dirname, + envWithPath, + existsSync, + join, + makeTmp, + mkdirSync, + outputOf, + parseDoctorJson, + parseDoctorJsonResult, + readFileSync, + resolve, + rmSync, + run, + sha256, + spawnCli, + stopChild, + symlinkSync, + test, + waitForOutput, + writeBuildConfig, + writeFakeAdb, + writeFakeAppleLibrariesZip, + writeFakeCommand, + writeFakeLove, + writeFakeLoveAndroid, + writeFakeLoveIos, + writeFakeLoveJs, + writeFakeVendorGit, + writeFakeXcrun, + writeFileSync, + writeGame, + writeLocalPluginSource, + writeLock, + writeMinimalRuntime, + readStoredZipEntries, +} from './helpers.mjs'; + +test('run: non-interactive missing game path exits with compact error', () => { + const result = run(['run']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Game path is required')); + assert.equal(outputOf(result).includes('Raw mode is not supported'), false); + assert.equal(outputOf(result).includes('Error:'), false); +}); + +test('run: missing game path and missing main.lua render compact errors', () => { + const dir = makeTmp(); + const missingPath = join(dir, 'missing-game'); + const emptyGame = join(dir, 'empty-game'); + mkdirSync(emptyGame, { recursive: true }); + + const missing = run(['run', missingPath]); + assert.equal(missing.exitCode, 1); + assert.ok(outputOf(missing).includes(`Game path not found: ${resolve(missingPath)}`)); + assert.equal(outputOf(missing).includes('Error:'), false); + + const noMain = run(['run', emptyGame]); + assert.equal(noMain.exitCode, 1); + assert.ok(outputOf(noMain).includes(`No main.lua found in: ${resolve(emptyGame)}`)); + assert.equal(outputOf(noMain).includes('Error:'), false); +}); + +test('run: fake love receives shim, args, env, and exit code is propagated', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + const { fakePath, recordPath } = writeFakeLove(dir, { exitCode: 7 }); + + const result = run([ + 'run', + '--love', + fakePath, + '--session-name', + 'Command Test', + '--no-plugins', + '--feather-path', + join(LOCAL_SRC, 'feather'), + gameDir, + '--', + '--level', + '2', + ]); + + assert.equal(result.exitCode, 7); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.equal(record.env.FEATHER_GAME_PATH, resolve(gameDir)); + assert.equal(record.env.FEATHER_SESSION_NAME, 'Command Test'); + assert.equal(record.shimMainExists, true); + assert.equal(record.featherAutoExists, true); + assert.equal(existsSync(record.argv[0]), false, 'shim should be cleaned after love exits'); + assert.deepEqual(record.argv.slice(1), ['--level', '2']); +}); + +test('run: source checkout build exposes feather.auto without a bundled cli/lua directory', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + const { fakePath, recordPath } = writeFakeLove(dir); + + const result = run(['run', '--love', fakePath, gameDir]); + + assert.equal(result.exitCode, 0, outputOf(result)); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.equal(record.featherAutoExists, true); +}); + +test('run: accepts configPath aliases and recovers npm-stripped config path argument', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + const configPath = join(gameDir, 'feather.config.lua'); + writeFileSync(configPath, 'return { sessionName = "From Config Alias" }\n'); + + const alias = writeFakeLove(dir, { recordPath: join(dir, 'alias-record.json') }); + const aliasResult = run(['run', '--love', alias.fakePath, '--configPath', configPath, gameDir]); + assert.equal(aliasResult.exitCode, 0, outputOf(aliasResult)); + const aliasRecord = JSON.parse(readFileSync(alias.recordPath, 'utf8')); + assert.equal(aliasRecord.env.FEATHER_SESSION_NAME, 'From Config Alias'); + assert.deepEqual(aliasRecord.argv.slice(1), []); + + const stripped = writeFakeLove(dir, { recordPath: join(dir, 'stripped-record.json') }); + const strippedResult = run(['run', '--love', stripped.fakePath, gameDir, configPath]); + assert.equal(strippedResult.exitCode, 0, outputOf(strippedResult)); + const strippedRecord = JSON.parse(readFileSync(stripped.recordPath, 'utf8')); + assert.equal(strippedRecord.env.FEATHER_SESSION_NAME, 'From Config Alias'); + assert.deepEqual(strippedRecord.argv.slice(1), []); +}); + +test('run: shim preloads config before requiring feather.auto', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + const configPath = join(gameDir, 'feather.config.lua'); + writeFileSync(configPath, 'return { __DANGEROUS_INSECURE_CONNECTION__ = true, sessionName = "Security Config" }\n'); + const { fakePath, recordPath } = writeFakeLove(dir); + + const result = run(['run', '--love', fakePath, gameDir, '--config', configPath]); + + assert.equal(result.exitCode, 0, outputOf(result)); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.ok(record.shimMain.includes('FEATHER_AUTO_CONFIG = {')); + assert.ok(record.shimMain.includes('__DANGEROUS_INSECURE_CONNECTION__ = true')); + assert.equal(record.shimMain.includes('require("feather.auto").setup'), false); + assert.ok(record.shimMain.includes('require("feather.auto")')); +}); + +test('run --no-debugger: launches game directly without Feather shim', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + const { fakePath, recordPath } = writeFakeLove(dir); + + const result = run(['run', '--love', fakePath, '--no-debugger', gameDir, '--', '--plain']); + + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('Debugger disabled')); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.equal(record.argv[0], resolve(gameDir)); + assert.deepEqual(record.argv.slice(1), ['--plain']); + assert.equal(record.featherAutoExists, false); + assert.equal(record.shimMain, readFileSync(join(gameDir, 'main.lua'), 'utf8')); +}); + +test('run --disable-debugger: aliases debugger-disabled desktop launch', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + const { fakePath, recordPath } = writeFakeLove(dir); + + const result = run(['run', '--love', fakePath, '--disable-debugger', gameDir]); + + assert.equal(result.exitCode, 0, outputOf(result)); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.equal(record.argv[0], resolve(gameDir)); +}); + +test('run --target android: builds, installs, sets adb reverse, launches, and supports device selection', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Run Android', + version: '1.0.0', + productId: 'com.example.runandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + writeFileSync(join(dir, 'feather.config.lua'), 'return { port = 4010 }\n'); + const { binDir, recordPath } = writeFakeAdb(dir); + + const result = run(['run', dir, '--target', 'android', '--device', 'emulator-5554'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('Launched android')); + assert.ok(outputOf(result).includes('com.example.runandroid')); + const records = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.deepEqual(records.map((entry) => entry.args), [ + ['-s', 'emulator-5554', 'version'], + ['-s', 'emulator-5554', 'install', '-r', join(dir, 'builds', 'run-android-1.0.0-android.apk')], + ['-s', 'emulator-5554', 'shell', 'am', 'force-stop', 'com.example.runandroid'], + ['-s', 'emulator-5554', 'reverse', 'tcp:4010', 'tcp:4010'], + ['-s', 'emulator-5554', 'shell', 'monkey', '-p', 'com.example.runandroid', '-c', 'android.intent.category.LAUNCHER', '1'], + ]); + const entries = readStoredZipEntries(join(dir, 'builds', 'run-android-1.0.0.love')); + assert.equal(entries.has('feather/auto.lua'), true); + assert.match(entries.get('feather.config.lua').toString('utf8'), /port\s*=\s*4010/); +}); + +test('run --target android --config: embeds selected raw Feather config in mobile love archive', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + const configPath = join(gameDir, 'custom-feather.config.lua'); + writeFileSync(configPath, `return { + sessionName = "Mobile Custom", + __DANGEROUS_INSECURE_CONNECTION__ = true, + debugger = { + hotReload = { + enabled = true, + allow = { "game.*" }, + }, + }, +} +`); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Run Android Config', + version: '1.0.0', + productId: 'com.example.runandroidconfig', + sourceDir: 'game', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + const { binDir } = writeFakeAdb(dir); + + const result = run(['run', gameDir, '--target', 'android', '--config', configPath, '--no-adb-reverse'], { + cwd: dir, + env: envWithPath(binDir), + }); + assert.equal(result.exitCode, 0, outputOf(result)); + const entries = readStoredZipEntries(join(dir, 'builds', 'run-android-config-1.0.0.love')); + assert.equal(entries.has('main.lua'), true); + assert.equal(entries.has('.feather-main.lua'), true); + assert.equal(entries.has('feather/core/debug_overlay.lua'), true); + const config = entries.get('feather.config.lua').toString('utf8'); + assert.match(config, /sessionName\s*=\s*"Mobile Custom"/); + assert.match(config, /hotReload\s*=\s*\{/); + assert.match(config, /allow\s*=\s*\{\s*"game\.\*"\s*\}/); +}); + +test('run --target android: uses root build config for a nested game path', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'src-lua', 'example', 'test_cli'); + writeGame(gameDir); + const { recordPath: gradleRecordPath } = writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Nested Android', + version: '1.0.0', + productId: 'com.example.nestedandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + const { binDir } = writeFakeAdb(dir); + + const result = run(['run', gameDir, '--target', 'android', '--no-adb-reverse'], { + cwd: dir, + env: envWithPath(binDir), + }); + + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('Launched android')); + assert.ok(outputOf(result).includes(gameDir)); + assert.ok(existsSync(join(dir, 'builds', 'nested-android-1.0.0-android.apk'))); + const gradleRecord = JSON.parse(readFileSync(gradleRecordPath, 'utf8')); + assert.equal(gradleRecord.embeddedLoveExists, true); +}); + +test('run --target android --no-adb-reverse skips reverse setup', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'No Reverse', + version: '1.0.0', + productId: 'com.example.noreverse', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + const { binDir, recordPath } = writeFakeAdb(dir); + + const result = run(['run', dir, '--target', 'android', '--no-adb-reverse'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + const records = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.equal(records.some((entry) => entry.args.includes('reverse')), false); +}); + +test('run --target android --no-debugger skips adb reverse setup', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'No Debugger Android', + version: '1.0.0', + productId: 'com.example.nodebuggerandroid', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + const { binDir, recordPath } = writeFakeAdb(dir); + + const result = run(['run', dir, '--target', 'android', '--no-debugger'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + const records = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.equal(records.some((entry) => entry.args.includes('reverse')), false); + assert.equal(outputOf(result).includes('ADB reverse disabled'), true); + const entries = readStoredZipEntries(join(dir, 'builds', 'no-debugger-android-1.0.0.love')); + assert.equal(entries.has('feather/auto.lua'), false); + assert.equal(entries.has('.feather-main.lua'), false); +}); + +test('run --target android --no-cache forwards cache option to mobile build', () => { + const dir = makeTmp(); + writeGame(dir); + const { recordPath: gradleRecordPath } = writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Run No Cache', + version: '1.0.0', + productId: 'com.example.runnocache', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + const { binDir } = writeFakeAdb(dir); + + const first = run(['run', dir, '--target', 'android', '--no-adb-reverse', '--no-cache'], { env: envWithPath(binDir) }); + const second = run(['run', dir, '--target', 'android', '--no-adb-reverse', '--no-cache'], { env: envWithPath(binDir) }); + assert.equal(first.exitCode, 0, outputOf(first)); + assert.equal(second.exitCode, 0, outputOf(second)); + assert.equal(existsSync(join(dir, 'builds', '.feather-cache')), false); + const records = JSON.parse(readFileSync(gradleRecordPath, 'utf8')).records; + assert.equal(records.length, 2); + assert.notEqual(records[0].cwd, records[1].cwd); +}); + +test('run --target android: missing adb produces doctor guidance', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Missing Adb', + version: '1.0.0', + productId: 'com.example.missingadb', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + + const result = run(['run', dir, '--target', 'android'], { + env: { + ...process.env, + NO_COLOR: '1', + FORCE_COLOR: '0', + PATH: dirname(process.execPath), + }, + }); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('adb not found')); + assert.ok(outputOf(result).includes('feather doctor --build-target android')); +}); + +test('run --target android: failed install exits with compact error', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveAndroid(dir); + writeBuildConfig(dir, { + name: 'Install Fail', + version: '1.0.0', + productId: 'com.example.installfail', + targets: { android: { loveAndroidDir: 'love-android' } }, + }); + const { binDir } = writeFakeAdb(dir, { failInstall: true }); + + const result = run(['run', dir, '--target', 'android'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Android install failed')); + assert.ok(outputOf(result).includes('install failed')); +}); + +test('run --target ios: builds app, installs simulator app, and launches bundle id', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Run iOS', + version: '1.0.0', + targets: { + ios: { + loveIosDir: 'love-ios', + bundleIdentifier: 'com.example.runios', + derivedDataPath: 'builds/ios-run-derived-data', + }, + }, + }); + const recordPath = join(dir, 'xcodebuild-run-record.json'); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` +if (process.argv.includes('-version')) { + console.log('Xcode 99.0'); + process.exit(0); +} +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +const derivedData = args[args.indexOf('-derivedDataPath') + 1]; +const configuration = args[args.indexOf('-configuration') + 1] || 'Release'; +const sdk = args[args.indexOf('-sdk') + 1] || 'iphonesimulator'; +const sdkFolder = sdk.startsWith('iphoneos') ? 'iphoneos' : 'iphonesimulator'; +const app = path.join(derivedData, 'Build', 'Products', configuration + '-' + sdkFolder, 'love-ios.app'); +fs.mkdirSync(app, { recursive: true }); +fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ argv: args }, null, 2)); +process.exit(0); +`); + const xcrun = writeFakeXcrun(dir); + + const result = run(['run', dir, '--target', 'ios', '--device', 'SIM-123'], { + env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }), + }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('Launched ios')); + const records = JSON.parse(readFileSync(xcrun.recordPath, 'utf8')); + assert.deepEqual(records.map((entry) => entry.args), [ + ['simctl', 'terminate', 'SIM-123', 'com.example.runios'], + ['simctl', 'uninstall', 'SIM-123', 'com.example.runios'], + ['simctl', 'install', 'SIM-123', join(dir, 'builds', 'run-ios-1.0.0-ios.app')], + ['simctl', 'launch', 'SIM-123', 'com.example.runios'], + ]); + assert.equal(existsSync(join(dir, 'builds', 'run-ios-1.0.0-ios.app', 'game.love')), true); +}); + +test('run --target ios: missing xcrun produces doctor guidance', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Missing Xcrun', + version: '1.0.0', + targets: { ios: { loveIosDir: 'love-ios', bundleIdentifier: 'com.example.missingxcrun' } }, + }); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +const derivedData = args[args.indexOf('-derivedDataPath') + 1]; +const configuration = args[args.indexOf('-configuration') + 1] || 'Release'; +const sdk = args[args.indexOf('-sdk') + 1] || 'iphonesimulator'; +const sdkFolder = sdk.startsWith('iphoneos') ? 'iphoneos' : 'iphonesimulator'; +const app = path.join(derivedData, 'Build', 'Products', configuration + '-' + sdkFolder, 'love-ios.app'); +fs.mkdirSync(app, { recursive: true }); +fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); +process.exit(0); +`); + + const result = run(['run', dir, '--target', 'ios'], { + env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1', PATH: `${binDir}${delimiter}${dirname(process.execPath)}` }), + }); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('xcrun not found')); + assert.ok(outputOf(result).includes('feather doctor --build-target ios')); +}); + +test('run mobile: forwarded game arguments are rejected', () => { + const dir = makeTmp(); + writeGame(dir); + + const result = run(['run', dir, '--target', 'android', '--', '--level', 'dev']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Mobile run does not support forwarded game arguments yet')); +}); + +test('run --target web: builds, embeds Feather, serves generated html, and stays running', async () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeFileSync(join(dir, 'feather.config.lua'), `return { + sessionName = "Web Custom", + __DANGEROUS_INSECURE_CONNECTION__ = true, + debugOverlay = { visible = false }, +} +`); + writeBuildConfig(dir, { + name: 'Run Web', + version: '1.0.0', + targets: { web: { loveJsDir: 'love.js' } }, + }); + + const child = spawnCli(['run', dir, '--target', 'web', '--web-port', '0', '--config', join(dir, 'feather.config.lua')], { + env: envWithPath('', { FEATHER_TEST_WEB_RUN_NO_SERVER: '1' }), + }); + try { + const output = await waitForOutput(child, /Debugger\s+enabled/); + assert.match(output, /Serving web build/); + assert.match(output, /Debugger\s+enabled/); + assert.equal(existsSync(join(dir, 'builds', 'run-web-1.0.0-html', 'index.html')), true); + const entries = readStoredZipEntries(join(dir, 'builds', 'run-web-1.0.0.love')); + assert.equal(entries.has('main.lua'), true); + assert.equal(entries.has('.feather-main.lua'), true); + assert.equal(entries.has('feather/auto.lua'), true); + assert.equal(entries.has('feather/core/debug_overlay.lua'), true); + assert.match(entries.get('feather.config.lua').toString('utf8'), /sessionName\s*=\s*"Web Custom"/); + assert.match(entries.get('feather.config.lua').toString('utf8'), /debugOverlay\s*=\s*\{/); + } finally { + await stopChild(child); + } +}); + +test('run --target web --no-debugger: builds and serves raw source', async () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeBuildConfig(dir, { + name: 'Run Web Raw', + version: '1.0.0', + targets: { web: { loveJsDir: 'love.js' } }, + }); + + const child = spawnCli(['run', dir, '--target', 'web', '--web-port', '0', '--no-debugger'], { + env: envWithPath('', { FEATHER_TEST_WEB_RUN_NO_SERVER: '1' }), + }); + try { + const output = await waitForOutput(child, /Debugger\s+disabled/); + assert.match(output, /Debugger\s+disabled/); + const entries = readStoredZipEntries(join(dir, 'builds', 'run-web-raw-1.0.0.love')); + assert.equal(entries.has('main.lua'), true); + assert.equal(entries.has('.feather-main.lua'), false); + assert.equal(entries.has('feather/auto.lua'), false); + } finally { + await stopChild(child); + } +}); + +test('run --target web: rejects forwarded game arguments', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeBuildConfig(dir, { + name: 'Run Web Args', + version: '1.0.0', + targets: { web: { loveJsDir: 'love.js' } }, + }); + + const result = run(['run', dir, '--target', 'web', '--', '--foo']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Web run does not support forwarded game arguments yet.')); +}); + +test('run --target web: missing love.js config exits with web build guidance', () => { + const dir = makeTmp(); + writeGame(dir); + + const result = run(['run', dir, '--target', 'web']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('Web build requires targets.web.loveJsDir')); +}); diff --git a/cli/test/commands/runtime.test.mjs b/cli/test/commands/runtime.test.mjs new file mode 100644 index 00000000..dd82b4f1 --- /dev/null +++ b/cli/test/commands/runtime.test.mjs @@ -0,0 +1,145 @@ +/* eslint-disable no-undef */ +import { + ANSI_RE, + LOCAL_SRC, + assert, + chmodSync, + delimiter, + dirname, + envWithPath, + existsSync, + join, + makeTmp, + mkdirSync, + outputOf, + parseDoctorJson, + parseDoctorJsonResult, + readFileSync, + resolve, + rmSync, + run, + sha256, + spawnCli, + stopChild, + symlinkSync, + test, + waitForOutput, + writeBuildConfig, + writeFakeAdb, + writeFakeAppleLibrariesZip, + writeFakeCommand, + writeFakeLove, + writeFakeLoveAndroid, + writeFakeLoveIos, + writeFakeLoveJs, + writeFakeVendorGit, + writeFakeXcrun, + writeFileSync, + writeGame, + writeLocalPluginSource, + writeLock, + writeMinimalRuntime, + readStoredZipEntries, +} from './helpers.mjs'; + +test('command runtime redacts API keys from compact and debug errors', async () => { + const { runCliAction } = await import('../../dist/lib/command.js'); + const originalError = console.error; + const previousExitCode = process.exitCode; + const previousDebug = process.env.FEATHER_DEBUG; + const secret = 'StrongSecretValue1234567890!'; + const lines = []; + process.env.FEATHER_DEBUG = '1'; + process.exitCode = undefined; + console.error = (line = '') => lines.push(String(line)); + try { + await runCliAction(async () => { + throw new Error(`Failed to parse config: apiKey = "${secret}"`); + }); + const output = lines.join('\n'); + assert.equal(process.exitCode, 1); + assert.equal(output.includes(secret), false); + assert.ok(output.includes('apiKey = "[redacted]"')); + } finally { + console.error = originalError; + process.exitCode = previousExitCode; + if (previousDebug === undefined) delete process.env.FEATHER_DEBUG; + else process.env.FEATHER_DEBUG = previousDebug; + } +}); + +test('command runtime: unexpected errors render compact stderr and exit 1', async () => { + const { runCliAction } = await import('../../dist/lib/command.js'); + const originalError = console.error; + const lines = []; + const previousExitCode = process.exitCode; + const previousDebug = process.env.FEATHER_DEBUG; + delete process.env.FEATHER_DEBUG; + process.exitCode = undefined; + console.error = (line = '') => lines.push(String(line)); + try { + await runCliAction(async () => { + throw new Error('surprise failure'); + }); + assert.equal(process.exitCode, 1); + assert.ok(lines.join('\n').includes('surprise failure')); + assert.equal(lines.join('\n').includes('Error: surprise failure'), false); + } finally { + console.error = originalError; + process.exitCode = previousExitCode; + if (previousDebug === undefined) delete process.env.FEATHER_DEBUG; + else process.env.FEATHER_DEBUG = previousDebug; + } +}); + +test('command runtime: FEATHER_DEBUG includes stack for unexpected errors', async () => { + const { runCliAction } = await import('../../dist/lib/command.js'); + const originalError = console.error; + const lines = []; + const previousExitCode = process.exitCode; + const previousDebug = process.env.FEATHER_DEBUG; + process.env.FEATHER_DEBUG = '1'; + process.exitCode = undefined; + console.error = (line = '') => lines.push(String(line)); + try { + await runCliAction(async () => { + throw new Error('debuggable failure'); + }); + assert.equal(process.exitCode, 1); + assert.ok(lines.join('\n').includes('debuggable failure')); + assert.ok(lines.join('\n').includes('Error: debuggable failure')); + } finally { + console.error = originalError; + process.exitCode = previousExitCode; + if (previousDebug === undefined) delete process.env.FEATHER_DEBUG; + else process.env.FEATHER_DEBUG = previousDebug; + } +}); + +test('json commands used by scripts stay parseable and decoration-free', () => { + const dir = makeTmp(); + writeGame(dir); + const content = 'return {}'; + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'helper.lua'), content); + writeLock(dir, { + helper: { + version: 'url', + trust: 'experimental', + source: { url: 'https://example.com/helper.lua' }, + files: [{ name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: sha256(content) }], + }, + }); + + const audit = run(['package', 'audit', '--json', '--dir', dir]); + assert.equal(audit.exitCode, 0); + assert.equal(ANSI_RE.test(audit.stdout), false); + const auditParsed = JSON.parse(audit.stdout); + assert.equal(auditParsed[0].status, 'verified'); + + const doctor = run(['doctor', dir, '--json']); + assert.equal(doctor.exitCode, 0); + assert.equal(ANSI_RE.test(doctor.stdout), false); + assert.equal(doctor.stdout.trim().startsWith('{'), true); + JSON.parse(doctor.stdout); +}); diff --git a/cli/test/commands/upload.test.mjs b/cli/test/commands/upload.test.mjs new file mode 100644 index 00000000..d2f90824 --- /dev/null +++ b/cli/test/commands/upload.test.mjs @@ -0,0 +1,111 @@ +/* eslint-disable no-undef */ +import { + ANSI_RE, + LOCAL_SRC, + assert, + chmodSync, + delimiter, + dirname, + envWithPath, + existsSync, + join, + makeTmp, + mkdirSync, + outputOf, + parseDoctorJson, + parseDoctorJsonResult, + readFileSync, + resolve, + rmSync, + run, + sha256, + spawnCli, + stopChild, + symlinkSync, + test, + waitForOutput, + writeBuildConfig, + writeFakeAdb, + writeFakeAppleLibrariesZip, + writeFakeCommand, + writeFakeLove, + writeFakeLoveAndroid, + writeFakeLoveIos, + writeFakeLoveJs, + writeFakeVendorGit, + writeFakeXcrun, + writeFileSync, + writeGame, + writeLocalPluginSource, + writeLock, + writeMinimalRuntime, + readStoredZipEntries, +} from './helpers.mjs'; + +test('upload itch: dry-run uses build manifest and configured channel', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeBuildConfig(dir, { + name: 'Upload Game', + version: '3.4.5', + targets: { web: { loveJsDir: 'love.js' } }, + upload: { itch: { project: 'tester/upload-game', channels: { web: 'html5' } } }, + }); + const build = run(['build', 'web', '--dir', dir, '--json']); + assert.equal(build.exitCode, 0, outputOf(build)); + + const result = run(['upload', 'itch', 'web', '--dir', dir, '--dry-run', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, true); + assert.equal(parsed.dryRun, true); + assert.equal(parsed.project, 'tester/upload-game'); + assert.equal(parsed.channel, 'html5'); + assert.equal(parsed.userVersion, '3.4.5'); + assert.deepEqual(parsed.command.slice(0, 2), ['butler', 'push']); +}); + +test('upload itch: fake butler receives artifact, channel, version, and flags', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeBuildConfig(dir, { + name: 'Butler Game', + version: '4.0.0', + targets: { web: { loveJsDir: 'love.js' } }, + upload: { itch: { project: 'tester/butler-game', channels: { web: 'html5' } } }, + }); + const build = run(['build', 'web', '--dir', dir, '--json']); + assert.equal(build.exitCode, 0, outputOf(build)); + const recordPath = join(dir, 'butler-record.json'); + const { binDir } = writeFakeCommand(dir, 'butler', ` +if (process.argv.includes('--version')) { + console.log('butler test'); + process.exit(0); +} +const fs = require('node:fs'); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ argv: process.argv.slice(2) }, null, 2)); +process.exit(0); +`); + + const result = run(['upload', 'itch', 'web', '--dir', dir, '--if-changed', '--hidden', '--json'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.equal(record.argv[0], 'push'); + assert.ok(record.argv[1].endsWith('butler-game-4.0.0.love') || record.argv[1].endsWith('butler-game-4.0.0-html.zip')); + assert.equal(record.argv[2], 'tester/butler-game:html5'); + assert.ok(record.argv.includes('--userversion')); + assert.ok(record.argv.includes('4.0.0')); + assert.ok(record.argv.includes('--if-changed')); + assert.ok(record.argv.includes('--hidden')); +}); + +test('upload steam: planned target fails cleanly', () => { + const dir = makeTmp(); + writeGame(dir); + const result = run(['upload', 'steam', '--dir', dir, '--json']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('planned but not supported yet')); +}); diff --git a/cli/test/e2e.mjs b/cli/test/e2e.mjs deleted file mode 100644 index f3a3fc3b..00000000 --- a/cli/test/e2e.mjs +++ /dev/null @@ -1,219 +0,0 @@ -#!/usr/bin/env node -/* eslint-disable no-undef */ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); -const cli = join(root, 'cli', 'dist', 'index.js'); -const localSrc = join(root, 'src-lua'); - -function log(message) { - console.log(`cli-e2e: ${message}`); -} - -function fail(message) { - console.error(`cli-e2e: ${message}`); - process.exit(1); -} - -function assert(condition, message) { - if (!condition) fail(message); -} - -function run(args, options = {}) { - const result = spawnSync(process.execPath, [cli, ...args], { - cwd: root, - encoding: 'utf8', - env: { ...process.env, NO_COLOR: '1' }, - ...options, - }); - - if (result.status !== 0) { - console.error(result.stdout); - console.error(result.stderr); - fail(`command failed: feather ${args.join(' ')}`); - } - - return result.stdout.trim(); -} - -function read(path) { - return readFileSync(path, 'utf8'); -} - -function writeGame(dir) { - writeFileSync( - join(dir, 'main.lua'), - `local t = 0 - -function love.update(dt) - t = t + dt -end - -function love.draw() - love.graphics.print("CLI E2E " .. tostring(t), 10, 10) -end -`, - ); -} - -function doctorJson(dir, extra = []) { - const raw = run(['doctor', dir, '--json', ...extra]); - try { - return JSON.parse(raw); - } catch (err) { - console.error(raw); - throw err; - } -} - -assert(existsSync(cli), 'cli/dist/index.js is missing. Run npm run cli:build first.'); -assert(existsSync(join(localSrc, 'feather', 'init.lua')), 'src-lua Feather runtime is missing.'); - -const workspace = mkdtempSync(join(tmpdir(), 'kyonru-feather-e2e-')); -log(`workspace ${workspace}`); - -try { - const autoProject = join(workspace, 'auto-game'); - const cliProject = join(workspace, 'cli-game'); - rmSync(autoProject, { recursive: true, force: true }); - rmSync(cliProject, { recursive: true, force: true }); - writeFileSync(join(workspace, '.keep'), ''); - mkdirSync(autoProject, { recursive: true }); - mkdirSync(cliProject, { recursive: true }); - - writeGame(autoProject); - writeGame(cliProject); - - log('init auto project'); - run([ - 'init', - autoProject, - '--mode', - 'auto', - '--local-src', - localSrc, - '--install-dir', - 'feather', - '--no-plugins', - '--yes', - ]); - - assert(existsSync(join(autoProject, 'feather', 'init.lua')), 'auto init did not install feather/init.lua'); - assert(existsSync(join(autoProject, 'feather.config.lua')), 'auto init did not create feather.config.lua'); - assert( - read(join(autoProject, 'main.lua')).includes('FEATHER-INIT-BEGIN require'), - 'auto init did not patch main.lua', - ); - assert(read(join(autoProject, 'main.lua')).includes('USE_DEBUGGER'), 'auto init did not add USE_DEBUGGER guard'); - - log('doctor auto project'); - const autoDoctor = doctorJson(autoProject); - assert(autoDoctor.failures === 0, `doctor found blockers for auto project: ${JSON.stringify(autoDoctor, null, 2)}`); - assert( - autoDoctor.checks.some((check) => check.label === 'Embedded Feather runtime' && check.severity === 'pass'), - 'doctor did not detect embedded runtime', - ); - assert( - autoDoctor.checks.some((check) => check.label === 'USE_DEBUGGER guard' && check.severity === 'pass'), - 'doctor did not detect USE_DEBUGGER guard', - ); - - log('remove auto project'); - run(['remove', autoProject, '--yes']); - assert(!existsSync(join(autoProject, 'feather')), 'remove did not delete feather runtime'); - assert(!existsSync(join(autoProject, 'feather.config.lua')), 'remove did not delete managed feather.config.lua'); - assert(!read(join(autoProject, 'main.lua')).includes('FEATHER-INIT'), 'remove did not strip FEATHER-INIT markers'); - - log('init cli-mode project'); - run([ - 'init', - cliProject, - '--mode', - 'cli', - '--local-src', - localSrc, - '--install-dir', - 'feather', - '--no-plugins', - '--yes', - ]); - - assert(existsSync(join(cliProject, 'feather.config.lua')), 'cli init did not create feather.config.lua'); - assert(!existsSync(join(cliProject, 'feather')), 'cli mode should not install embedded runtime'); - - log('doctor cli-mode project'); - const cliDoctor = doctorJson(cliProject); - assert(cliDoctor.failures === 0, `doctor found blockers for cli-mode project: ${JSON.stringify(cliDoctor, null, 2)}`); - assert( - cliDoctor.checks.some((check) => check.label === 'Embedded Feather runtime' && check.severity === 'info'), - 'doctor should treat missing runtime as info in cli mode', - ); - - log('help commands'); - run(['--help']); - run(['doctor', '--help']); - run(['plugin', '--help']); - - log('package: loading registry'); - const registryPath = join(root, 'cli', 'dist', 'generated', 'registry.json'); - assert(existsSync(registryPath), 'cli/dist/generated/registry.json missing — run build first'); - const registry = JSON.parse(read(registryPath)); - - const topLevel = Object.entries(registry.packages).filter(([, e]) => !e.parent); - log(`package: ${topLevel.length} top-level packages to test`); - - for (const [pkgId, entry] of topLevel) { - log(`package install ${pkgId} @ ${entry.source.tag}`); - const pkgDir = mkdtempSync(join(tmpdir(), `feather-pkg-${pkgId}-`)); - - // install - run(['package', 'install', pkgId, '--offline', '--dir', pkgDir]); - - // every file must exist on disk - for (const file of entry.install.files) { - assert(existsSync(join(pkgDir, file.target)), `${pkgId}: ${file.target} not on disk after install`); - } - - // lockfile entry must be correct - const lock = JSON.parse(read(join(pkgDir, 'feather.lock.json'))); - assert(lock.packages[pkgId], `${pkgId}: missing lockfile entry`); - assert( - lock.packages[pkgId].version === entry.source.tag, - `${pkgId}: lockfile version ${lock.packages[pkgId].version} !== registry ${entry.source.tag}`, - ); - assert( - lock.packages[pkgId].trust === entry.trust, - `${pkgId}: lockfile trust ${lock.packages[pkgId].trust} !== registry ${entry.trust}`, - ); - - // audit must pass (--json gives clean stdout even when piped) - const auditOut = run(['package', 'audit', '--json', '--dir', pkgDir]); - const auditResults = JSON.parse(auditOut); - for (const r of auditResults) { - assert(r.status === 'verified', `${pkgId}: audit ${r.target} → ${r.status}`); - } - - // remove - run(['package', 'remove', pkgId, '--dir', pkgDir, '--yes']); - for (const file of entry.install.files) { - assert(!existsSync(join(pkgDir, file.target)), `${pkgId}: ${file.target} still on disk after remove`); - } - const lockAfter = JSON.parse(read(join(pkgDir, 'feather.lock.json'))); - assert(!lockAfter.packages[pkgId], `${pkgId}: lockfile entry remains after remove`); - - if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { - rmSync(pkgDir, { recursive: true, force: true }); - } - log(`package install ${pkgId} ✔`); - } - - log('passed'); -} finally { - if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { - rmSync(workspace, { recursive: true, force: true }); - } -} diff --git a/package.json b/package.json index 126b005e..e1a76ae1 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "scripts": { "cli:build": "npm run build --workspace=cli", "cli:dev": "npm run dev --workspace=cli", - "test:cli:e2e": "npm run cli:build && node cli/test/e2e.mjs && node --test cli/test/*.test.mjs", + "test:cli:e2e": "npm run cli:build && node --test cli/test/commands/*.test.mjs", "test:lua:e2e": "node scripts/lua-e2e.mjs", "test:app:e2e": "playwright test", "test:tauri:e2e": "cargo test --manifest-path src-tauri/Cargo.toml ws_server", From 63d18b4f9e289f940fa3d5b8ab636602e7def84d Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 19:54:26 -0400 Subject: [PATCH 51/68] cli: fix ios builds --- cli/src/lib/build/config.ts | 1 + cli/src/lib/build/debug-stage.ts | 15 --------------- cli/src/lib/build/ios.ts | 16 ++++++++++++++-- cli/src/lib/build/validation.ts | 1 + cli/test/commands/build-ios.test.mjs | 6 ++++++ cli/test/commands/build.test.mjs | 3 ++- src-lua/feather/core/debug_overlay.lua | 3 --- 7 files changed, 24 insertions(+), 21 deletions(-) diff --git a/cli/src/lib/build/config.ts b/cli/src/lib/build/config.ts index f3097507..5a7e51d5 100644 --- a/cli/src/lib/build/config.ts +++ b/cli/src/lib/build/config.ts @@ -40,6 +40,7 @@ export type IosBuildTargetConfig = { scheme?: string; configuration?: string; sdk?: string; + simulatorArch?: string; deploymentTarget?: string; derivedDataPath?: string; teamId?: string; diff --git a/cli/src/lib/build/debug-stage.ts b/cli/src/lib/build/debug-stage.ts index 89a6e712..fbc4d0fa 100644 --- a/cli/src/lib/build/debug-stage.ts +++ b/cli/src/lib/build/debug-stage.ts @@ -100,7 +100,6 @@ function generatedMobileConfig(name: string): string { ' wrapPrint = true,', ' autoRegisterErrorHandler = true,', ' captureScreenshot = false,', - ' iosTextRenderGuard = true,', ' __DANGEROUS_INSECURE_CONNECTION__ = true,', ' debugOverlay = {', ' enabled = true,', @@ -132,21 +131,7 @@ function mobileMainWrapper(): string { ' return config', 'end', '', - 'local function installIosTextRenderGuard(config)', - ' if type(config) ~= "table" then config = {} end', - ' if config.iosTextRendering == true or config.iosTextRenderGuard == false then return end', - ' if not love.system or not love.system.getOS or love.system.getOS() ~= "iOS" then return end', - ' if not love.graphics then return end', - ' local originalPrint = love.graphics.print', - ' local originalPrintf = love.graphics.printf', - ' love.graphics._featherOriginalPrint = originalPrint', - ' love.graphics._featherOriginalPrintf = originalPrintf', - ' love.graphics.print = function(...) end', - ' love.graphics.printf = function(...) end', - 'end', - '', 'FEATHER_AUTO_CONFIG = loadFeatherConfig()', - 'installIosTextRenderGuard(FEATHER_AUTO_CONFIG)', 'require("feather.auto")', '', 'local gameMain, err = love.filesystem.load(".feather-main.lua")', diff --git a/cli/src/lib/build/ios.ts b/cli/src/lib/build/ios.ts index 6214aa50..2ea54f84 100644 --- a/cli/src/lib/build/ios.ts +++ b/cli/src/lib/build/ios.ts @@ -47,6 +47,7 @@ export function buildIos(config: ResolvedBuildConfig, stageDir: string, options: } const cacheEnabled = !options.release && options.cache !== false; logNativeStep(options.log, `iOS template: ${loveIosDir}`); + const sdk = iosConfig.sdk ?? 'iphonesimulator'; const workspace = createNativeWorkspace('feather-ios-', loveIosDir, 'love-ios', { enabled: cacheEnabled, target: 'ios', @@ -58,7 +59,8 @@ export function buildIos(config: ResolvedBuildConfig, stageDir: string, options: displayName: iosConfig.displayName ?? config.name, scheme: iosConfig.scheme ?? 'love-ios', configuration: iosConfiguration(config), - sdk: iosConfig.sdk ?? 'iphonesimulator', + sdk, + simulatorArch: sdk.startsWith('iphonesimulator') ? iosSimulatorArch(config) : undefined, deploymentTarget: iosConfig.deploymentTarget ?? '12.0', teamId: iosConfig.teamId, gameLoveResourcePatch: 4, @@ -246,7 +248,14 @@ function maybeAdHocCodesign(appPath: string, log?: NativeBuildLogger): void { } function iosConfiguration(config: ResolvedBuildConfig): string { - return config.targets.ios?.configuration ?? 'Release'; + const iosConfig = config.targets.ios ?? {}; + if (iosConfig.configuration) return iosConfig.configuration; + const sdk = iosConfig.sdk ?? 'iphonesimulator'; + return sdk.startsWith('iphonesimulator') ? 'Debug' : 'Release'; +} + +function iosSimulatorArch(config: ResolvedBuildConfig): string { + return config.targets.ios?.simulatorArch ?? 'x86_64'; } function iosSdkBuildFolder(sdk: string): string { @@ -407,6 +416,9 @@ export function xcodebuildArgs(config: ResolvedBuildConfig, xcodeProject: string `IPHONEOS_DEPLOYMENT_TARGET=${iosConfig.deploymentTarget ?? '12.0'}`, 'OTHER_CFLAGS=-Wno-everything', ]; + if (sdk.startsWith('iphonesimulator')) { + args.push(`ARCHS=${iosSimulatorArch(config)}`, 'ONLY_ACTIVE_ARCH=NO'); + } if (iosConfig.teamId) args.push(`DEVELOPMENT_TEAM=${iosConfig.teamId}`); if (sdk.startsWith('iphonesimulator') && !iosConfig.teamId) { args.push('CODE_SIGN_IDENTITY=', 'CODE_SIGNING_REQUIRED=NO', 'CODE_SIGNING_ALLOWED=NO'); diff --git a/cli/src/lib/build/validation.ts b/cli/src/lib/build/validation.ts index 43b774d6..2ee9ccbe 100644 --- a/cli/src/lib/build/validation.ts +++ b/cli/src/lib/build/validation.ts @@ -170,6 +170,7 @@ export function validateIosBuildConfig(config: ResolvedBuildConfig, release = fa ['targets.ios.scheme', ios.scheme], ['targets.ios.configuration', ios.configuration], ['targets.ios.sdk', ios.sdk], + ['targets.ios.simulatorArch', ios.simulatorArch], ['targets.ios.deploymentTarget', ios.deploymentTarget], ] as const) { if (value !== undefined && (!isNonEmptySingleLine(value) || !XCODE_VALUE_RE.test(value))) { diff --git a/cli/test/commands/build-ios.test.mjs b/cli/test/commands/build-ios.test.mjs index 9acab067..620b0d24 100644 --- a/cli/test/commands/build-ios.test.mjs +++ b/cli/test/commands/build-ios.test.mjs @@ -135,6 +135,8 @@ process.exit(0); assert.ok(record.argv.includes(join(dir, 'builds', 'ios-derived-data'))); assert.ok(record.argv.includes('PRODUCT_BUNDLE_IDENTIFIER=com.example.iosgame')); assert.ok(record.argv.includes('INFOPLIST_KEY_CFBundleDisplayName=iOS Game Dev')); + assert.ok(record.argv.includes('ARCHS=x86_64')); + assert.ok(record.argv.includes('ONLY_ACTIVE_ARCH=NO')); assert.ok(record.argv.includes('DEVELOPMENT_TEAM=ABC123XYZ')); assert.equal(record.gameLoveExists, true); assert.equal(record.projectContainsGameLove, true); @@ -208,6 +210,10 @@ process.exit(0); assert.equal(records.length, 2); assert.equal(records[0].cwd, records[1].cwd); assert.equal(records[0].derivedData, records[1].derivedData); + assert.ok(records[0].argv.includes('Debug')); + assert.ok(records[1].argv.includes('Debug')); + assert.ok(records[0].argv.includes('ARCHS=x86_64')); + assert.ok(records[1].argv.includes('ARCHS=x86_64')); assert.ok(records[0].cwd.includes(join('builds', '.feather-cache', 'ios'))); assert.ok(records[0].derivedData.includes(join('builds', '.feather-cache', 'ios'))); assert.equal(records[0].gameLoveExists, true); diff --git a/cli/test/commands/build.test.mjs b/cli/test/commands/build.test.mjs index 74a0cc4f..f7387122 100644 --- a/cli/test/commands/build.test.mjs +++ b/cli/test/commands/build.test.mjs @@ -145,11 +145,12 @@ test('build validation: rejects bad mobile config values and unsafe native paths const iosIssues = validateIosBuildConfig({ ...baseConfig, - targets: { ios: { bundleIdentifier: 'bad id', scheme: 'bad;', derivedDataPath: '../escape' } }, + targets: { ios: { bundleIdentifier: 'bad id', scheme: 'bad;', simulatorArch: 'bad;', derivedDataPath: '../escape' } }, }); assert.deepEqual(iosIssues.map((issue) => issue.field), [ 'bundleIdentifier', 'targets.ios.scheme', + 'targets.ios.simulatorArch', 'targets.ios.derivedDataPath', ]); const iosReleaseIssues = validateIosBuildConfig({ diff --git a/src-lua/feather/core/debug_overlay.lua b/src-lua/feather/core/debug_overlay.lua index 5caede9f..454ef3d9 100644 --- a/src-lua/feather/core/debug_overlay.lua +++ b/src-lua/feather/core/debug_overlay.lua @@ -31,9 +31,6 @@ function DebugOverlay:init(feather, config) self.touchToggle = withDefault(config.touchToggle, DEFAULTS.touchToggle) self.corner = config.corner or DEFAULTS.corner self.text = withDefault(config.text, DEFAULTS.text) - if love and love.system and love.system.getOS and love.system.getOS() == "iOS" and config.text == nil then - self.text = false - end self._lastTouchTime = 0 self._doubleTapWindow = config.doubleTapWindow or 0.35 self._touchSize = config.touchSize or 96 From 779578a75ba6137a1f7b25d486011b40b01e4513 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 19:58:14 -0400 Subject: [PATCH 52/68] cli: fix ios error messages --- cli/src/lib/build/ios.ts | 12 ++++++++++- cli/test/commands/build-ios.test.mjs | 32 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/cli/src/lib/build/ios.ts b/cli/src/lib/build/ios.ts index 2ea54f84..d503b73e 100644 --- a/cli/src/lib/build/ios.ts +++ b/cli/src/lib/build/ios.ts @@ -16,6 +16,8 @@ import { } from './native.js'; import { iosBundleIdentifier } from './validation.js'; +const XCODEBUILD_MAX_BUFFER = 64 * 1024 * 1024; + export type IosBuildModeOptions = { release?: boolean; cache?: boolean; @@ -476,9 +478,17 @@ function runXcodebuild(args: string[], workDir: string, log?: NativeBuildLogger) cwd: workDir, encoding: streamOutput ? undefined : 'utf8', stdio: streamOutput ? 'inherit' : 'pipe', + maxBuffer: XCODEBUILD_MAX_BUFFER, }); if (!streamOutput) logNativeOutput(log, result.stdout, result.stderr); - if (result.error) throw new Error('xcodebuild not found. Run `feather doctor --build-target ios`.'); + if (result.error) { + const err = result.error as Error & { code?: string }; + if (err.code === 'ENOENT') { + throw new Error('xcodebuild not found. Run `feather doctor --build-target ios`.'); + } + const output = spawnOutput(result); + throw new Error([`xcodebuild failed to run: ${err.message}`, output].filter(Boolean).join('\n')); + } if (result.status !== 0) { throw new Error((result.stderr || result.stdout || `xcodebuild failed with exit code ${result.status ?? 'unknown'}`).toString().trim()); } diff --git a/cli/test/commands/build-ios.test.mjs b/cli/test/commands/build-ios.test.mjs index 620b0d24..71493a30 100644 --- a/cli/test/commands/build-ios.test.mjs +++ b/cli/test/commands/build-ios.test.mjs @@ -220,6 +220,38 @@ process.exit(0); assert.equal(records[1].gameLoveExists, true); }); +test('build ios: buffers noisy xcodebuild output in non-verbose mode', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveIos(dir); + writeBuildConfig(dir, { + name: 'Noisy iOS', + version: '1.0.0', + targets: { ios: { loveIosDir: 'love-ios', bundleIdentifier: 'com.example.noisyios' } }, + }); + const { binDir } = writeFakeCommand(dir, 'xcodebuild', ` +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +const derivedData = args[args.indexOf('-derivedDataPath') + 1]; +const configuration = args[args.indexOf('-configuration') + 1] || 'Release'; +const sdk = args[args.indexOf('-sdk') + 1] || 'iphonesimulator'; +const sdkFolder = sdk.startsWith('iphoneos') ? 'iphoneos' : 'iphonesimulator'; +const app = path.join(derivedData, 'Build', 'Products', configuration + '-' + sdkFolder, 'love-ios.app'); +fs.mkdirSync(app, { recursive: true }); +fs.writeFileSync(path.join(app, 'Info.plist'), 'fake app'); +process.stdout.write('x'.repeat(2 * 1024 * 1024)); +process.exit(0); +`); + + const result = run(['build', 'ios', '--dir', dir, '--no-cache', '--json'], { + env: envWithPath(binDir, { FEATHER_TEST_ALLOW_IOS_BUILD: '1' }), + }); + + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(JSON.parse(result.stdout).ok, true); +}); + test('build ios --verbose: shows native build steps and xcodebuild output', () => { const dir = makeTmp(); writeGame(dir); From 832efd2d0c0c10df5c3065ea35364db2989abff2 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 21:32:43 -0400 Subject: [PATCH 53/68] cli: add desktop platforms builds --- cli/src/commands/doctor/index.ts | 100 ++++++++-- cli/src/index.ts | 7 +- cli/src/lib/build/build.ts | 33 +++- cli/src/lib/build/config.ts | 16 +- cli/src/lib/build/desktop.ts | 238 ++++++++++++++++++++++-- cli/src/lib/build/files.ts | 10 +- cli/src/lib/build/vendor.ts | 207 ++++++++++++++++++--- cli/test/commands/build-vendor.test.mjs | 92 ++++++++- cli/test/commands/build.test.mjs | 80 ++++++-- cli/test/commands/doctor.test.mjs | 41 ++++ cli/test/commands/helpers.mjs | 167 +++++++++++++++++ cli/test/commands/upload.test.mjs | 47 +++++ 12 files changed, 939 insertions(+), 99 deletions(-) diff --git a/cli/src/commands/doctor/index.ts b/cli/src/commands/doctor/index.ts index c4133564..7c6df6b7 100644 --- a/cli/src/commands/doctor/index.ts +++ b/cli/src/commands/doctor/index.ts @@ -345,25 +345,17 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) 'Set targets.ios.release.exportOptionsPlist or exportMethod for App Store/TestFlight exports.', ); } - } else if (isSupportedBuildTarget(opts.buildTarget)) { - const loveRelease = commandVersion('love-release', ['--version']); - add( - checks, - 'Build', - 'love-release', - loveRelease ? 'pass' : 'fail', - loveRelease ? loveRelease : 'not found', - loveRelease ? undefined : 'Install with `luarocks install love-release` and make sure love-release is on PATH.', - ); - const luarocks = commandVersion('luarocks', ['--version']); + } else if (opts.buildTarget === 'love') { add( checks, 'Build', - 'LuaRocks', - luarocks ? 'pass' : 'warn', - luarocks ? luarocks : 'not found', - luarocks ? undefined : 'Install LuaRocks if you need to install love-release locally.', + 'LÖVE package', + 'pass', + '.love archive', + 'Run it with LÖVE or build a platform target for a bundled runtime.', ); + } else if (isSupportedBuildTarget(opts.buildTarget)) { + addDesktopBuildChecks(checks, projectDir, buildConfig, opts.buildTarget); } } @@ -937,6 +929,84 @@ function androidReleaseSigningSeverity(buildConfig: ReturnType, + target: string, +): void { + if (!isDoctorDesktopBuildTarget(target)) return; + + const configuredPath = target === 'steamos' + ? buildConfig.targets.steamos?.loveRuntimeDir ?? buildConfig.targets.linux?.loveRuntimeDir + : buildConfig.targets[target]?.loveRuntimeDir; + const runtimeDir = configuredPath ? resolve(projectDir, configuredPath) : ''; + const runtimeReady = Boolean(runtimeDir && desktopRuntimeReady(runtimeDir, target)); + const configField = target === 'steamos' + ? 'targets.steamos.loveRuntimeDir or targets.linux.loveRuntimeDir' + : `targets.${target}.loveRuntimeDir`; + add( + checks, + 'Build', + `${desktopTargetLabel(target)} LÖVE runtime`, + runtimeReady ? 'pass' : 'fail', + configuredPath ?? 'not configured', + runtimeReady ? undefined : `Run \`feather build vendor add ${target} --dir ${projectDir}\` or set ${configField} in feather.build.json.`, + ); + + if (target === 'windows') { + addToolCheck(checks, 'NSIS makensis', 'makensis', ['/VERSION'], 'Install NSIS and make sure makensis is on PATH.'); + return; + } + if (target === 'macos') { + addToolCheck(checks, 'plutil', 'plutil', ['-help'], 'Use macOS or install Xcode command line tools.'); + addToolCheck(checks, 'hdiutil', 'hdiutil', ['help'], 'Use macOS for DMG packaging.'); + return; + } + + const appImageTool = runtimeDir ? join(runtimeDir, 'appimagetool.AppImage') : ''; + add( + checks, + 'Build', + 'appimagetool', + appImageTool && existsSync(appImageTool) ? 'pass' : 'fail', + appImageTool || 'not found', + appImageTool && existsSync(appImageTool) ? undefined : `Run \`feather build vendor add ${target} --dir ${projectDir}\`.`, + ); + addToolCheck(checks, 'tar', 'tar', ['--version'], 'Install tar or use a shell with standard archive tools available.'); +} + +function desktopRuntimeReady(runtimeDir: string, target: DoctorDesktopBuildTarget): boolean { + if (target === 'windows') return existsSync(join(runtimeDir, 'love.exe')); + if (target === 'macos') return existsSync(join(runtimeDir, 'love.app', 'Contents', 'Info.plist')); + return existsSync(join(runtimeDir, 'squashfs-root', 'bin', 'love')) && existsSync(join(runtimeDir, 'appimagetool.AppImage')); +} + +function desktopTargetLabel(target: DoctorDesktopBuildTarget): string { + if (target === 'macos') return 'macOS'; + if (target === 'steamos') return 'SteamOS'; + return target[0].toUpperCase() + target.slice(1); +} + +function addToolCheck(checks: DoctorCheck[], label: string, command: string, args: string[], fix: string): void { + const version = commandVersion(command, args); + add( + checks, + 'Build', + label, + version ? 'pass' : 'fail', + version ?? 'not found', + version ? undefined : fix, + ); +} + function androidReleaseSigningDetail(buildConfig: ReturnType): string { const release = buildConfig.targets.android?.release; if (!release) return 'not configured'; diff --git a/cli/src/index.ts b/cli/src/index.ts index 50500d56..05e198b7 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -8,6 +8,7 @@ import { doctorCommand } from './commands/doctor.js'; import { updateCommand } from './commands/update.js'; import { buildCommand } from './commands/build.js'; import { buildVendorAddCommand, buildVendorListCommand } from './commands/build-vendor.js'; +import { buildTargets } from './lib/build/config.js'; import { uploadCommand } from './commands/upload.js'; import { pluginListCommand, @@ -160,7 +161,7 @@ program const build = program .command('build') - .description('Build a LÖVE game for web, mobile dev, or desktop targets'); + .description('Build a LÖVE game package, web bundle, mobile dev app, or desktop installer'); function addBuildTargetCommand(target: string): void { build @@ -223,7 +224,7 @@ function runtimeConfigOption( return runtimeConfig ?? configPath ?? (looksLikeRuntimeConfig(config) ? config : undefined); } -for (const target of ['web', 'android', 'ios', 'windows', 'macos', 'linux', 'steamos']) { +for (const target of buildTargets) { addBuildTargetCommand(target); } @@ -233,7 +234,7 @@ const buildVendor = build buildVendor .command('add [targets...]') - .description('Fetch build vendors: web, android, ios, mobile, or all') + .description('Fetch build vendors: web, android, ios, mobile, desktop, or all') .allowUnknownOption() .option('--dir ', 'Project directory (default: current directory)') .option('--config ', 'Path to feather.build.json') diff --git a/cli/src/lib/build/build.ts b/cli/src/lib/build/build.ts index 79454011..a66e8099 100644 --- a/cli/src/lib/build/build.ts +++ b/cli/src/lib/build/build.ts @@ -23,7 +23,7 @@ import { import { buildWeb } from './web.js'; import { buildAndroid } from './android.js'; import { buildIos } from './ios.js'; -import { buildDesktop } from './desktop.js'; +import { buildDesktop, buildLove } from './desktop.js'; import { assertBuildConfigValidForTarget } from './validation.js'; import type { NativeBuildLogger, NativeCacheInfo } from './native.js'; import { embedMobileDebuggerStage } from './debug-stage.js'; @@ -148,7 +148,9 @@ export function runBuild(options: BuildOptions): BuildResult { } else if (debuggerEmbedBuild) { log?.('Feather debugger embedding: disabled'); } - const artifacts = options.target === 'web' + const artifacts = options.target === 'love' + ? buildLove(config, staged.dir) + : options.target === 'web' ? buildWeb(config, staged.dir) : options.target === 'android' ? buildAndroid(config, staged.dir, { @@ -206,6 +208,9 @@ function assertReleaseTargetSupported(target: BuildTarget, release: boolean): vo function plannedArtifacts(config: ResolvedBuildConfig, target: BuildTarget, release = false): BuildArtifact[] { const base = artifactBaseName(config); + if (target === 'love') { + return [{ target, type: 'love', path: join(config.outDir, `${base}.love`) }]; + } if (target === 'web') { return [ { target, type: 'love', path: join(config.outDir, `${base}.love`) }, @@ -239,14 +244,34 @@ function plannedArtifacts(config: ResolvedBuildConfig, target: BuildTarget, rele { target, type: 'app', path: join(config.outDir, `${base}-ios.app`) }, ]; } + if (target === 'windows') { + return [ + { target, type: 'love', path: join(config.outDir, `${base}.love`) }, + { target, type: 'zip', path: join(config.outDir, `${base}-windows.zip`) }, + { target, type: 'installer', path: join(config.outDir, `${base}-windows-installer.exe`) }, + ]; + } + if (target === 'macos') { + return [ + { target, type: 'love', path: join(config.outDir, `${base}.love`) }, + { target, type: 'zip', path: join(config.outDir, `${base}-macos.app.zip`) }, + { target, type: 'dmg', path: join(config.outDir, `${base}-macos.dmg`) }, + ]; + } + if (target === 'linux' || target === 'steamos') { + return [ + { target, type: 'love', path: join(config.outDir, `${base}.love`) }, + { target, type: 'appimage', path: join(config.outDir, `${base}-${target}.AppImage`) }, + { target, type: 'tar.gz', path: join(config.outDir, `${base}-${target}.tar.gz`) }, + ]; + } return [ { target, type: 'love', path: join(config.outDir, `${base}.love`) }, - { target, type: 'external', path: config.outDir }, ]; } export function describeArtifact(artifact: BuildArtifact): string { - const size = existsSync(artifact.path) && artifact.type !== 'html' && artifact.type !== 'external' + const size = existsSync(artifact.path) && artifact.type !== 'html' ? ` (${fileSize(artifact.path)} bytes)` : ''; return `${artifact.type}: ${artifact.path}${size}`; diff --git a/cli/src/lib/build/config.ts b/cli/src/lib/build/config.ts index 5a7e51d5..c3a0a4ee 100644 --- a/cli/src/lib/build/config.ts +++ b/cli/src/lib/build/config.ts @@ -2,8 +2,8 @@ import { accessSync, constants, existsSync, readFileSync } from 'node:fs'; import { basename, dirname, join, resolve } from 'node:path'; import { assertNoSymlinkEscape, assertSafeRelativePath, isPathInside } from '../path-safety.js'; -export const buildTargets = ['web', 'android', 'ios', 'windows', 'macos', 'linux', 'steamos'] as const; -export const supportedBuildTargets = ['web', 'android', 'ios', 'windows', 'macos', 'linux', 'steamos'] as const; +export const buildTargets = ['love', 'web', 'android', 'ios', 'windows', 'macos', 'linux', 'steamos'] as const; +export const supportedBuildTargets = ['love', 'web', 'android', 'ios', 'windows', 'macos', 'linux', 'steamos'] as const; export const uploadTargets = ['itch', 'steam'] as const; export type BuildTarget = typeof buildTargets[number]; @@ -57,6 +57,10 @@ export type IosBuildTargetConfig = { }; }; +export type DesktopRuntimeBuildTargetConfig = { + loveRuntimeDir?: string; +}; + export type FeatherBuildConfig = { name?: string; version?: string; @@ -78,10 +82,10 @@ export type FeatherBuildConfig = { title?: string; outputName?: string; }; - windows?: Record; - macos?: Record; - linux?: Record; - steamos?: Record; + windows?: DesktopRuntimeBuildTargetConfig; + macos?: DesktopRuntimeBuildTargetConfig; + linux?: DesktopRuntimeBuildTargetConfig; + steamos?: DesktopRuntimeBuildTargetConfig; android?: AndroidBuildTargetConfig; ios?: IosBuildTargetConfig; }; diff --git a/cli/src/lib/build/desktop.ts b/cli/src/lib/build/desktop.ts index 4557b38d..1be4ec06 100644 --- a/cli/src/lib/build/desktop.ts +++ b/cli/src/lib/build/desktop.ts @@ -1,31 +1,233 @@ import { spawnSync } from 'node:child_process'; -import { artifactBaseName, writeLoveArchive, type BuildArtifact } from './files.js'; +import { + appendFileSync, + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { + artifactBaseName, + buildSlug, + copyDirectory, + removePath, + writeDirectoryZip, + writeLoveArchive, + type BuildArtifact, +} from './files.js'; import type { ResolvedBuildConfig, SupportedBuildTarget } from './config.js'; -export type DesktopBuildTarget = Exclude; +export type DesktopBuildTarget = Exclude; + +export function buildLove(config: ResolvedBuildConfig, stageDir: string): BuildArtifact[] { + const lovePath = writeLoveArchive(stageDir, config.outDir, artifactBaseName(config)); + return [{ target: 'love', type: 'love', path: lovePath }]; +} export function buildDesktop(config: ResolvedBuildConfig, target: DesktopBuildTarget, stageDir: string): BuildArtifact[] { - const normalizedTarget = target === 'steamos' ? 'linux' : target; const base = artifactBaseName(config); const lovePath = writeLoveArchive(stageDir, config.outDir, base); - const args = [ - '--output', - config.outDir, - '--name', - config.name, - '--version', - config.version, - '--target', - normalizedTarget, - stageDir, + if (target === 'windows') return buildWindows(config, lovePath, base); + if (target === 'macos') return buildMacos(config, lovePath, base); + return buildLinuxLike(config, target, lovePath, base); +} + +function buildWindows(config: ResolvedBuildConfig, lovePath: string, base: string): BuildArtifact[] { + const runtimeDir = desktopRuntimeDir(config, 'windows'); + const sourceExe = join(runtimeDir, 'love.exe'); + if (!existsSync(sourceExe)) { + throw new Error(`Windows LÖVE runtime is incomplete at ${runtimeDir}. Run \`feather build vendor add windows --dir ${config.projectDir}\`.`); + } + + const workDir = mkdtempSync(join(tmpdir(), 'feather-windows-')); + const appSlug = buildSlug(config.name); + const bundleDir = join(workDir, appSlug); + const zipPath = join(config.outDir, `${base}-windows.zip`); + const installerPath = join(config.outDir, `${base}-windows-installer.exe`); + try { + copyDirectory(runtimeDir, bundleDir); + const appExe = join(bundleDir, `${appSlug}.exe`); + renameSync(join(bundleDir, 'love.exe'), appExe); + appendFileSync(appExe, readFileSync(lovePath)); + writeDirectoryZip(bundleDir, zipPath); + writeWindowsInstallerScript(join(workDir, 'installer.nsi'), config, bundleDir, installerPath); + runTool('makensis', [join(workDir, 'installer.nsi')], workDir, 'makensis not found. Run `feather doctor --build-target windows`.'); + } finally { + removePath(workDir); + } + + return [ + { target: 'windows', type: 'love', path: lovePath }, + { target: 'windows', type: 'zip', path: zipPath }, + { target: 'windows', type: 'installer', path: installerPath }, ]; - const result = spawnSync('love-release', args, { encoding: 'utf8' }); - if (result.error) throw new Error(`love-release not found. Run \`feather doctor --build-target ${target}\`.`); - if (result.status !== 0) { - throw new Error((result.stderr || result.stdout || `love-release failed for ${target}`).trim()); +} + +function buildMacos(config: ResolvedBuildConfig, lovePath: string, base: string): BuildArtifact[] { + const runtimeDir = desktopRuntimeDir(config, 'macos'); + const runtimeApp = join(runtimeDir, 'love.app'); + if (!existsSync(runtimeApp)) { + throw new Error(`macOS LÖVE runtime is incomplete at ${runtimeDir}. Run \`feather build vendor add macos --dir ${config.projectDir}\`.`); + } + + const workDir = mkdtempSync(join(tmpdir(), 'feather-macos-')); + const appSlug = buildSlug(config.name); + const appBundle = join(workDir, `${appSlug}.app`); + const zipPath = join(config.outDir, `${base}-macos.app.zip`); + const dmgPath = join(config.outDir, `${base}-macos.dmg`); + try { + copyDirectory(runtimeApp, appBundle); + const resourcesDir = join(appBundle, 'Contents', 'Resources'); + mkdirSync(resourcesDir, { recursive: true }); + writeFileSync(join(resourcesDir, 'game.love'), readFileSync(lovePath)); + patchMacosPlist(config, join(appBundle, 'Contents', 'Info.plist'), appSlug); + const ditto = spawnSync('ditto', ['-c', '-k', '--keepParent', appBundle, zipPath], { encoding: 'utf8' }); + if (ditto.error) { + writeDirectoryZip(appBundle, zipPath); + } else if (ditto.status !== 0) { + throw new Error((ditto.stderr || ditto.stdout || 'ditto failed').trim()); + } + runTool('hdiutil', ['create', '-volname', appSlug, '-srcfolder', appBundle, '-ov', '-format', 'UDZO', dmgPath], workDir, 'hdiutil not found. Run `feather doctor --build-target macos`.'); + } finally { + removePath(workDir); + } + + return [ + { target: 'macos', type: 'love', path: lovePath }, + { target: 'macos', type: 'zip', path: zipPath }, + { target: 'macos', type: 'dmg', path: dmgPath }, + ]; +} + +function buildLinuxLike(config: ResolvedBuildConfig, target: 'linux' | 'steamos', lovePath: string, base: string): BuildArtifact[] { + const runtimeDir = desktopRuntimeDir(config, target); + const runtimeRoot = join(runtimeDir, 'squashfs-root'); + const appImageTool = join(runtimeDir, 'appimagetool.AppImage'); + if (!existsSync(join(runtimeRoot, 'bin', 'love')) || !existsSync(appImageTool)) { + throw new Error(`${target} LÖVE runtime is incomplete at ${runtimeDir}. Run \`feather build vendor add ${target} --dir ${config.projectDir}\`.`); + } + + const workDir = mkdtempSync(join(tmpdir(), `feather-${target}-`)); + const appSlug = buildSlug(config.name); + const appDir = join(workDir, 'squashfs-root'); + const appImagePath = join(config.outDir, `${base}-${target}.AppImage`); + const tarPath = join(config.outDir, `${base}-${target}.tar.gz`); + try { + copyDirectory(runtimeRoot, appDir); + patchLinuxRuntime(appDir, appSlug, config.description); + const appBinary = join(appDir, 'bin', appSlug); + appendFileSync(appBinary, readFileSync(lovePath)); + chmodSync(appBinary, 0o755); + chmodSync(appImageTool, 0o755); + runTool(appImageTool, [appDir, appImagePath], workDir, `appimagetool failed. Run \`feather doctor --build-target ${target}\`.`); + runTool('tar', ['-czf', tarPath, '-C', appDir, '.'], workDir, `tar not found. Run \`feather doctor --build-target ${target}\`.`); + } finally { + removePath(workDir); } + return [ { target, type: 'love', path: lovePath }, - { target, type: 'external', path: config.outDir }, + { target, type: 'appimage', path: appImagePath }, + { target, type: 'tar.gz', path: tarPath }, ]; } + +function desktopRuntimeDir(config: ResolvedBuildConfig, target: DesktopBuildTarget): string { + const configured = target === 'steamos' + ? config.targets.steamos?.loveRuntimeDir ?? config.targets.linux?.loveRuntimeDir + : config.targets[target]?.loveRuntimeDir; + if (!configured) { + const field = target === 'steamos' ? 'targets.steamos.loveRuntimeDir or targets.linux.loveRuntimeDir' : `targets.${target}.loveRuntimeDir`; + throw new Error(`${target} build requires ${field} in feather.build.json. Run \`feather build vendor add ${target} --dir ${config.projectDir}\`.`); + } + const runtimeDir = resolve(config.projectDir, configured); + if (!existsSync(runtimeDir)) { + throw new Error(`${target} LÖVE runtime not found at ${runtimeDir}. Run \`feather build vendor add ${target} --dir ${config.projectDir}\`.`); + } + return runtimeDir; +} + +function patchLinuxRuntime(appDir: string, appSlug: string, description: string | undefined): void { + const loveBin = join(appDir, 'bin', 'love'); + const appBin = join(appDir, 'bin', appSlug); + mkdirSync(dirname(appBin), { recursive: true }); + renameSync(loveBin, appBin); + const appRun = join(appDir, 'AppRun'); + if (existsSync(appRun)) { + writeFileSync(appRun, readFileSync(appRun, 'utf8').replace(/bin\/love/g, `bin/${appSlug}`)); + chmodSync(appRun, 0o755); + } + const desktop = [ + '[Desktop Entry]', + `Name=${appSlug}`, + `Comment=${description ?? appSlug}`, + 'Type=Application', + 'Categories=Game;', + `Exec=${appSlug} %f`, + `Icon=${appSlug}`, + 'Terminal=false', + 'NoDisplay=false', + '', + ].join('\n'); + writeFileSync(join(appDir, `${appSlug}.desktop`), desktop); + mkdirSync(join(appDir, 'share', 'applications'), { recursive: true }); + writeFileSync(join(appDir, 'share', 'applications', `${appSlug}.desktop`), desktop); +} + +function patchMacosPlist(config: ResolvedBuildConfig, plistPath: string, appSlug: string): void { + if (!existsSync(plistPath)) { + throw new Error(`macOS LÖVE runtime is missing Contents/Info.plist. Run \`feather build vendor add macos --dir ${config.projectDir} --force\`.`); + } + const bundleId = config.productId ?? `org.feather.${appSlug.replace(/[^a-z0-9]+/g, '.')}.macos`; + for (const [key, value] of [ + ['CFBundleIdentifier', bundleId], + ['CFBundleName', config.name], + ['CFBundleDisplayName', config.name], + ['CFBundleShortVersionString', config.version], + ['CFBundleVersion', config.version], + ['NSHumanReadableCopyright', config.copyright ?? ''], + ] as const) { + runTool('plutil', ['-replace', key, '-string', value, plistPath], dirname(plistPath), 'plutil not found. Run `feather doctor --build-target macos`.'); + } +} + +function writeWindowsInstallerScript(path: string, config: ResolvedBuildConfig, bundleDir: string, installerPath: string): void { + const appSlug = buildSlug(config.name); + const sourceGlob = `${bundleDir.replace(/\\/g, '/')}/*`; + const script = [ + `Name "${nsis(config.name)}"`, + `OutFile "${nsis(installerPath)}"`, + `InstallDir "$LOCALAPPDATA\\${nsis(config.company ?? 'Feather')}\\${nsis(config.name)}"`, + 'RequestExecutionLevel user', + 'Section', + ' SetOutPath "$INSTDIR"', + ` File /r "${nsis(sourceGlob)}"`, + ` CreateShortcut "$SMPROGRAMS\\${nsis(config.name)}.lnk" "$INSTDIR\\${nsis(appSlug)}.exe"`, + ' WriteUninstaller "$INSTDIR\\uninstall.exe"', + 'SectionEnd', + 'Section "Uninstall"', + ' Delete "$INSTDIR\\*.*"', + ' RMDir "$INSTDIR"', + 'SectionEnd', + '', + ].join('\n'); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, script); +} + +function nsis(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function runTool(command: string, args: string[], cwd: string, missingMessage: string): void { + const result = spawnSync(command, args, { cwd, encoding: 'utf8' }); + if (result.error) throw new Error(missingMessage); + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || `${command} failed`).trim()); + } +} diff --git a/cli/src/lib/build/files.ts b/cli/src/lib/build/files.ts index 3a430903..e32a1e27 100644 --- a/cli/src/lib/build/files.ts +++ b/cli/src/lib/build/files.ts @@ -173,10 +173,12 @@ export function fileSize(path: string): number { export function artifactForTarget(manifest: BuildManifest, target: string): BuildArtifact | null { const artifacts = manifest.artifacts.filter((artifact) => artifact.target === target && artifact.type !== 'metadata'); - return artifacts.find((artifact) => artifact.type === 'zip') - ?? artifacts.find((artifact) => artifact.type === 'external') - ?? artifacts.find((artifact) => artifact.type === 'love') - ?? null; + const preferredTypes = ['dmg', 'installer', 'appimage', 'zip', 'tar.gz', 'ipa', 'aab', 'apk', 'app', 'html', 'external', 'love']; + for (const type of preferredTypes) { + const artifact = artifacts.find((candidate) => candidate.type === type); + if (artifact) return artifact; + } + return artifacts[0] ?? null; } export function resolveArtifactPath(path: string): string { diff --git a/cli/src/lib/build/vendor.ts b/cli/src/lib/build/vendor.ts index 98e41bf1..e746f8ba 100644 --- a/cli/src/lib/build/vendor.ts +++ b/cli/src/lib/build/vendor.ts @@ -1,5 +1,7 @@ import { spawnSync } from 'node:child_process'; import { + chmodSync, + cpSync, existsSync, mkdirSync, readFileSync, @@ -16,9 +18,9 @@ import { import { removePath } from './files.js'; import { assertNoSymlinkEscape, assertSafeRelativePath, isPathInside } from '../path-safety.js'; -export const buildVendorTargets = ['web', 'android', 'ios', 'mobile', 'all'] as const; +export const buildVendorTargets = ['web', 'android', 'ios', 'mobile', 'desktop', 'windows', 'macos', 'linux', 'steamos', 'all'] as const; export type BuildVendorTargetInput = typeof buildVendorTargets[number]; -export type ConcreteBuildVendorTarget = 'web' | 'android' | 'ios'; +export type ConcreteBuildVendorTarget = 'web' | 'android' | 'ios' | 'windows' | 'macos' | 'linux' | 'steamos'; export type BuildVendorAddOptions = { projectDir?: string; @@ -82,6 +84,8 @@ const DEFAULT_LOVE_JS_REF = 'main'; const LOVE_JS_REPO = 'https://github.com/2dengine/love.js'; const LOVE_ANDROID_REPO = 'https://github.com/love2d/love-android'; const LOVE_REPO = 'https://github.com/love2d/love'; +const LOVE_RELEASE_BASE = 'https://github.com/love2d/love/releases/download'; +const APPIMAGETOOL_URL = 'https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage'; export function isBuildVendorTarget(value: string): value is BuildVendorTargetInput { return (buildVendorTargets as readonly string[]).includes(value); @@ -89,7 +93,7 @@ export function isBuildVendorTarget(value: string): value is BuildVendorTargetIn export async function addBuildVendors(targets: BuildVendorTargetInput[], options: BuildVendorAddOptions = {}): Promise { const projectDir = resolve(options.projectDir ?? process.cwd()); - const raw = readBuildConfig(projectDir, options.configPath); + let raw = readBuildConfig(projectDir, options.configPath); const configPath = buildConfigPath(projectDir, options.configPath); const config = loadBuildConfig({ projectDir, configPath: options.configPath }); const loveVersion = sanitizeRef(options.ref ?? config.loveVersion ?? DEFAULT_LOVE_VERSION, 'LÖVE version'); @@ -111,6 +115,9 @@ export async function addBuildVendors(targets: BuildVendorTargetInput[], options updateConfig: options.updateConfig !== false, }); results.push(result); + if (result.configUpdated) { + raw = readBuildConfig(projectDir, options.configPath); + } } return { @@ -127,7 +134,7 @@ export function listBuildVendors(options: BuildVendorListOptions = {}): BuildVen const raw = readBuildConfig(projectDir, options.configPath); const configPath = buildConfigPath(projectDir, options.configPath); const vendorDir = resolveVendorDir(projectDir, options.vendorDir ?? 'vendor'); - const vendors = (['web', 'android', 'ios'] as const).map((target) => vendorStatus(projectDir, raw, vendorDir, target)); + const vendors = (['web', 'android', 'ios', 'windows', 'macos', 'linux', 'steamos'] as const).map((target) => vendorStatus(projectDir, raw, vendorDir, target)); return { ok: true, projectDir, configPath, vendors }; } @@ -139,6 +146,15 @@ function expandVendorTargets(targets: BuildVendorTargetInput[]): ConcreteBuildVe expanded.add('android'); expanded.add('ios'); if (target === 'all') expanded.add('web'); + if (target === 'all') { + expanded.add('windows'); + expanded.add('macos'); + expanded.add('linux'); + } + } else if (target === 'desktop') { + expanded.add('windows'); + expanded.add('macos'); + expanded.add('linux'); } else { expanded.add(target); } @@ -168,37 +184,57 @@ async function addSingleVendor(input: AddSingleVendorInput): Promise ${relativePath}`); + if (canReuseSteamosRuntime) { + actions.push(`reuse ${relativePath} for steamos`); + } else { + actions.push(`${isDownloadedRuntimeTarget(input.target) ? 'download' : 'clone'} ${repo}#${input.ref} -> ${relativePath}`); + } if (input.target === 'ios') { actions.push(`install love-${input.loveVersion}-apple-libraries.zip`); + } else if ((input.target === 'linux' || input.target === 'steamos') && !canReuseSteamosRuntime) { + actions.push('install appimagetool.AppImage'); } if (input.updateConfig) { actions.push(`update ${relative(input.projectDir, input.configPath) || 'feather.build.json'}`); } if (!input.dryRun) { - assertGitAvailable(); - const shouldCleanupOnFailure = input.force || !existsSync(targetPath); - try { - if (existsSync(targetPath) && input.force) removePath(targetPath); - mkdirSync(dirname(targetPath), { recursive: true }); - cloneVendor(repo, input.ref, targetPath, input.target === 'android'); - if (input.target === 'ios') { - await installAppleLibraries(input.loveVersion, targetPath); - } + if (canReuseSteamosRuntime) { if (input.updateConfig) { updateVendorConfig(input.projectDir, input.configPath, input.raw, input.target, relativePath); } - } catch (err) { - if (shouldCleanupOnFailure) removePath(targetPath); - throw err; + } else { + if (!isDownloadedRuntimeTarget(input.target)) assertGitAvailable(); + const shouldCleanupOnFailure = input.force || !existsSync(targetPath); + try { + if (existsSync(targetPath) && input.force) removePath(targetPath); + mkdirSync(dirname(targetPath), { recursive: true }); + if (isDownloadedRuntimeTarget(input.target)) { + await installDesktopRuntime(input.target, input.loveVersion, targetPath); + } else { + cloneVendor(repo, input.ref, targetPath, input.target === 'android'); + } + if (input.target === 'ios') { + await installAppleLibraries(input.loveVersion, targetPath); + } + if (input.updateConfig) { + updateVendorConfig(input.projectDir, input.configPath, input.raw, input.target, relativePath); + } + } catch (err) { + if (shouldCleanupOnFailure) removePath(targetPath); + throw err; + } } } @@ -208,8 +244,8 @@ async function addSingleVendor(input: AddSingleVendorInput): Promise { + removePath(targetPath); + mkdirSync(targetPath, { recursive: true }); + if (target === 'windows') { + const zip = await downloadRuntimeArchive(target, loveVersion); + extractZip(zip, targetPath, { stripRoot: true }); + return; + } + if (target === 'macos') { + const zip = await downloadRuntimeArchive(target, loveVersion); + extractZip(zip, targetPath, { stripRoot: false }); + const loveBinary = join(targetPath, 'love.app', 'Contents', 'MacOS', 'love'); + if (existsSync(loveBinary)) chmodSync(loveBinary, 0o755); + return; + } + + const loveAppImage = join(targetPath, 'love.AppImage'); + const appImageTool = join(targetPath, 'appimagetool.AppImage'); + await downloadRuntimeFile(runtimeUrl('linux', loveVersion), loveAppImage, 'FEATHER_TEST_LOVE_LINUX_APPIMAGE'); + await downloadRuntimeFile(APPIMAGETOOL_URL, appImageTool, 'FEATHER_TEST_APPIMAGETOOL'); + chmodSync(loveAppImage, 0o755); + chmodSync(appImageTool, 0o755); + const result = spawnSync(loveAppImage, ['--appimage-extract'], { cwd: targetPath, encoding: 'utf8' }); + if (result.error) { + throw new Error(`Failed to extract LÖVE AppImage: ${result.error.message}`); + } + if (result.status !== 0 || !existsSync(join(targetPath, 'squashfs-root', 'bin', 'love'))) { + throw new Error((result.stderr || result.stdout || 'Failed to extract LÖVE AppImage.').trim()); + } +} + +async function downloadRuntimeArchive(target: 'windows' | 'macos', loveVersion: string): Promise { + const fixture = process.env[target === 'windows' ? 'FEATHER_TEST_LOVE_WINDOWS_ZIP' : 'FEATHER_TEST_LOVE_MACOS_ZIP']; + if (fixture) return readFileSync(fixture); + const response = await fetch(runtimeUrl(target, loveVersion)); + if (!response.ok) { + throw new Error(`Failed to download ${runtimeUrl(target, loveVersion)}: HTTP ${response.status}`); + } + return Buffer.from(await response.arrayBuffer()); +} + +async function downloadRuntimeFile(url: string, path: string, fixtureEnv: string): Promise { + const fixture = process.env[fixtureEnv]; + mkdirSync(dirname(path), { recursive: true }); + if (fixture) { + cpSync(fixture, path, { force: true }); + return; + } + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download ${url}: HTTP ${response.status}`); + } + writeFileSync(path, Buffer.from(await response.arrayBuffer())); +} + +function runtimeUrl(target: 'windows' | 'macos' | 'linux', loveVersion: string): string { + if (target === 'windows') return `${LOVE_RELEASE_BASE}/${encodeURIComponent(loveVersion)}/love-${encodeURIComponent(loveVersion)}-win64.zip`; + if (target === 'macos') return `${LOVE_RELEASE_BASE}/${encodeURIComponent(loveVersion)}/love-${encodeURIComponent(loveVersion)}-macos.zip`; + return `${LOVE_RELEASE_BASE}/${encodeURIComponent(loveVersion)}/love-${encodeURIComponent(loveVersion)}-x86_64.AppImage`; +} + +function extractZip(zip: Buffer, targetPath: string, options: { stripRoot: boolean }): void { + const entries = unzip(zip); + const fileEntries: Array = entries + .map((entry) => ({ ...entry, normalized: normalizeZipEntry(entry.name) })) + .filter((entry): entry is UnzippedEntry & { normalized: string } => typeof entry.normalized === 'string' && entry.normalized.length > 0); + const root = options.stripRoot ? commonRoot(fileEntries.map((entry) => entry.normalized)) : null; + for (const entry of fileEntries) { + const name = root ? entry.normalized.slice(root.length + 1) : entry.normalized; + if (!name || name.endsWith('/')) continue; + const destination = resolve(targetPath, name); + if (!isPathInside(targetPath, destination)) { + throw new Error(`Runtime ZIP contains an unsafe path: ${entry.name}`); + } + mkdirSync(dirname(destination), { recursive: true }); + writeFileSync(destination, entry.data); + } +} + +function normalizeZipEntry(name: string): string | null { + const normalized = name.replace(/\\/g, '/').replace(/^\/+/, ''); + if (!normalized || normalized.startsWith('__MACOSX/') || normalized.includes('/.__MACOSX/')) return null; + if (normalized.startsWith('._') || normalized.includes('/._')) return null; + return normalized; +} + +function commonRoot(names: string[]): string | null { + const roots = new Set(names.map((name) => name.split('/')[0]).filter(Boolean)); + return roots.size === 1 ? [...roots][0]! : null; +} + async function installAppleLibraries(loveVersion: string, loveIosDir: string): Promise { const zip = await appleLibrariesZip(loveVersion); const entries = unzip(zip); @@ -467,8 +617,9 @@ function updateVendorConfig( writeFileSync(configPath, `${JSON.stringify(next, null, 2)}\n`); } -function vendorConfigKey(target: ConcreteBuildVendorTarget): 'loveJsDir' | 'loveAndroidDir' | 'loveIosDir' { +function vendorConfigKey(target: ConcreteBuildVendorTarget): 'loveJsDir' | 'loveAndroidDir' | 'loveIosDir' | 'loveRuntimeDir' { if (target === 'web') return 'loveJsDir'; if (target === 'android') return 'loveAndroidDir'; - return 'loveIosDir'; + if (target === 'ios') return 'loveIosDir'; + return 'loveRuntimeDir'; } diff --git a/cli/test/commands/build-vendor.test.mjs b/cli/test/commands/build-vendor.test.mjs index 2e8a3dfe..a00ed2a8 100644 --- a/cli/test/commands/build-vendor.test.mjs +++ b/cli/test/commands/build-vendor.test.mjs @@ -27,7 +27,12 @@ import { writeBuildConfig, writeFakeAdb, writeFakeAppleLibrariesZip, + writeFakeAppImageTool, writeFakeCommand, + writeFakeDesktopRuntimeVendors, + writeFakeLoveLinuxAppImage, + writeFakeLoveMacosZip, + writeFakeLoveWindowsZip, writeFakeLove, writeFakeLoveAndroid, writeFakeLoveIos, @@ -122,7 +127,29 @@ test('build vendor add mobile --dry-run --json: reports planned vendors without assert.equal(existsSync(join(dir, 'feather.build.json')), false); }); -test('build vendor add all --dry-run --json: includes web and mobile vendors', () => { +test('build vendor add --dry-run --json: defaults to mobile vendors', () => { + const dir = makeTmp(); + writeGame(dir); + + const result = run(['build', 'vendor', 'add', '--dir', dir, '--dry-run', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + assert.deepEqual(parsed.vendors.map((vendor) => vendor.target), ['android', 'ios']); +}); + +test('build vendor add desktop --dry-run --json: includes desktop runtime vendors', () => { + const dir = makeTmp(); + writeGame(dir); + + const result = run(['build', 'vendor', 'add', 'desktop', '--dir', dir, '--dry-run', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.deepEqual(parsed.vendors.map((vendor) => vendor.target), ['windows', 'macos', 'linux']); + assert.equal(existsSync(join(dir, 'vendor')), false); +}); + +test('build vendor add all --dry-run --json: includes web, mobile, and desktop vendors', () => { const dir = makeTmp(); writeGame(dir); @@ -130,10 +157,63 @@ test('build vendor add all --dry-run --json: includes web and mobile vendors', ( assert.equal(result.exitCode, 0, outputOf(result)); assert.equal(ANSI_RE.test(result.stdout), false); const parsed = JSON.parse(result.stdout); - assert.deepEqual(parsed.vendors.map((vendor) => vendor.target), ['android', 'ios', 'web']); + assert.deepEqual(parsed.vendors.map((vendor) => vendor.target), ['android', 'ios', 'web', 'windows', 'macos', 'linux']); assert.equal(existsSync(join(dir, 'vendor')), false); }); +test('build vendor add desktop --json: installs runtime archives and updates config', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Vendor Desktop', version: '1.0.0', loveVersion: '11.5' }); + const windowsZip = writeFakeLoveWindowsZip(dir); + const macosZip = writeFakeLoveMacosZip(dir); + const linuxAppImage = writeFakeLoveLinuxAppImage(dir); + const appImageTool = writeFakeAppImageTool(dir); + + const result = run(['build', 'vendor', 'add', 'desktop', '--dir', dir, '--json'], { + env: { + ...process.env, + NO_COLOR: '1', + FORCE_COLOR: '0', + FEATHER_TEST_LOVE_WINDOWS_ZIP: windowsZip, + FEATHER_TEST_LOVE_MACOS_ZIP: macosZip, + FEATHER_TEST_LOVE_LINUX_APPIMAGE: linuxAppImage, + FEATHER_TEST_APPIMAGETOOL: appImageTool, + }, + }); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + assert.deepEqual(parsed.vendors.map((vendor) => vendor.target), ['windows', 'macos', 'linux']); + assert.equal(existsSync(join(dir, 'vendor', 'love-windows', 'love.exe')), true); + assert.equal(existsSync(join(dir, 'vendor', 'love-macos', 'love.app', 'Contents', 'Info.plist')), true); + assert.equal(existsSync(join(dir, 'vendor', 'love-linux', 'squashfs-root', 'bin', 'love')), true); + assert.equal(existsSync(join(dir, 'vendor', 'love-linux', 'appimagetool.AppImage')), true); + const config = JSON.parse(readFileSync(join(dir, 'feather.build.json'), 'utf8')); + assert.equal(config.targets.windows.loveRuntimeDir, 'vendor/love-windows'); + assert.equal(config.targets.macos.loveRuntimeDir, 'vendor/love-macos'); + assert.equal(config.targets.linux.loveRuntimeDir, 'vendor/love-linux'); +}); + +test('build vendor add steamos --json: reuses configured Linux runtime vendor', () => { + const dir = makeTmp(); + writeGame(dir); + const vendors = writeFakeDesktopRuntimeVendors(dir); + writeBuildConfig(dir, { + name: 'Vendor SteamOS', + version: '1.0.0', + targets: { linux: { loveRuntimeDir: vendors.linux } }, + }); + + const result = run(['build', 'vendor', 'add', 'steamos', '--dir', dir, '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.vendors[0].target, 'steamos'); + assert.equal(parsed.vendors[0].skipped, true); + assert.equal(parsed.vendors[0].installed, false); + const config = JSON.parse(readFileSync(join(dir, 'feather.build.json'), 'utf8')); + assert.equal(config.targets.steamos.loveRuntimeDir, 'vendor/love-linux'); +}); + test('build vendor add --no-config: fetches vendor without writing build config', () => { const dir = makeTmp(); writeGame(dir); @@ -173,11 +253,15 @@ test('build vendor list --json: reports configured, missing, and valid vendors', writeGame(dir); writeFakeLoveJs(dir); writeFakeLoveAndroid(dir); + const vendors = writeFakeDesktopRuntimeVendors(dir); writeBuildConfig(dir, { targets: { web: { loveJsDir: 'love.js' }, android: { loveAndroidDir: 'love-android' }, ios: { loveIosDir: 'vendor/love-ios' }, + windows: { loveRuntimeDir: vendors.windows }, + macos: { loveRuntimeDir: vendors.macos }, + linux: { loveRuntimeDir: vendors.linux }, }, }); @@ -190,6 +274,10 @@ test('build vendor list --json: reports configured, missing, and valid vendors', assert.equal(labels.get('android').valid, true); assert.equal(labels.get('ios').exists, false); assert.equal(labels.get('ios').detail, 'missing'); + assert.equal(labels.get('windows').valid, true); + assert.equal(labels.get('macos').valid, true); + assert.equal(labels.get('linux').valid, true); + assert.equal(labels.get('steamos').valid, true); }); test('build vendor add: missing git produces compact actionable error', () => { diff --git a/cli/test/commands/build.test.mjs b/cli/test/commands/build.test.mjs index f7387122..73c67094 100644 --- a/cli/test/commands/build.test.mjs +++ b/cli/test/commands/build.test.mjs @@ -28,6 +28,8 @@ import { writeFakeAdb, writeFakeAppleLibrariesZip, writeFakeCommand, + writeFakeDesktopRuntimeVendors, + writeFakeDesktopTools, writeFakeLove, writeFakeLoveAndroid, writeFakeLoveIos, @@ -71,31 +73,71 @@ test('build web: creates love archive, love.js html package, zip, and manifest', assert.equal(manifest.target, 'web'); }); -test('build linux: delegates desktop packaging to love-release and writes manifest', () => { +test('build love: creates only a .love package and writes manifest', () => { const dir = makeTmp(); writeGame(dir); - writeBuildConfig(dir, { name: 'Desktop Game', version: '2.0.0' }); -const recordPath = join(dir, 'love-release-record.json'); - const { binDir } = writeFakeCommand(dir, 'love-release', ` -if (process.argv.length === 3 && process.argv[2] === '--version') { - console.log('love-release test'); - process.exit(0); -} -const fs = require('node:fs'); -fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ argv: process.argv.slice(2) }, null, 2)); -process.exit(0); -`); + writeBuildConfig(dir, { name: 'Love Package', version: '2.0.0' }); - const result = run(['build', 'linux', '--dir', dir, '--json'], { env: envWithPath(binDir) }); + const result = run(['build', 'love', '--dir', dir, '--json']); assert.equal(result.exitCode, 0, outputOf(result)); const parsed = JSON.parse(result.stdout); assert.equal(parsed.ok, true); - assert.equal(existsSync(join(dir, 'builds', 'desktop-game-2.0.0.love')), true); - const record = JSON.parse(readFileSync(recordPath, 'utf8')); - assert.ok(record.argv.includes('--target')); - assert.ok(record.argv.includes('linux')); - assert.ok(record.argv.includes('--name')); - assert.ok(record.argv.includes('Desktop Game')); + assert.equal(parsed.target, 'love'); + assert.deepEqual(parsed.artifacts.map((artifact) => artifact.type), ['love']); + assert.equal(existsSync(join(dir, 'builds', 'love-package-2.0.0.love')), true); + const manifest = JSON.parse(readFileSync(join(dir, 'builds', 'feather-build-manifest.json'), 'utf8')); + assert.equal(manifest.target, 'love'); +}); + +for (const target of ['windows', 'macos', 'linux', 'steamos']) { + test(`build ${target}: packages with configured local LÖVE runtime vendor`, () => { + const dir = makeTmp(); + writeGame(dir); + const vendors = writeFakeDesktopRuntimeVendors(dir); + const { binDir } = writeFakeDesktopTools(dir); + writeBuildConfig(dir, { + name: 'Desktop Game', + version: '2.0.0', + targets: { + windows: { loveRuntimeDir: vendors.windows }, + macos: { loveRuntimeDir: vendors.macos }, + linux: { loveRuntimeDir: vendors.linux }, + }, + }); + + const result = run(['build', target, '--dir', dir, '--json'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, true); + assert.equal(parsed.target, target); + assert.equal(existsSync(join(dir, 'builds', 'desktop-game-2.0.0.love')), true); + if (target === 'windows') { + assert.deepEqual(parsed.artifacts.map((artifact) => artifact.type), ['love', 'zip', 'installer']); + assert.equal(existsSync(join(dir, 'builds', 'desktop-game-2.0.0-windows.zip')), true); + assert.equal(existsSync(join(dir, 'builds', 'desktop-game-2.0.0-windows-installer.exe')), true); + const entries = readStoredZipEntries(join(dir, 'builds', 'desktop-game-2.0.0-windows.zip')); + assert.equal(entries.has('desktop-game.exe'), true); + assert.equal(entries.has('love.exe'), false); + } else if (target === 'macos') { + assert.deepEqual(parsed.artifacts.map((artifact) => artifact.type), ['love', 'zip', 'dmg']); + assert.equal(existsSync(join(dir, 'builds', 'desktop-game-2.0.0-macos.app.zip')), true); + assert.equal(existsSync(join(dir, 'builds', 'desktop-game-2.0.0-macos.dmg')), true); + } else { + assert.deepEqual(parsed.artifacts.map((artifact) => artifact.type), ['love', 'appimage', 'tar.gz']); + assert.equal(existsSync(join(dir, 'builds', `desktop-game-2.0.0-${target}.AppImage`)), true); + assert.equal(existsSync(join(dir, 'builds', `desktop-game-2.0.0-${target}.tar.gz`)), true); + } + }); +} + +test('build desktop: missing runtime vendor fails with vendor add guidance', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Missing Desktop Runtime', version: '1.0.0' }); + + const result = run(['build', 'windows', '--dir', dir, '--json']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('feather build vendor add windows')); }); test('build validation: rejects bad mobile config values and unsafe native paths', async () => { diff --git a/cli/test/commands/doctor.test.mjs b/cli/test/commands/doctor.test.mjs index 6f96a8fb..971d69cb 100644 --- a/cli/test/commands/doctor.test.mjs +++ b/cli/test/commands/doctor.test.mjs @@ -28,6 +28,8 @@ import { writeFakeAdb, writeFakeAppleLibrariesZip, writeFakeCommand, + writeFakeDesktopRuntimeVendors, + writeFakeDesktopTools, writeFakeLove, writeFakeLoveAndroid, writeFakeLoveIos, @@ -259,6 +261,45 @@ test('doctor: build and upload target checks report missing and configured depen assert.equal(labels.get('BUTLER_API_KEY')?.severity, 'pass'); }); +test('doctor: desktop build targets report runtime vendors and packaging tools', () => { + const dir = makeTmp(); + writeGame(dir); + writeBuildConfig(dir, { name: 'Desktop Doctor Game', version: '1.0.0' }); + + const { parsed: missing } = parseDoctorJsonResult(dir, ['--build-target', 'windows']); + const missingLabels = new Map(missing.checks.map((check) => [check.label, check])); + assert.equal(missingLabels.get('Windows LÖVE runtime')?.severity, 'fail'); + assert.ok(missingLabels.get('Windows LÖVE runtime')?.fix.includes('feather build vendor add windows')); + + const vendors = writeFakeDesktopRuntimeVendors(dir); + writeBuildConfig(dir, { + name: 'Desktop Doctor Game', + version: '1.0.0', + targets: { + windows: { loveRuntimeDir: vendors.windows }, + macos: { loveRuntimeDir: vendors.macos }, + linux: { loveRuntimeDir: vendors.linux }, + }, + }); + const { binDir } = writeFakeDesktopTools(dir); + + for (const target of ['windows', 'macos', 'linux', 'steamos']) { + const result = run(['doctor', dir, '--json', '--build-target', target], { env: envWithPath(binDir) }); + assert.equal(result.stdout.trim().startsWith('{'), true, outputOf(result)); + const parsed = JSON.parse(result.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + const runtimeLabel = target === 'macos' + ? 'macOS LÖVE runtime' + : target === 'steamos' + ? 'SteamOS LÖVE runtime' + : `${target[0].toUpperCase()}${target.slice(1)} LÖVE runtime`; + assert.equal(labels.get(runtimeLabel)?.severity, 'pass'); + if (target === 'windows') assert.equal(labels.get('NSIS makensis')?.severity, 'pass'); + if (target === 'macos') assert.equal(labels.get('hdiutil')?.severity, 'pass'); + if (target === 'linux' || target === 'steamos') assert.equal(labels.get('appimagetool')?.severity, 'pass'); + } +}); + test('doctor: android build target reports template and local tool setup', () => { const dir = makeTmp(); writeGame(dir); diff --git a/cli/test/commands/helpers.mjs b/cli/test/commands/helpers.mjs index 99a7ecbc..42b72036 100644 --- a/cli/test/commands/helpers.mjs +++ b/cli/test/commands/helpers.mjs @@ -541,6 +541,167 @@ async function writeFakeAppleLibrariesZip(dir) { return zipPath; } +function writeFakeDesktopRuntimeVendors(dir) { + const windows = join(dir, 'vendor', 'love-windows'); + mkdirSync(windows, { recursive: true }); + writeFileSync(join(windows, 'love.exe'), 'fake love exe'); + writeFileSync(join(windows, 'SDL2.dll'), 'fake dll'); + + const macos = join(dir, 'vendor', 'love-macos', 'love.app', 'Contents'); + mkdirSync(join(macos, 'MacOS'), { recursive: true }); + mkdirSync(join(macos, 'Resources'), { recursive: true }); + writeFileSync(join(macos, 'Info.plist'), fakeMacosPlist()); + writeFileSync(join(macos, 'MacOS', 'love'), '#!/bin/sh\n'); + chmodSync(join(macos, 'MacOS', 'love'), 0o755); + + const linux = join(dir, 'vendor', 'love-linux'); + writeFakeLinuxRuntime(linux); + + return { + windows: 'vendor/love-windows', + macos: 'vendor/love-macos', + linux: 'vendor/love-linux', + }; +} + +function writeFakeLinuxRuntime(root) { + mkdirSync(join(root, 'squashfs-root', 'bin'), { recursive: true }); + mkdirSync(join(root, 'squashfs-root', 'share', 'applications'), { recursive: true }); + writeFileSync(join(root, 'squashfs-root', 'bin', 'love'), '#!/bin/sh\n'); + chmodSync(join(root, 'squashfs-root', 'bin', 'love'), 0o755); + writeFileSync(join(root, 'squashfs-root', 'AppRun'), '#!/bin/sh\nexec "$APPDIR/bin/love" "$@"\n'); + chmodSync(join(root, 'squashfs-root', 'AppRun'), 0o755); + writeFileSync(join(root, 'squashfs-root', 'love.desktop'), '[Desktop Entry]\nName=LÖVE\nType=Application\nExec=love\n'); + writeFileSync( + join(root, 'appimagetool.AppImage'), + `#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +const out = process.argv[3]; +fs.mkdirSync(path.dirname(out), { recursive: true }); +fs.writeFileSync(out, 'fake appimage'); +`, + ); + chmodSync(join(root, 'appimagetool.AppImage'), 0o755); +} + +function writeFakeDesktopTools(dir) { + const { binDir } = writeFakeCommand(dir, 'makensis', ` +const fs = require('node:fs'); +if (process.argv.includes('/VERSION')) { + console.log('makensis test'); + process.exit(0); +} +const script = fs.readFileSync(process.argv[2], 'utf8'); +const out = script.match(/OutFile "([^"]+)"/)?.[1]; +if (!out) process.exit(2); +fs.mkdirSync(require('node:path').dirname(out), { recursive: true }); +fs.writeFileSync(out, 'fake installer'); +process.exit(0); +`); + writeFakeCommand(dir, 'hdiutil', ` +const fs = require('node:fs'); +const path = require('node:path'); +if (process.argv[2] === 'help') { + console.log('hdiutil test'); + process.exit(0); +} +const out = process.argv[process.argv.length - 1]; +fs.mkdirSync(path.dirname(out), { recursive: true }); +fs.writeFileSync(out, 'fake dmg'); +process.exit(0); +`); + writeFakeCommand(dir, 'plutil', ` +console.log('plutil test'); +process.exit(0); +`); + writeFakeCommand(dir, 'ditto', ` +const fs = require('node:fs'); +const path = require('node:path'); +const out = process.argv[process.argv.length - 1]; +fs.mkdirSync(path.dirname(out), { recursive: true }); +fs.writeFileSync(out, 'fake app zip'); +process.exit(0); +`); + return { binDir }; +} + +function writeFakeLoveWindowsZip(dir) { + const zipPath = join(dir, 'love-windows.zip'); + writeFileSync(zipPath, createDataDescriptorZipBuffer([ + { name: 'love-11.5-win64/love.exe', data: Buffer.from('fake love exe') }, + { name: 'love-11.5-win64/SDL2.dll', data: Buffer.from('fake dll') }, + ])); + return zipPath; +} + +function writeFakeLoveMacosZip(dir) { + const zipPath = join(dir, 'love-macos.zip'); + writeFileSync(zipPath, createDataDescriptorZipBuffer([ + { name: 'love.app/Contents/Info.plist', data: Buffer.from(fakeMacosPlist()) }, + { name: 'love.app/Contents/MacOS/love', data: Buffer.from('#!/bin/sh\n') }, + ])); + return zipPath; +} + +function writeFakeLoveLinuxAppImage(dir) { + const appImage = join(dir, 'love.AppImage'); + writeFileSync( + appImage, + `#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +if (process.argv.includes('--appimage-extract')) { + const root = path.join(process.cwd(), 'squashfs-root'); + fs.mkdirSync(path.join(root, 'bin'), { recursive: true }); + fs.mkdirSync(path.join(root, 'share', 'applications'), { recursive: true }); + fs.writeFileSync(path.join(root, 'bin', 'love'), '#!/bin/sh\\n'); + fs.chmodSync(path.join(root, 'bin', 'love'), 0o755); + fs.writeFileSync(path.join(root, 'AppRun'), '#!/bin/sh\\nexec "$APPDIR/bin/love" "$@"\\n'); + fs.chmodSync(path.join(root, 'AppRun'), 0o755); + fs.writeFileSync(path.join(root, 'love.desktop'), '[Desktop Entry]\\nName=LÖVE\\nType=Application\\nExec=love\\n'); + process.exit(0); +} +console.log('fake love appimage'); +process.exit(0); +`, + ); + chmodSync(appImage, 0o755); + return appImage; +} + +function writeFakeAppImageTool(dir) { + const appImageTool = join(dir, 'appimagetool.AppImage'); + writeFileSync( + appImageTool, + `#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +const out = process.argv[3]; +fs.mkdirSync(path.dirname(out), { recursive: true }); +fs.writeFileSync(out, 'fake appimage'); +`, + ); + chmodSync(appImageTool, 0o755); + return appImageTool; +} + +function fakeMacosPlist() { + return ` + + + + CFBundleExecutable + love + CFBundleIdentifier + org.love2d.love + CFBundleName + love + + +`; +} + function parseDoctorJson(dir, extra = []) { const result = run(['doctor', dir, '--json', ...extra]); assert.equal(result.exitCode, 0, outputOf(result)); @@ -583,11 +744,17 @@ export { waitForOutput, writeBuildConfig, writeFakeAdb, + writeFakeAppImageTool, writeFakeAppleLibrariesZip, writeFakeCommand, + writeFakeDesktopRuntimeVendors, + writeFakeDesktopTools, writeFakeLove, writeFakeLoveAndroid, writeFakeLoveIos, + writeFakeLoveLinuxAppImage, + writeFakeLoveMacosZip, + writeFakeLoveWindowsZip, writeFakeLoveJs, writeFakeVendorGit, writeFakeXcrun, diff --git a/cli/test/commands/upload.test.mjs b/cli/test/commands/upload.test.mjs index d2f90824..bf188db3 100644 --- a/cli/test/commands/upload.test.mjs +++ b/cli/test/commands/upload.test.mjs @@ -102,6 +102,53 @@ process.exit(0); assert.ok(record.argv.includes('--hidden')); }); +test('upload itch: desktop targets prefer installer-style artifacts over .love', () => { + const dir = makeTmp(); + writeGame(dir); + const builds = join(dir, 'builds'); + mkdirSync(builds, { recursive: true }); + writeBuildConfig(dir, { + name: 'Desktop Upload', + version: '5.0.0', + upload: { + itch: { + project: 'tester/desktop-upload', + channels: { windows: 'win', macos: 'mac', linux: 'linux' }, + }, + }, + }); + const artifacts = [ + { target: 'windows', type: 'love', path: join(builds, 'desktop-upload-5.0.0.love') }, + { target: 'windows', type: 'zip', path: join(builds, 'desktop-upload-5.0.0-windows.zip') }, + { target: 'windows', type: 'installer', path: join(builds, 'desktop-upload-5.0.0-windows-installer.exe') }, + { target: 'macos', type: 'love', path: join(builds, 'desktop-upload-5.0.0.love') }, + { target: 'macos', type: 'zip', path: join(builds, 'desktop-upload-5.0.0-macos.app.zip') }, + { target: 'macos', type: 'dmg', path: join(builds, 'desktop-upload-5.0.0-macos.dmg') }, + { target: 'linux', type: 'love', path: join(builds, 'desktop-upload-5.0.0.love') }, + { target: 'linux', type: 'tar.gz', path: join(builds, 'desktop-upload-5.0.0-linux.tar.gz') }, + { target: 'linux', type: 'appimage', path: join(builds, 'desktop-upload-5.0.0-linux.AppImage') }, + ]; + for (const artifact of artifacts) writeFileSync(artifact.path, artifact.type); + writeFileSync(join(builds, 'feather-build-manifest.json'), `${JSON.stringify({ + name: 'Desktop Upload', + version: '5.0.0', + target: 'windows', + createdAt: '2026-05-16T00:00:00.000Z', + artifacts, + }, null, 2)}\n`); + + for (const [target, expected] of [ + ['windows', 'desktop-upload-5.0.0-windows-installer.exe'], + ['macos', 'desktop-upload-5.0.0-macos.dmg'], + ['linux', 'desktop-upload-5.0.0-linux.AppImage'], + ]) { + const result = run(['upload', 'itch', target, '--dir', dir, '--dry-run', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.artifact.endsWith(expected), true); + } +}); + test('upload steam: planned target fails cleanly', () => { const dir = makeTmp(); writeGame(dir); From 08be03e9c63ff7b150b4acfd9b4072e1b7b854e1 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 21:41:25 -0400 Subject: [PATCH 54/68] cli: improve doctor --- cli/src/commands/doctor/index.ts | 438 +++++++++++++++++------------ cli/test/commands/doctor.test.mjs | 92 +++++- cli/test/commands/runtime.test.mjs | 1 + 3 files changed, 347 insertions(+), 184 deletions(-) diff --git a/cli/src/commands/doctor/index.ts b/cli/src/commands/doctor/index.ts index 7c6df6b7..ca6caecc 100644 --- a/cli/src/commands/doctor/index.ts +++ b/cli/src/commands/doctor/index.ts @@ -13,6 +13,8 @@ import { isUploadTarget, loadBuildConfig, outDirWritableDetail, + supportedBuildTargets, + type SupportedBuildTarget, uploadTargets, } from '../../lib/build/config.js'; import { androidProductId, iosBundleIdentifier, validateBuildConfigForTarget } from '../../lib/build/validation.js'; @@ -109,23 +111,25 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) add(checks, 'Environment', 'Platform', 'info', `${process.platform} ${process.arch}`); if (opts.buildTarget) { - if (!isBuildTarget(opts.buildTarget)) { + if (!isDoctorBuildTarget(opts.buildTarget)) { add( checks, 'Build', 'Build target', 'fail', opts.buildTarget, - `Use one of: ${buildTargets.join(', ')}.`, + `Use one of: ${doctorBuildTargets.join(', ')}.`, ); } else { add( checks, 'Build', 'Build target', - isSupportedBuildTarget(opts.buildTarget) ? 'pass' : 'warn', + opts.buildTarget === 'all' || isSupportedBuildTarget(opts.buildTarget) ? 'pass' : 'warn', opts.buildTarget, - isSupportedBuildTarget(opts.buildTarget) + opts.buildTarget === 'all' + ? `checking ${supportedBuildTargets.join(', ')}` + : isSupportedBuildTarget(opts.buildTarget) ? undefined : `${opts.buildTarget} support is registered but not enabled in this build.`, ); @@ -186,176 +190,13 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) writable.ok ? undefined : 'Choose a writable outDir in feather.build.json or pass --out-dir.', ); - if (opts.buildTarget && isBuildTarget(opts.buildTarget)) { - const configIssues = validateBuildConfigForTarget(buildConfig, opts.buildTarget, Boolean(opts.release)); - if (opts.release && opts.buildTarget !== 'android' && opts.buildTarget !== 'ios') { - add( - checks, - 'Build', - 'Release mode', - 'fail', - opts.buildTarget, - 'Release mode is currently supported only for android and ios builds.', - ); - } - if (opts.buildTarget === 'android' || opts.buildTarget === 'ios') { - add( - checks, - 'Build', - opts.buildTarget === 'android' ? 'Android config' : 'iOS config', - configIssues.length === 0 ? 'pass' : 'fail', - configIssues.length === 0 ? 'valid' : configIssues.map((issue) => `${issue.field}: ${issue.message}`).join('; '), - configIssues.length === 0 ? undefined : 'Fix feather.build.json before running the build.', - ); - } - if (opts.buildTarget === 'web') { - const loveJsDir = buildConfig.targets.web?.loveJsDir; - add( - checks, - 'Build', - 'love.js player', - loveJsDir && existsSync(resolve(projectDir, loveJsDir)) ? 'pass' : 'fail', - loveJsDir ?? 'not configured', - `Run \`feather build vendor add web --dir ${projectDir}\` or set targets.web.loveJsDir in feather.build.json to a local love.js checkout or build output.`, - ); - } else if (opts.buildTarget === 'android') { - const androidConfig = buildConfig.targets.android ?? {}; - const loveAndroidDir = androidConfig.loveAndroidDir ? resolve(projectDir, androidConfig.loveAndroidDir) : ''; - const hasLoveAndroidDir = Boolean(loveAndroidDir && existsSync(loveAndroidDir)); - add( - checks, - 'Build', - 'love-android template', - hasLoveAndroidDir ? 'pass' : 'fail', - androidConfig.loveAndroidDir ?? 'not configured', - hasLoveAndroidDir ? undefined : `Run \`feather build vendor add android --dir ${projectDir}\` or set targets.android.loveAndroidDir in feather.build.json.`, - ); - const gradleWrapper = hasLoveAndroidDir && (existsSync(join(loveAndroidDir, 'gradlew')) || existsSync(join(loveAndroidDir, 'gradlew.bat'))); - add( - checks, - 'Build', - 'Android Gradle wrapper', - gradleWrapper ? 'pass' : 'fail', - gradleWrapper ? loveAndroidDir : 'not found', - 'Use a love-android checkout that includes gradlew, or restore the Gradle wrapper files.', - ); - const java = commandVersion('java', ['-version']); - add( - checks, - 'Build', - 'JDK', - java ? 'pass' : 'fail', - java ?? 'not found', - 'Install a JDK compatible with the configured Android Gradle Plugin and make sure java is on PATH.', - ); - const androidSdk = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT; - add( - checks, - 'Build', - 'Android SDK', - androidSdk ? 'pass' : 'fail', - androidSdk ?? 'ANDROID_HOME/ANDROID_SDK_ROOT missing', - 'Install Android SDK command-line tools and set ANDROID_HOME or ANDROID_SDK_ROOT.', - ); - const productId = androidProductId(buildConfig); - add( - checks, - 'Build', - 'Android product id', - configIssues.some((issue) => issue.field === 'productId') ? 'fail' : 'pass', - productId, - configIssues.some((issue) => issue.field === 'productId') ? 'Set a valid productId or targets.android.productId in feather.build.json.' : undefined, - ); - add( - checks, - 'Build', - 'Android signing', - opts.release ? androidReleaseSigningSeverity(buildConfig) : 'warn', - opts.release ? androidReleaseSigningDetail(buildConfig) : 'debug/dev build', - opts.release - ? androidReleaseSigningFix(buildConfig) - : 'Use --release to check signed AAB/APK setup.', - ); - } else if (opts.buildTarget === 'ios') { - const iosConfig = buildConfig.targets.ios ?? {}; - const loveIosDir = iosConfig.loveIosDir ? resolve(projectDir, iosConfig.loveIosDir) : ''; - const hasLoveIosDir = Boolean(loveIosDir && existsSync(loveIosDir)); - add( - checks, - 'Build', - 'macOS host', - process.platform === 'darwin' ? 'pass' : 'fail', - process.platform, - 'iOS builds require macOS with Xcode.', - ); - const xcodebuild = commandVersion('xcodebuild', ['-version']); - add( - checks, - 'Build', - 'xcodebuild', - xcodebuild ? 'pass' : 'fail', - xcodebuild ?? 'not found', - 'Install Xcode and command line tools, then run `xcode-select --install` if needed.', - ); - add( - checks, - 'Build', - 'LÖVE iOS template', - hasLoveIosDir ? 'pass' : 'fail', - iosConfig.loveIosDir ?? 'not configured', - hasLoveIosDir ? undefined : `Run \`feather build vendor add ios --dir ${projectDir}\` or set targets.ios.loveIosDir in feather.build.json.`, - ); - const xcodeProject = hasLoveIosDir && existsSync(join(loveIosDir, 'platform', 'xcode', 'love.xcodeproj')); - add( - checks, - 'Build', - 'LÖVE iOS Xcode project', - xcodeProject ? 'pass' : 'fail', - xcodeProject ? join(loveIosDir, 'platform', 'xcode', 'love.xcodeproj') : 'not found', - 'Use a LÖVE iOS source tree that includes platform/xcode/love.xcodeproj.', - ); - const bundleId = iosBundleIdentifier(buildConfig); - add( - checks, - 'Build', - 'iOS bundle id', - configIssues.some((issue) => issue.field === 'bundleIdentifier') ? 'fail' : 'pass', - bundleId, - configIssues.some((issue) => issue.field === 'bundleIdentifier') ? 'Set a valid productId, targets.ios.productId, or targets.ios.bundleIdentifier in feather.build.json.' : undefined, - ); - add( - checks, - 'Build', - 'iOS signing team', - opts.release - ? ((iosConfig.release?.teamId ?? iosConfig.teamId) ? 'pass' : 'warn') - : (iosConfig.teamId ? 'pass' : 'warn'), - opts.release ? (iosConfig.release?.teamId ?? iosConfig.teamId ?? 'missing') : (iosConfig.teamId ?? 'missing'), - opts.release - ? 'Set targets.ios.release.teamId or targets.ios.teamId if your export requires a team.' - : 'Set targets.ios.teamId for device builds; simulator debug builds can usually omit it.', - ); - if (opts.release) { - add( - checks, - 'Build', - 'iOS export options', - iosConfig.release?.exportOptionsPlist || iosConfig.release?.exportMethod ? 'pass' : 'warn', - iosConfig.release?.exportOptionsPlist ?? iosConfig.release?.exportMethod ?? 'generated development export options', - 'Set targets.ios.release.exportOptionsPlist or exportMethod for App Store/TestFlight exports.', - ); - } - } else if (opts.buildTarget === 'love') { - add( - checks, - 'Build', - 'LÖVE package', - 'pass', - '.love archive', - 'Run it with LÖVE or build a platform target for a bundled runtime.', - ); - } else if (isSupportedBuildTarget(opts.buildTarget)) { - addDesktopBuildChecks(checks, projectDir, buildConfig, opts.buildTarget); + if (opts.buildTarget && isDoctorBuildTarget(opts.buildTarget)) { + const fromAll = opts.buildTarget === 'all'; + for (const target of expandDoctorBuildTargets(opts.buildTarget)) { + addBuildTargetChecks(checks, projectDir, buildConfig, target, { + fromAll, + release: Boolean(opts.release), + }); } } @@ -624,7 +465,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) checks, 'Safety', 'Desktop App ID', - appIdMissing ? (opts.production ? 'fail' : 'warn') : 'pass', + appIdMissing ? 'fail' : 'pass', appIdMissing ? 'missing' : mode === 'disk' ? 'not needed for disk mode' : 'configured', appIdMissing ? 'Set appId in feather.config.lua before shipping socket/network builds.' : undefined, ); @@ -706,7 +547,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) checks, 'Safety', 'Desktop App ID', - opts.production ? 'fail' : 'warn', + 'fail', 'missing', 'Create feather.config.lua with a strong appId before shipping socket/network builds.', ); @@ -916,6 +757,232 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) } } +const doctorBuildTargets = [...buildTargets, 'all'] as const; +type DoctorBuildTarget = SupportedBuildTarget | 'all'; + +function isDoctorBuildTarget(value: string): value is DoctorBuildTarget { + return value === 'all' || isBuildTarget(value); +} + +function expandDoctorBuildTargets(target: DoctorBuildTarget): SupportedBuildTarget[] { + return target === 'all' ? [...supportedBuildTargets] : isSupportedBuildTarget(target) ? [target] : []; +} + +function addBuildTargetChecks( + checks: DoctorCheck[], + projectDir: string, + buildConfig: ReturnType, + target: SupportedBuildTarget, + options: { fromAll: boolean; release: boolean }, +): void { + const release = options.release && (!options.fromAll || target === 'android' || target === 'ios'); + const label = (singleTargetLabel: string, allTargetBase = singleTargetLabel) => + options.fromAll ? buildCheckLabel(target, allTargetBase, true) : singleTargetLabel; + const configIssues = validateBuildConfigForTarget(buildConfig, target, release); + + if (options.release && !options.fromAll && target !== 'android' && target !== 'ios') { + add( + checks, + 'Build', + 'Release mode', + 'fail', + target, + 'Release mode is currently supported only for android and ios builds.', + ); + } + + if (target === 'love') { + add( + checks, + 'Build', + label('LÖVE package'), + 'pass', + '.love archive', + 'Run it with LÖVE or build a platform target for a bundled runtime.', + ); + return; + } + + if (target === 'web') { + const loveJsDir = buildConfig.targets.web?.loveJsDir; + add( + checks, + 'Build', + label('love.js player'), + loveJsDir && existsSync(resolve(projectDir, loveJsDir)) ? 'pass' : 'fail', + loveJsDir ?? 'not configured', + `Run \`feather build vendor add web --dir ${projectDir}\` or set targets.web.loveJsDir in feather.build.json to a local love.js checkout or build output.`, + ); + return; + } + + if (target === 'android') { + addMobileConfigCheck(checks, target, configIssues, options.fromAll); + const androidConfig = buildConfig.targets.android ?? {}; + const loveAndroidDir = androidConfig.loveAndroidDir ? resolve(projectDir, androidConfig.loveAndroidDir) : ''; + const hasLoveAndroidDir = Boolean(loveAndroidDir && existsSync(loveAndroidDir)); + add( + checks, + 'Build', + label('love-android template'), + hasLoveAndroidDir ? 'pass' : 'fail', + androidConfig.loveAndroidDir ?? 'not configured', + hasLoveAndroidDir ? undefined : `Run \`feather build vendor add android --dir ${projectDir}\` or set targets.android.loveAndroidDir in feather.build.json.`, + ); + const gradleWrapper = hasLoveAndroidDir && (existsSync(join(loveAndroidDir, 'gradlew')) || existsSync(join(loveAndroidDir, 'gradlew.bat'))); + add( + checks, + 'Build', + label('Android Gradle wrapper'), + gradleWrapper ? 'pass' : 'fail', + gradleWrapper ? loveAndroidDir : 'not found', + 'Use a love-android checkout that includes gradlew, or restore the Gradle wrapper files.', + ); + const java = commandVersion('java', ['-version']); + add( + checks, + 'Build', + label('JDK'), + java ? 'pass' : 'fail', + java ?? 'not found', + 'Install a JDK compatible with the configured Android Gradle Plugin and make sure java is on PATH.', + ); + const androidSdk = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT; + add( + checks, + 'Build', + label('Android SDK'), + androidSdk ? 'pass' : 'fail', + androidSdk ?? 'ANDROID_HOME/ANDROID_SDK_ROOT missing', + 'Install Android SDK command-line tools and set ANDROID_HOME or ANDROID_SDK_ROOT.', + ); + const productId = androidProductId(buildConfig); + add( + checks, + 'Build', + label('Android product id'), + configIssues.some((issue) => issue.field === 'productId') ? 'fail' : 'pass', + productId, + configIssues.some((issue) => issue.field === 'productId') ? 'Set a valid productId or targets.android.productId in feather.build.json.' : undefined, + ); + add( + checks, + 'Build', + label('Android signing'), + release ? androidReleaseSigningSeverity(buildConfig) : 'warn', + release ? androidReleaseSigningDetail(buildConfig) : 'debug/dev build', + release + ? androidReleaseSigningFix(buildConfig) + : 'Use --release to check signed AAB/APK setup.', + ); + return; + } + + if (target === 'ios') { + addMobileConfigCheck(checks, target, configIssues, options.fromAll); + const iosConfig = buildConfig.targets.ios ?? {}; + const loveIosDir = iosConfig.loveIosDir ? resolve(projectDir, iosConfig.loveIosDir) : ''; + const hasLoveIosDir = Boolean(loveIosDir && existsSync(loveIosDir)); + add( + checks, + 'Build', + label('macOS host'), + process.platform === 'darwin' ? 'pass' : 'fail', + process.platform, + 'iOS builds require macOS with Xcode.', + ); + const xcodebuild = commandVersion('xcodebuild', ['-version']); + add( + checks, + 'Build', + label('xcodebuild'), + xcodebuild ? 'pass' : 'fail', + xcodebuild ?? 'not found', + 'Install Xcode and command line tools, then run `xcode-select --install` if needed.', + ); + add( + checks, + 'Build', + label('LÖVE iOS template', 'LÖVE template'), + hasLoveIosDir ? 'pass' : 'fail', + iosConfig.loveIosDir ?? 'not configured', + hasLoveIosDir ? undefined : `Run \`feather build vendor add ios --dir ${projectDir}\` or set targets.ios.loveIosDir in feather.build.json.`, + ); + const xcodeProject = hasLoveIosDir && existsSync(join(loveIosDir, 'platform', 'xcode', 'love.xcodeproj')); + add( + checks, + 'Build', + label('LÖVE iOS Xcode project', 'LÖVE Xcode project'), + xcodeProject ? 'pass' : 'fail', + xcodeProject ? join(loveIosDir, 'platform', 'xcode', 'love.xcodeproj') : 'not found', + 'Use a LÖVE iOS source tree that includes platform/xcode/love.xcodeproj.', + ); + const bundleId = iosBundleIdentifier(buildConfig); + add( + checks, + 'Build', + label('iOS bundle id'), + configIssues.some((issue) => issue.field === 'bundleIdentifier') ? 'fail' : 'pass', + bundleId, + configIssues.some((issue) => issue.field === 'bundleIdentifier') ? 'Set a valid productId, targets.ios.productId, or targets.ios.bundleIdentifier in feather.build.json.' : undefined, + ); + add( + checks, + 'Build', + label('iOS signing team'), + release + ? ((iosConfig.release?.teamId ?? iosConfig.teamId) ? 'pass' : 'warn') + : (iosConfig.teamId ? 'pass' : 'warn'), + release ? (iosConfig.release?.teamId ?? iosConfig.teamId ?? 'missing') : (iosConfig.teamId ?? 'missing'), + release + ? 'Set targets.ios.release.teamId or targets.ios.teamId if your export requires a team.' + : 'Set targets.ios.teamId for device builds; simulator debug builds can usually omit it.', + ); + if (release) { + add( + checks, + 'Build', + label('iOS export options'), + iosConfig.release?.exportOptionsPlist || iosConfig.release?.exportMethod ? 'pass' : 'warn', + iosConfig.release?.exportOptionsPlist ?? iosConfig.release?.exportMethod ?? 'generated development export options', + 'Set targets.ios.release.exportOptionsPlist or exportMethod for App Store/TestFlight exports.', + ); + } + return; + } + + addDesktopBuildChecks(checks, projectDir, buildConfig, target, { fromAll: options.fromAll }); +} + +function addMobileConfigCheck( + checks: DoctorCheck[], + target: 'android' | 'ios', + configIssues: ReturnType, + fromAll: boolean, +): void { + const label = target === 'android' ? 'Android config' : 'iOS config'; + add( + checks, + 'Build', + buildCheckLabel(target, label, fromAll), + configIssues.length === 0 ? 'pass' : 'fail', + configIssues.length === 0 ? 'valid' : configIssues.map((issue) => `${issue.field}: ${issue.message}`).join('; '), + configIssues.length === 0 ? undefined : 'Fix feather.build.json before running the build.', + ); +} + +function buildCheckLabel(target: SupportedBuildTarget, base: string, prefixed: boolean): string { + return prefixedCheckLabel(buildTargetLabel(target), base, prefixed); +} + +function buildTargetLabel(target: SupportedBuildTarget): string { + if (target === 'love') return 'LÖVE'; + if (target === 'ios') return 'iOS'; + if (target === 'macos') return 'macOS'; + if (target === 'steamos') return 'SteamOS'; + return target[0].toUpperCase() + target.slice(1); +} + function androidReleaseSigningSeverity(buildConfig: ReturnType): DoctorCheck['severity'] { const release = buildConfig.targets.android?.release; if (!release) return 'warn'; @@ -941,8 +1008,10 @@ function addDesktopBuildChecks( projectDir: string, buildConfig: ReturnType, target: string, + options: { fromAll?: boolean } = {}, ): void { if (!isDoctorDesktopBuildTarget(target)) return; + const label = (base: string) => prefixedCheckLabel(desktopTargetLabel(target), base, Boolean(options.fromAll)); const configuredPath = target === 'steamos' ? buildConfig.targets.steamos?.loveRuntimeDir ?? buildConfig.targets.linux?.loveRuntimeDir @@ -955,19 +1024,19 @@ function addDesktopBuildChecks( add( checks, 'Build', - `${desktopTargetLabel(target)} LÖVE runtime`, + label(`${desktopTargetLabel(target)} LÖVE runtime`), runtimeReady ? 'pass' : 'fail', configuredPath ?? 'not configured', runtimeReady ? undefined : `Run \`feather build vendor add ${target} --dir ${projectDir}\` or set ${configField} in feather.build.json.`, ); if (target === 'windows') { - addToolCheck(checks, 'NSIS makensis', 'makensis', ['/VERSION'], 'Install NSIS and make sure makensis is on PATH.'); + addToolCheck(checks, label('NSIS makensis'), 'makensis', ['/VERSION'], 'Install NSIS and make sure makensis is on PATH.'); return; } if (target === 'macos') { - addToolCheck(checks, 'plutil', 'plutil', ['-help'], 'Use macOS or install Xcode command line tools.'); - addToolCheck(checks, 'hdiutil', 'hdiutil', ['help'], 'Use macOS for DMG packaging.'); + addToolCheck(checks, label('plutil'), 'plutil', ['-help'], 'Use macOS or install Xcode command line tools.'); + addToolCheck(checks, label('hdiutil'), 'hdiutil', ['help'], 'Use macOS for DMG packaging.'); return; } @@ -975,12 +1044,12 @@ function addDesktopBuildChecks( add( checks, 'Build', - 'appimagetool', + label('appimagetool'), appImageTool && existsSync(appImageTool) ? 'pass' : 'fail', appImageTool || 'not found', appImageTool && existsSync(appImageTool) ? undefined : `Run \`feather build vendor add ${target} --dir ${projectDir}\`.`, ); - addToolCheck(checks, 'tar', 'tar', ['--version'], 'Install tar or use a shell with standard archive tools available.'); + addToolCheck(checks, label('tar'), 'tar', ['--version'], 'Install tar or use a shell with standard archive tools available.'); } function desktopRuntimeReady(runtimeDir: string, target: DoctorDesktopBuildTarget): boolean { @@ -995,6 +1064,11 @@ function desktopTargetLabel(target: DoctorDesktopBuildTarget): string { return target[0].toUpperCase() + target.slice(1); } +function prefixedCheckLabel(prefix: string, base: string, prefixed: boolean): string { + if (!prefixed || base === prefix || base.startsWith(`${prefix} `)) return base; + return `${prefix} ${base}`; +} + function addToolCheck(checks: DoctorCheck[], label: string, command: string, args: string[], fix: string): void { const version = commandVersion(command, args); add( diff --git a/cli/test/commands/doctor.test.mjs b/cli/test/commands/doctor.test.mjs index 971d69cb..778dad32 100644 --- a/cli/test/commands/doctor.test.mjs +++ b/cli/test/commands/doctor.test.mjs @@ -48,6 +48,7 @@ test('doctor --json reports unknown installed plugin trust', () => { const dir = makeTmp(); writeGame(dir); writeMinimalRuntime(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); writeLocalPluginSource(dir, 'custom-plugin'); const parsed = parseDoctorJson(dir); @@ -62,6 +63,7 @@ test('doctor --json reports dangerous bundled plugin trust', () => { const dir = makeTmp(); writeGame(dir); writeMinimalRuntime(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); const result = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); assert.equal(result.exitCode, 0, outputOf(result)); @@ -77,6 +79,7 @@ test('doctor --json warns about runtime symlinks escaping project', () => { const outside = join(makeTmp(), 'outside-runtime'); writeGame(dir); writeMinimalRuntime(outside); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); symlinkSync(join(outside, 'feather'), join(dir, 'feather'), 'dir'); const parsed = parseDoctorJson(dir); @@ -89,6 +92,7 @@ test('doctor --json remains decoration-free and reports missing plugin directory const dir = makeTmp(); writeGame(dir); writeMinimalRuntime(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); const parsed = parseDoctorJson(dir); const labels = new Map(parsed.checks.map((check) => [check.label, check])); assert.equal(labels.get('Plugin directory').severity, 'info'); @@ -99,6 +103,7 @@ test('doctor --json reports malformed plugin manifests with recovery text', () = const dir = makeTmp(); writeGame(dir); writeMinimalRuntime(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); mkdirSync(join(dir, 'feather', 'plugins', 'bad-plugin'), { recursive: true }); writeFileSync(join(dir, 'feather', 'plugins', 'bad-plugin', 'manifest.lua'), 'return { name = "Bad Plugin" }\n'); @@ -164,7 +169,7 @@ test('doctor --production fails unsafe remote-control and production settings', } }); -test('doctor --json keeps unsafe settings warning-oriented outside production', () => { +test('doctor --json makes missing Desktop App ID fail outside production', () => { const dir = makeTmp(); writeGame(dir); writeFileSync( @@ -179,14 +184,27 @@ test('doctor --json keeps unsafe settings warning-oriented outside production', ); const { result, parsed } = parseDoctorJsonResult(dir); - assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(result.exitCode, 1, outputOf(result)); assert.equal(parsed.production, false); const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('Desktop App ID')?.severity, 'fail'); assert.equal(labels.get('__DANGEROUS_INSECURE_CONNECTION__')?.severity, 'warn'); assert.equal(labels.get('Console API key')?.severity, 'warn'); assert.equal(labels.get('Network host exposure')?.severity, 'warn'); }); +test('doctor --json keeps missing Desktop App ID non-failing in disk mode', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { mode = "disk" }\n'); + + const { result, parsed } = parseDoctorJsonResult(dir); + assert.equal(result.exitCode, 0, outputOf(result)); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('Desktop App ID')?.severity, 'pass'); + assert.equal(labels.get('Desktop App ID')?.detail, 'not needed for disk mode'); +}); + test('doctor --security --json emits a sterile security report without secrets', () => { const dir = makeTmp(); const secret = 'StrongSecretValue1234567890!'; @@ -233,6 +251,7 @@ test('doctor --production fails unmanaged embedded runtime', () => { test('doctor: build and upload target checks report missing and configured dependencies', () => { const dir = makeTmp(); writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); writeBuildConfig(dir, { name: 'Doctor Build Game', version: '1.0.0', @@ -264,6 +283,7 @@ test('doctor: build and upload target checks report missing and configured depen test('doctor: desktop build targets report runtime vendors and packaging tools', () => { const dir = makeTmp(); writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); writeBuildConfig(dir, { name: 'Desktop Doctor Game', version: '1.0.0' }); const { parsed: missing } = parseDoctorJsonResult(dir, ['--build-target', 'windows']); @@ -300,9 +320,74 @@ test('doctor: desktop build targets report runtime vendors and packaging tools', } }); +test('doctor: build target all reports every platform with prefixed labels', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); + writeFakeLoveJs(dir); + writeFakeLoveAndroid(dir); + writeFakeLoveIos(dir); + const vendors = writeFakeDesktopRuntimeVendors(dir); + writeBuildConfig(dir, { + name: 'All Platforms Doctor Game', + version: '1.0.0', + productId: 'com.example.allplatformsdoctor', + targets: { + web: { loveJsDir: 'love.js' }, + android: { loveAndroidDir: 'love-android' }, + ios: { loveIosDir: 'love-ios', bundleIdentifier: 'com.example.allplatformsdoctor.ios' }, + windows: { loveRuntimeDir: vendors.windows }, + macos: { loveRuntimeDir: vendors.macos }, + linux: { loveRuntimeDir: vendors.linux }, + }, + }); + const { binDir } = writeFakeDesktopTools(dir); + writeFakeCommand(dir, 'java', `console.error('java version "17.0.0"'); process.exit(0);`); + writeFakeCommand(dir, 'xcodebuild', `console.log('Xcode 99.0'); process.exit(0);`); + + const result = run(['doctor', dir, '--json', '--build-target', 'all'], { + env: envWithPath(binDir, { ANDROID_HOME: join(dir, 'android-sdk') }), + }); + assert.equal(result.stdout.trim().startsWith('{'), true, outputOf(result)); + const parsed = JSON.parse(result.stdout); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('Build target')?.detail, 'all'); + assert.equal(labels.get('Build target')?.fix, 'checking love, web, android, ios, windows, macos, linux, steamos'); + assert.equal(labels.get('LÖVE package')?.severity, 'pass'); + assert.equal(labels.get('Web love.js player')?.severity, 'pass'); + assert.equal(labels.get('Android config')?.severity, 'pass'); + assert.equal(labels.get('Android love-android template')?.severity, 'pass'); + assert.equal(labels.get('Android JDK')?.severity, 'pass'); + assert.equal(labels.get('Android SDK')?.severity, 'pass'); + assert.equal(labels.get('iOS config')?.severity, 'pass'); + assert.equal(labels.get('iOS LÖVE template')?.severity, 'pass'); + assert.equal(labels.get('iOS xcodebuild')?.severity, 'pass'); + assert.equal(labels.get('Windows LÖVE runtime')?.severity, 'pass'); + assert.equal(labels.get('Windows NSIS makensis')?.severity, 'pass'); + assert.equal(labels.get('macOS LÖVE runtime')?.severity, 'pass'); + assert.equal(labels.get('macOS hdiutil')?.severity, 'pass'); + assert.equal(labels.get('Linux LÖVE runtime')?.severity, 'pass'); + assert.equal(labels.get('Linux appimagetool')?.severity, 'pass'); + assert.equal(labels.get('SteamOS LÖVE runtime')?.severity, 'pass'); + assert.equal(labels.get('SteamOS appimagetool')?.severity, 'pass'); +}); + +test('doctor: invalid build target mentions doctor-only all target', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); + + const { result, parsed } = parseDoctorJsonResult(dir, ['--build-target', 'badtarget']); + assert.equal(result.exitCode, 1); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('Build target')?.severity, 'fail'); + assert.ok(labels.get('Build target')?.fix.includes('all')); +}); + test('doctor: android build target reports template and local tool setup', () => { const dir = makeTmp(); writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); writeBuildConfig(dir, { name: 'Android Doctor Game', version: '1.0.0' }); const { parsed: missing } = parseDoctorJsonResult(dir, ['--build-target', 'android']); const missingLabels = new Map(missing.checks.map((check) => [check.label, check])); @@ -336,6 +421,7 @@ test('doctor: android build target reports template and local tool setup', () => test('doctor: mobile build target reports config validation failures', () => { const dir = makeTmp(); writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); writeFakeLoveAndroid(dir); writeBuildConfig(dir, { name: 'Doctor Bad Android', @@ -355,6 +441,7 @@ test('doctor: mobile build target reports config validation failures', () => { test('doctor: android release reports missing signing environment', () => { const dir = makeTmp(); writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); writeFakeLoveAndroid(dir); writeFileSync(join(dir, 'release.keystore'), 'fake keystore'); writeBuildConfig(dir, { @@ -385,6 +472,7 @@ test('doctor: android release reports missing signing environment', () => { test('doctor: ios build target reports platform, template, Xcode, and signing hints', () => { const dir = makeTmp(); writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); writeBuildConfig(dir, { name: 'iOS Doctor Game', version: '1.0.0' }); const { parsed: missing } = parseDoctorJsonResult(dir, ['--build-target', 'ios']); const missingLabels = new Map(missing.checks.map((check) => [check.label, check])); diff --git a/cli/test/commands/runtime.test.mjs b/cli/test/commands/runtime.test.mjs index dd82b4f1..e892735e 100644 --- a/cli/test/commands/runtime.test.mjs +++ b/cli/test/commands/runtime.test.mjs @@ -119,6 +119,7 @@ test('command runtime: FEATHER_DEBUG includes stack for unexpected errors', asyn test('json commands used by scripts stay parseable and decoration-free', () => { const dir = makeTmp(); writeGame(dir); + writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); const content = 'return {}'; mkdirSync(join(dir, 'lib'), { recursive: true }); writeFileSync(join(dir, 'lib', 'helper.lua'), content); From 8224df09765345157ccf579edecb501b23816aa9 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 21:43:43 -0400 Subject: [PATCH 55/68] feather: simplify minimal example --- README.md | 53 +++++++++++++++++++------------------------ docs/index.md | 63 ++++++++++++++++----------------------------------- 2 files changed, 43 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 8de98619..528d90f7 100644 --- a/README.md +++ b/README.md @@ -33,60 +33,53 @@ The goal is to make the day-to-day loop of writing and testing a LÖVE game fast --- -## Setup +## Quick Start -Install the CLI, then initialize your project: +Install the Feather desktop app and CLI: + +1. Download the desktop app from [Releases](https://github.com/Kyonru/feather/releases). +2. Install the CLI: ```sh npm install -g @kyonru/feather -feather init --mode cli ``` -Run your game with Feather loaded: +Initialize your project, open the Feather app, then run the game: ```sh +feather init path/to/my-game feather run path/to/my-game -# or -USE_DEBUGGER=1 love path/to/my-game ``` -For games running on external devices (iOS, Android, Steam Deck, another machine), embed the runtime directly: +Feather is injected automatically for local desktop runs, so your game code does not need a manual `require`. + +Optional vendor setup for web and mobile dev loops: ```sh -feather init path/to/my-game # copies the Lua runtime into your project -USE_DEBUGGER=1 love path/to/my-game -``` +feather build vendor add web --dir path/to/my-game +feather run path/to/my-game --target web -`feather init` creates a `feather.config.lua` in your project: +feather build vendor add android --dir path/to/my-game +feather run path/to/my-game --target android -```lua -return { - sessionName = "My Game", - -- For phones, tablets, Steam Deck, or another computer: - -- host = "192.168.1.50", - -- include = { "console" }, - -- exclude = { "hump.signal" }, -} +feather build vendor add ios --dir path/to/my-game +feather run path/to/my-game --target ios ``` -All generated game-side code is guarded so it only runs when `USE_DEBUGGER` is set: +For all build vendors, including desktop packaging runtimes: -```lua -function love.update(dt) - if DEBUGGER then - DEBUGGER:update(dt) - end -end +```sh +feather build vendor add all --dir path/to/my-game ``` -To strip Feather from a project before shipping: +For more commands and options: ```sh -feather remove --dry-run -feather remove --yes +feather --help +feather run --help ``` -Then download the desktop app from the [releases page](https://github.com/Kyonru/feather/releases). +See the [CLI docs](docs/cli.md) for `feather run`, `feather doctor`, `feather build`, and `feather upload`. --- diff --git a/docs/index.md b/docs/index.md index cce534a5..8d64e0e3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,70 +28,47 @@ Like Flipper or React DevTools, but for your game. Inspect logs, variables, perf ## Quick Start -> [!IMPORTANT] -> For quick local desktop iteration, use `feather run path/to/my-game` without changing game code. For web dev loops, `feather run path/to/my-game --target web` builds and serves a local love.js artifact. For Android and iOS dev loops, `feather run path/to/my-game --target android|ios` builds the configured native template, installs it, and launches it on a device or simulator. +Install the Feather desktop app and CLI: -### Option A — CLI injection (no game-side changes) +1. Download the desktop app from [Releases](https://github.com/Kyonru/feather/releases). +2. Install the CLI: ```bash npm install -g @kyonru/feather -feather init path/to/my-game -feather run path/to/my-game -feather run path/to/my-game --target web -feather run path/to/my-game --target android ``` -Feather is injected automatically. No `require` needed in the game. See [CLI](cli.md). - -> [!NOTE] -> This is best for desktop development where the CLI launches LÖVE directly. Android/iOS mobile run requires the build template setup from `feather build vendor add mobile`. - -### Option B — Managed in-game setup +Initialize your project, open the Feather app, then run the game: ```bash -npm install -g @kyonru/feather -feather init path/to/my-game --mode auto -USE_DEBUGGER=1 love path/to/my-game +feather init path/to/my-game +feather run path/to/my-game ``` -> [!IMPORTANT] -> Use this for handhelds, Steam Deck, or a second computer. Web can use `feather run --target web` once love.js is configured, and Android/iOS can use `feather run --target android|ios` once mobile build templates are configured. +Feather is injected automatically for local desktop runs, so your game code does not need a manual `require`. -`feather init` creates `feather.config.lua`: +### Optional Vendors -```lua -return { - sessionName = "My RPG", - -- Set to the desktop app machine's LAN IP for remote devices. - host = "192.168.1.50", - exclude = { "network-inspector" }, -} -``` +Vendor setup downloads the local LÖVE runtimes/templates needed by web and mobile targets, then updates `feather.build.json`. -> [!TIP] -> The generated `main.lua` integration is guarded by `USE_DEBUGGER`, so Feather is not imported unless you opt in for a dev run. +```bash +feather build vendor add web --dir path/to/my-game +feather run path/to/my-game --target web -When you access `DEBUGGER` in your own code, guard it: +feather build vendor add android --dir path/to/my-game +feather run path/to/my-game --target android -```lua -function love.update(dt) - if DEBUGGER then - DEBUGGER:update(dt) - end -end +feather build vendor add ios --dir path/to/my-game +feather run path/to/my-game --target ios ``` -Before shipping a production build: +For all build vendors, including desktop packaging runtimes: ```bash -feather doctor path/to/my-game --production -feather doctor path/to/my-game --build-target web --upload-target itch -feather build web --dir path/to/my-game -feather upload itch web --dir path/to/my-game --dry-run -feather remove --dry-run -feather remove --yes +feather build vendor add all --dir path/to/my-game ``` +See [CLI](cli.md) for `feather run`, `feather doctor`, `feather build`, and `feather upload` options. + --- ## Documentation From 99c1935813678811e7605d6560a82b36ee14b64b Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 21:57:38 -0400 Subject: [PATCH 56/68] feather: improve docs --- README.md | 22 +++- cli/README.md | 29 ++++- cli/test/commands/build-android.test.mjs | 2 + cli/test/commands/build-ios.test.mjs | 10 ++ docs/assets.md | 43 ++----- docs/debugger.md | 17 ++- docs/index.md | 27 ++++- docs/installation.md | 92 +++++++-------- docs/recommendations.md | 137 +++++++---------------- docs/usage.md | 35 +++++- 10 files changed, 202 insertions(+), 212 deletions(-) diff --git a/README.md b/README.md index 528d90f7..1c6f94ec 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Feather — CLI, Debugging & Inspection Tool for Löve2D +# Feather — CLI Debugging, Inspection, and Builds for LÖVE Feather is a CLI for debugging, inspecting, and manage packages for [LÖVE](https://love2d.org) games. @@ -19,10 +19,10 @@ The goal is to make the day-to-day loop of writing and testing a LÖVE game fast - **Console / REPL** — Execute Lua in the running game (opt-in, requires an `apiKey`). - **Plugin system** — 18+ built-in plugins (collision debug, animation inspector, audio debug, particle editor, and more). Plugins define their UI in Lua; the desktop app renders it automatically. - **Multi-session** — Connect multiple games at the same time. -- **Mobile debugging** — Auto-detected LAN IP for connecting phones, tablets, and Steam Deck. +- **Mobile and platform builds** — CLI-managed web, Android, iOS, Windows, macOS, Linux, and SteamOS workflows. - **Screenshots & GIF capture** — Built-in capture plugin. - **Log file viewer** — Open `.featherlog` files for offline inspection. -- **CLI** — No Lua changes needed to run and debug love2d games. +- **CLI** — No Lua changes needed to run, debug, build, or clean up love2d games. - **Package Manager** — Install packages from a curated list of popular love2D packages. --- @@ -51,9 +51,9 @@ feather init path/to/my-game feather run path/to/my-game ``` -Feather is injected automatically for local desktop runs, so your game code does not need a manual `require`. +Feather is injected by the CLI for dev runs and debug builds, so your game code does not need a manual `require` for any target. -Optional vendor setup for web and mobile dev loops: +Optional vendor setup for web, mobile, and packaged desktop workflows: ```sh feather build vendor add web --dir path/to/my-game @@ -72,6 +72,18 @@ For all build vendors, including desktop packaging runtimes: feather build vendor add all --dir path/to/my-game ``` +Build release artifacts from the same CLI flow: + +```sh +feather build love --dir path/to/my-game +feather build android --dir path/to/my-game --release +feather build ios --dir path/to/my-game --release +feather build windows --dir path/to/my-game +feather build macos --dir path/to/my-game +feather build linux --dir path/to/my-game +feather build steamos --dir path/to/my-game +``` + For more commands and options: ```sh diff --git a/cli/README.md b/cli/README.md index ad2193e2..171b2f88 100644 --- a/cli/README.md +++ b/cli/README.md @@ -362,12 +362,15 @@ Doctor passed with 1 warning. ### `feather build ` -Build a LÖVE game into local artifacts. V1 supports `web`, `android`, `ios`, `windows`, `macos`, `linux`, and `steamos`. Android and iOS default to development builds from local native template checkouts; `--release` produces signed/store-oriented mobile artifacts. Notarization, Play Console/App Store upload, and Steam upload are later phases. +Build a LÖVE game into local artifacts. Supported targets are `love`, `web`, `android`, `ios`, `windows`, `macos`, `linux`, and `steamos`. Android and iOS default to development builds from local native template checkouts; `--release` produces signed/store-oriented mobile artifacts without embedding Feather's debugger runtime. ```bash +feather build love --dir path/to/my-game feather build web --dir path/to/my-game feather build vendor add web --dir path/to/my-game feather build vendor add mobile --dir path/to/my-game +feather build vendor add desktop --dir path/to/my-game +feather build vendor add all --dir path/to/my-game feather build android --dir path/to/my-game feather build android --dir path/to/my-game --verbose feather build android --dir path/to/my-game --no-cache @@ -377,26 +380,29 @@ feather build android --dir path/to/my-game --release feather build ios --dir path/to/my-game feather build ios --dir path/to/my-game --verbose feather build ios --dir path/to/my-game --release +feather build windows --dir path/to/my-game +feather build macos --dir path/to/my-game feather build linux --dir path/to/my-game feather build steamos --dir path/to/my-game --json feather build web --dry-run feather build web --allow-unsafe ``` -Builds read `feather.build.json` from the project root. Missing config is allowed for simple desktop builds, but web builds need a local love.js player directory, mobile builds need local LÖVE native template paths, and uploads need store metadata. +Builds read `feather.build.json` from the project root. `love` builds can run without target-specific vendors. Web builds need a local love.js player directory, mobile builds need local LÖVE native template paths, desktop builds need local LÖVE runtime vendors, and uploads need store metadata. -To fetch web/mobile build vendors locally: +To fetch build vendors locally: ```bash feather build vendor add web feather build vendor add mobile +feather build vendor add desktop feather build vendor add all --json feather build vendor add android --ref 11.5 feather build vendor add ios --ref 11.5 --json feather build vendor list ``` -`build vendor add` clones LÖVE build vendor sources 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. Mobile 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. Web fetches `2dengine/love.js` into `vendor/love.js`. Android fetches `love2d/love-android` with submodules. iOS fetches `love2d/love` and installs the matching `love--apple-libraries.zip` into the Xcode tree. Desktop vendors download official LÖVE runtimes for Windows, macOS, and Linux; SteamOS reuses the Linux runtime unless configured separately. Mobile and desktop versions come from `loveVersion` or `--ref`, falling back to `11.5`; web defaults to the love.js `main` branch unless `--web-ref` or `--ref` is passed. ```json { @@ -441,6 +447,15 @@ feather build vendor list "provisioningProfileSpecifier": "My Game App Store", "teamId": "ABCDE12345" } + }, + "windows": { + "loveRuntimeDir": "vendor/love-windows" + }, + "macos": { + "loveRuntimeDir": "vendor/love-macos" + }, + "linux": { + "loveRuntimeDir": "vendor/love-linux" } }, "upload": { @@ -469,7 +484,8 @@ Build behavior: - `--release` on Android produces `.aab` and `.apk` artifacts; signing passwords are read from environment variables named in config - `--release` on iOS produces `.xcarchive` and `.ipa` artifacts through `xcodebuild archive` and `-exportArchive` - `--verbose` on Android/iOS shows staging steps, native workspace paths, Gradle/Xcode commands, and captured native tool output; JSON output stays decoration-free -- delegates desktop targets to `love-release`; `steamos` uses the Linux packaging path with Steam-friendly target naming +- packages Windows as a fused runtime zip and NSIS installer, macOS as `.app.zip` and `.dmg`, and Linux/SteamOS as `.AppImage` plus `.tar.gz` +- `steamos` uses the Linux runtime vendor by default with SteamOS artifact naming **Options:** @@ -490,13 +506,14 @@ Build behavior: | `--runtime-config` | Path to `feather.config.lua` for Android/iOS dev embedding. | | `--verbose` | Show Android/iOS native build commands and tool output. | -Run `feather doctor --build-target ` to see missing local dependencies and exact setup guidance before building. +Run `feather doctor --build-target ` to see missing local dependencies and exact setup guidance before building. Use `feather doctor --build-target all` to scan every platform in one pass. Mobile build notes: - Android builds expect `targets.android.loveAndroidDir` to point at a local love-android checkout with `gradlew`. - iOS builds expect `targets.ios.loveIosDir` to point at a local LÖVE iOS source tree with `platform/xcode/love.xcodeproj`. - `feather build vendor add mobile` fetches those template checkouts, but it does not install Android SDK, JDK, Xcode, or signing assets. +- Desktop builds expect `targets.windows.loveRuntimeDir`, `targets.macos.loveRuntimeDir`, and `targets.linux.loveRuntimeDir` to point at local runtime vendors. `feather build vendor add desktop` creates those directories. - Dev Android/iOS builds reuse cached copied native templates by default. Use `--no-cache` for a fresh temporary workspace, or `--clean` to remove both artifacts and cached state in the output directory. - Release Android/iOS builds use fresh native workspaces by default for reproducibility. - `feather doctor --build-target android --release` validates product id, Gradle wrapper, JDK, Android SDK, and signing env setup. diff --git a/cli/test/commands/build-android.test.mjs b/cli/test/commands/build-android.test.mjs index b2cc161d..565f1b80 100644 --- a/cli/test/commands/build-android.test.mjs +++ b/cli/test/commands/build-android.test.mjs @@ -182,6 +182,8 @@ test('build android --release: does not auto-embed Feather debugger runtime', () const entries = readStoredZipEntries(join(dir, 'builds', 'release-raw-android-1.0.0.love')); assert.equal(entries.has('.feather-main.lua'), false); assert.equal(entries.has('feather/auto.lua'), false); + assert.equal(entries.has('feather/core/debug_overlay.lua'), false); + assert.equal(entries.has('feather.config.lua'), false); }); test('build android: reuses dev native cache between builds', () => { diff --git a/cli/test/commands/build-ios.test.mjs b/cli/test/commands/build-ios.test.mjs index 71493a30..fac3581c 100644 --- a/cli/test/commands/build-ios.test.mjs +++ b/cli/test/commands/build-ios.test.mjs @@ -353,6 +353,11 @@ process.exit(0); assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'ipa'), true); assert.equal(existsSync(join(dir, 'builds', 'release-ios-6.0.0-ios.xcarchive')), true); assert.equal(existsSync(join(dir, 'builds', 'release-ios-6.0.0-ios.ipa')), true); + const loveEntries = readStoredZipEntries(join(dir, 'builds', 'release-ios-6.0.0.love')); + assert.equal(loveEntries.has('.feather-main.lua'), false); + assert.equal(loveEntries.has('feather/auto.lua'), false); + assert.equal(loveEntries.has('feather/core/debug_overlay.lua'), false); + assert.equal(loveEntries.has('feather.config.lua'), false); const record = JSON.parse(readFileSync(recordPath, 'utf8')); assert.ok(record.records[0].argv.includes('archive')); assert.ok(record.records[0].argv.includes('DEVELOPMENT_TEAM=TEAM12345')); @@ -412,6 +417,11 @@ process.exit(0); const parsed = JSON.parse(result.stdout); assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'ipa'), true); assert.equal(existsSync(join(dir, 'builds', 'unsigned-ios-1.2.3-ios.ipa')), true); + const loveEntries = readStoredZipEntries(join(dir, 'builds', 'unsigned-ios-1.2.3.love')); + assert.equal(loveEntries.has('.feather-main.lua'), false); + assert.equal(loveEntries.has('feather/auto.lua'), false); + assert.equal(loveEntries.has('feather/core/debug_overlay.lua'), false); + assert.equal(loveEntries.has('feather.config.lua'), false); const record = JSON.parse(readFileSync(recordPath, 'utf8')); assert.equal(record.records.length, 1); assert.ok(record.records[0].argv.includes('archive')); diff --git a/docs/assets.md b/docs/assets.md index b4f6ebd3..2b7196a8 100644 --- a/docs/assets.md +++ b/docs/assets.md @@ -14,28 +14,13 @@ Assets appear in the desktop app after they are loaded by the game. ## Setup -No extra setup is required beyond running Feather in debug mode: +No extra setup is required beyond running the game through the Feather CLI: -```lua -require("feather.auto") - -function love.update(dt) - DEBUGGER:update(dt) -end -``` - -Or with manual setup: - -```lua -local FeatherDebugger = require("feather") - -local debugger = FeatherDebugger({ - debug = true, -}) - -function love.update(dt) - debugger:update(dt) -end +```bash +feather run path/to/my-game +feather run path/to/my-game --target web +feather run path/to/my-game --target android +feather run path/to/my-game --target ios ``` Open the **Assets** tab in the desktop app to view the tracked catalog. @@ -45,18 +30,10 @@ Open the **Assets** tab in the desktop app to view the tracked catalog. Asset tracking is enabled by default. To disable it for a session: ```lua -require("feather.auto").setup({ - assetPreview = false, -}) -``` - -Or with manual setup: - -```lua -local debugger = FeatherDebugger({ - debug = true, +-- feather.config.lua +return { assetPreview = false, -}) +} ``` When disabled, Feather does not hook `love.graphics.newImage`, `love.graphics.newFont`, `love.audio.newSource`, or `love.draw` for asset previews. @@ -176,7 +153,7 @@ When zoom is above 100%, Feather draws a pixel grid over the preview. This is us ### The texture list is empty > [!IMPORTANT] -> Make sure the game loads images after Feather starts. If assets are loaded before `require("feather.auto")` or before creating `FeatherDebugger`, Feather cannot track those calls. +> Make sure the game loads images after Feather starts. When using `feather run`, the CLI starts Feather before your game's `main.lua` is loaded. ### Preview says the file is not available diff --git a/docs/debugger.md b/docs/debugger.md index 951d0cce..1be47474 100644 --- a/docs/debugger.md +++ b/docs/debugger.md @@ -11,18 +11,27 @@ It also hosts Feather's opt-in [Hot Reload](hot-reload.md) controls for selected ## Setup -### With auto.lua +### CLI-managed setup -The debugger is controlled by the `debugger` config option, not the plugin system. Enable it in auto setup: +The debugger is controlled by the `debugger` config option, not the plugin system. Enable it in `feather.config.lua`, then run through the CLI: ```lua -require("feather.auto").setup({ +return { debugger = true, -}) +} +``` + +```bash +feather run path/to/my-game +feather run path/to/my-game --target web +feather run path/to/my-game --target android +feather run path/to/my-game --target ios ``` ### Manual setup +Manual Lua integration is only needed for projects that intentionally vendor Feather themselves: + ```lua local debugger = FeatherDebugger({ debug = true, diff --git a/docs/index.md b/docs/index.md index 8d64e0e3..172253fe 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,7 @@ **Real-time debug & inspect tool for [LÖVE (love2d)](https://love2d.org) games.** -Like Flipper or React DevTools, but for your game. Inspect logs, variables, performance metrics, and errors in real-time over a WebSocket connection — with a built-in plugin system, step debugger, and zero-config setup. +Like Flipper or React DevTools, but for LÖVE game. Inspect logs, variables, performance metrics, and errors in real time over a WebSocket connection with a built-in plugin system, step debugger, and zero-config setup. --- @@ -19,9 +19,9 @@ Like Flipper or React DevTools, but for your game. Inspect logs, variables, perf - 🐛 **Step Debugger** — Breakpoints, step over/into/out, call stack, and local variable inspection. - 🖼️ **Asset inspector** — Browse loaded textures, fonts, and audio sources with previews, zoom, pan, and pixel grid. - 📁 **Log file viewer** — Open `.featherlog` files for offline inspection. -- 🖥️ **CLI-first workflow** — `feather init`, `feather run`, and `feather remove` manage setup and cleanup. -- 🚢 **Build/upload helpers** — `feather build` creates local web, mobile dev, and desktop artifacts and `feather upload itch` pushes them with Butler. -- ⚡ **Guarded in-game setup** — Generated imports load only when `USE_DEBUGGER` is enabled. +- 🖥️ **CLI-first workflow** — `feather init`, `feather run`, and `feather remove` manage setup and cleanup with no manual Lua integration. +- 🚢 **Build/upload helpers** — `feather build` creates `.love`, web, mobile, and desktop artifacts and `feather upload itch` pushes them with Butler. +- 🧹 **Self-cleaning setup** — Generated files are managed by Feather and can be previewed or removed before release. - 📦 **Config file support** — `feather.config.lua` keeps project settings outside game code. --- @@ -44,11 +44,14 @@ feather init path/to/my-game feather run path/to/my-game ``` -Feather is injected automatically for local desktop runs, so your game code does not need a manual `require`. +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. + +> [!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. ### Optional Vendors -Vendor setup downloads the local LÖVE runtimes/templates needed by web and mobile targets, then updates `feather.build.json`. +Vendor setup downloads the local LÖVE runtimes/templates needed by web, mobile, and packaged desktop targets, then updates `feather.build.json`. ```bash feather build vendor add web --dir path/to/my-game @@ -67,6 +70,18 @@ For all build vendors, including desktop packaging runtimes: feather build vendor add all --dir path/to/my-game ``` +Build release artifacts from the same CLI flow: + +```bash +feather build love --dir path/to/my-game --release +feather build android --dir path/to/my-game --release +feather build ios --dir path/to/my-game --release +feather build windows --dir path/to/my-game --release +feather build macos --dir path/to/my-game --release +feather build linux --dir path/to/my-game --release +feather build steamos --dir path/to/my-game --release +``` + See [CLI](cli.md) for `feather run`, `feather doctor`, `feather build`, and `feather upload` options. --- diff --git a/docs/installation.md b/docs/installation.md index 3f105f57..bc0efdc3 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,85 +1,69 @@ # Installation -## Option 1: CLI (Recommended — no game changes needed) +## CLI (Recommended — no game changes needed) -Install the `@kyonru/feather` npm package globally, then use `feather run` to inject Feather into any desktop love2d game without touching its source: +Install the Feather desktop app, install the `@kyonru/feather` npm package globally, then use the CLI for desktop, web, mobile, and packaged desktop workflows: ```bash npm install -g @kyonru/feather +feather init path/to/my-game feather run path/to/my-game ``` -A new session tab appears in the Feather desktop app automatically. No `require` calls, no `DEBUGGER:update(dt)` — the CLI handles everything. +A new session tab appears in the Feather desktop app automatically. No `require` calls, no `DEBUGGER:update(dt)` — the CLI handles runtime injection and setup. > [!NOTE] -> Use plain `feather run` for local desktop iteration where the CLI launches LÖVE directly. Use `feather run --target web` when you want a served love.js dev artifact. +> Use plain `feather run` for local desktop iteration where the CLI launches LÖVE directly. Use `feather run --target web|android|ios` when you want the CLI to build, serve, install, or launch a configured platform target. -> [!IMPORTANT] -> For Android and iOS dev loops, `feather run path/to/my-game --target android|ios` can build the configured native template, install it, and launch it. For handhelds, Steam Deck, or another computer, embed Feather into the game instead. +### Vendors and Platform Runs -### Embedded library for devices - -Use auto mode for handheld or remote device builds: +Add local LÖVE runtimes/templates once per project, then run or build those targets: ```bash -cd path/to/my-game -feather init --mode auto -``` - -Then set the desktop app machine as the connection target in `feather.config.lua`: - -```lua -return { - sessionName = "Steam Deck Test", +feather build vendor add web --dir path/to/my-game +feather run path/to/my-game --target web - -- IP address of the computer running the Feather desktop app. - host = "192.168.1.50", -} -``` +feather build vendor add android --dir path/to/my-game +feather run path/to/my-game --target android -Run the game with Feather enabled: +feather build vendor add ios --dir path/to/my-game +feather run path/to/my-game --target ios -```bash -# macOS / Linux / Steam Deck shell -USE_DEBUGGER=1 love . +feather build vendor add all --dir path/to/my-game ``` -> [!TIP] -> `feather run --target android` runs ADB reverse by default, so `host = "127.0.0.1"` can still work over USB. Web and mobile dev runs embed Feather and your selected `feather.config.lua` into the temporary `.love` archive; pass `--no-debugger` to build/serve/install raw source instead. For Wi-Fi devices, Steam Deck, or another computer, use the LAN IP shown in Feather Settings. - -See [CLI](cli.md) for all commands, flags, and `feather.config.lua` options. +`feather build vendor add all` also installs desktop runtime vendors for Windows, macOS, Linux, and SteamOS packaging. Vendor fetching does not install Android SDK, JDK, Xcode, NSIS, or signing assets. -Before sharing or packaging a managed project, run: +Check all platform readiness in one pass: ```bash -feather doctor path/to/my-game --production +feather doctor path/to/my-game --build-target all ``` -To check release dependencies for Feather's local build/upload helpers: +Common run and build commands: ```bash -feather doctor path/to/my-game --build-target web -feather doctor path/to/my-game --upload-target itch -``` - -`feather build` supports web, Android, iOS, Windows, macOS, Linux, and SteamOS artifacts. Web builds need a local love.js player directory configured in `feather.build.json`; Android/iOS builds need local love-android or LÖVE iOS template paths; desktop builds expect `love-release` on `PATH`. `feather upload itch` expects Butler on `PATH` and either `BUTLER_API_KEY` in CI or a local `butler login` session. +feather run path/to/my-game --target web +feather run path/to/my-game --target android +feather run path/to/my-game --target ios -For web/mobile setup, run `feather build vendor add web --dir path/to/my-game` for love.js or `feather build vendor add mobile --dir path/to/my-game` for Android/iOS templates. Both commands fetch vendor checkouts into `vendor/` and update `feather.build.json`. Then run `feather doctor path/to/my-game --build-target web`, `--build-target android`, or `--build-target ios`. Doctor checks the configured vendor paths, product/bundle id where relevant, local build tools, and the common environment variables before the build or run command stages or writes artifacts. Vendor fetching does not install Android SDK, JDK, Xcode, or signing assets. +feather build love --dir path/to/my-game +feather build android --dir path/to/my-game --release +feather build ios --dir path/to/my-game --release +feather build windows --dir path/to/my-game +feather build macos --dir path/to/my-game +feather build linux --dir path/to/my-game +feather build steamos --dir path/to/my-game +``` -Web/mobile dev run examples: +Release builds do not auto-embed Feather's debugger runtime. If you used a managed init mode while experimenting, Feather can clean up its own generated blocks/files before packaging: ```bash -feather run path/to/my-game --target web -feather run path/to/my-game --target web --web-port 3000 -feather run path/to/my-game --target web --no-debugger -feather run path/to/my-game --target android -feather run path/to/my-game --target android --device emulator-5554 -feather run path/to/my-game --target android --no-debugger -feather run path/to/my-game --target ios -feather run path/to/my-game --target ios --device +feather remove path/to/my-game --dry-run +feather remove path/to/my-game --yes ``` -Use `feather build android --release` for Android AAB/APK release artifacts and `feather build ios --release` for iOS archive/IPA artifacts. Keep signing file paths in `feather.build.json`, but put passwords in environment variables referenced by the config so secrets are not committed or printed. +See [CLI](cli.md) for all commands, flags, and `feather.config.lua` options. For CI or release scripts that need a security-only JSON report: @@ -89,7 +73,9 @@ feather doctor path/to/my-game --security --json --- -## Option 2: Install Script +## Advanced: Install Script + +The install script is an advanced/manual path. Prefer the CLI above unless you intentionally want to vendor the Lua runtime yourself. Download the core library and all plugins with a single command: @@ -121,7 +107,7 @@ FEATHER_INCLUDE_CONSOLE=1 bash -c "$(curl -sSf https://raw.githubusercontent.com FEATHER_INCLUDE_HOT_RELOAD=1 bash -c "$(curl -sSf https://raw.githubusercontent.com/Kyonru/feather/main/scripts/install-feather.sh)" ``` -## Option 3: Direct Download +## Advanced: Direct Download 1. Go to the [releases page](https://github.com/Kyonru/feather/releases) and download `feather-x.x.x.zip`. 2. Unzip and copy the `feather/` folder into your project, e.g. `lib/feather/`. @@ -131,7 +117,7 @@ FEATHER_INCLUDE_HOT_RELOAD=1 bash -c "$(curl -sSf https://raw.githubusercontent. local Feather = require "lib.feather" ``` -## Option 4: LuaRocks +## Advanced: LuaRocks ```bash luarocks install feather @@ -242,7 +228,7 @@ Run without arguments to see the full list of available plugins: bash install-plugin.sh ``` -After installing, register the plugins in your setup: +After installing, register the plugins in your manual setup: ```lua require("feather.auto").setup({ diff --git a/docs/recommendations.md b/docs/recommendations.md index 22ba9014..3cc84fc5 100644 --- a/docs/recommendations.md +++ b/docs/recommendations.md @@ -43,9 +43,11 @@ Setting `__DANGEROUS_INSECURE_CONNECTION__ = true` is your acknowledgment that t Set `apiKey` in the game config and match it in the Feather desktop app Settings. This gates access to the Console / REPL plugin (remote Lua execution): ```lua -local debugger = FeatherDebugger({ +-- feather.config.lua +return { + include = { "console" }, apiKey = "your-api-key", -}) +} ``` ### Console plugin @@ -55,51 +57,32 @@ local debugger = FeatherDebugger({ --- -## Safe `DEBUGGER` Usage +## CLI-Managed Debugging > [!IMPORTANT] -> `DEBUGGER` only exists when Feather actually loads. With CLI-managed setup, generated imports are guarded by `USE_DEBUGGER`, so production-like runs can leave Feather unloaded entirely. +> The CLI is the preferred workflow for desktop, web, Android, iOS, and packaged desktop builds. You do not need to add `require("feather.auto")`, call `DEBUGGER:update(dt)`, or keep Feather-specific code in your game. -Always guard direct `DEBUGGER` usage: +Use the CLI for local and platform runs: -```lua -function love.update(dt) - if DEBUGGER then - DEBUGGER:update(dt) - end -end -``` - -For custom actions: - -```lua -if DEBUGGER then - DEBUGGER:log("Player spawned") -end -``` - -Avoid this in game code: - -```lua -DEBUGGER:update(dt) +```bash +feather init path/to/my-game +feather run path/to/my-game +feather run path/to/my-game --target web +feather run path/to/my-game --target android +feather run path/to/my-game --target ios ``` -> [!CAUTION] -> That direct call will fail when `USE_DEBUGGER` is unset, when `debug = false`, or after running `feather remove`. - -For local/dev runs, enable Feather explicitly: +Add vendors for platform targets once per project: ```bash -USE_DEBUGGER=1 love . +feather build vendor add all --dir path/to/my-game ``` -For production builds, leave `USE_DEBUGGER` unset and run `feather remove --yes` before packaging. - Run doctor as a release gate before packaging: ```bash feather doctor path/to/my-game --production -feather doctor path/to/my-game --build-target web +feather doctor path/to/my-game --build-target all ``` `--production` exits with code `1` for release blockers such as insecure connections, weak Console auth, hot reload, debugger/screenshot/disk persistence settings, network exposure with weak auth, or unmanaged embedded Feather runtime. @@ -115,92 +98,55 @@ The security report includes config posture, plugin trust, package provenance, a If you use Feather's local release helpers, keep release metadata in `feather.build.json` and let doctor check the target-specific tools before CI runs the build: ```bash +feather build vendor add all --dir path/to/my-game --json feather build web --dir path/to/my-game --json -feather build vendor add web --dir path/to/my-game --json -feather build vendor add mobile --dir path/to/my-game --json +feather build android --dir path/to/my-game --release --json +feather build ios --dir path/to/my-game --release --json +feather build windows --dir path/to/my-game --json feather upload itch web --dir path/to/my-game --dry-run --json ``` -Web builds package a local love.js player; `build vendor add web` fetches love.js into `vendor/`; `build vendor add mobile` fetches official Android/iOS LÖVE templates; desktop targets delegate packaging to `love-release`; Itch uploads use Butler. Mobile release mode is opt-in with `--release` and produces Android AAB/APK or iOS archive/IPA artifacts. Play Console, App Store, and Steam upload are intentionally later phases until those release flows are hardened. +Web builds package a local love.js player; mobile targets use official LÖVE templates; desktop targets use local LÖVE runtime vendors. Mobile release mode is opt-in with `--release` and produces Android AAB/APK or iOS archive/IPA artifacts. Release builds do not auto-embed Feather's debugger runtime. --- ## Performance > [!WARNING] -> Feather is a development tool, not something you should ship in production builds. Use one of these approaches, from least to most thorough. - -### Level 1 — Disable at runtime (`debug = false`) +> Feather is a development tool, not something you should ship in production builds. Prefer CLI-managed runs and builds so release artifacts stay clean by default. -The `debug` flag makes Feather a no-op: no WebSocket connection, no hooks, `update()` returns immediately. The library is still bundled but consumes no CPU. - -```lua -local debugger = FeatherDebugger({ - debug = Config.IS_DEBUG, -}) -``` - -The files are dormant but present in the bundle. - -### Level 2 — Keep Feather managed by the CLI - -Prefer initializing Feather through the CLI so the generated files are easy to remove later: - -```bash -feather init --mode auto -# or -feather init --mode manual -``` - -Auto mode inserts marked `FEATHER-INIT` blocks in `main.lua`. Manual mode creates `feather.debugger.lua` and loads it from a marked block in `main.lua`. - -Both modes guard Feather imports with the `USE_DEBUGGER` environment variable. Run local/dev builds with: +### Release builds ```bash -# macOS / Linux -USE_DEBUGGER=1 love . -``` - -```powershell -# Windows PowerShell -$env:USE_DEBUGGER = "1" -love . -``` - -```bat -:: Windows cmd.exe -set USE_DEBUGGER=1 && love . +feather build android --dir path/to/my-game --release +feather build ios --dir path/to/my-game --release ``` -> [!NOTE] -> Leave `USE_DEBUGGER` unset, `0`, or `false` in production-like runs so Feather is not loaded at all. - -Before packaging a release, run: +`--release` disables automatic debugger embedding for mobile builds. The generated `.love` used for the release artifact contains the game source, not Feather's injected `.feather-main.lua`, `feather/auto.lua`, plugin files, or generated debug config. -```bash -feather remove -``` +### Self-cleaning managed files -Use `--dry-run` first to preview: +If you used `feather init` while experimenting, Feather can preview and remove its own generated files: ```bash -feather remove --dry-run +feather remove path/to/my-game --dry-run +feather remove path/to/my-game --yes ``` The remove command only edits generated `FEATHER-INIT` blocks and removes generated Feather files it can identify. If you want to keep part of the setup: ```bash -feather remove --keep-config -feather remove --keep-runtime -feather remove --keep-main +feather remove path/to/my-game --keep-config +feather remove path/to/my-game --keep-runtime +feather remove path/to/my-game --keep-main ``` > [!TIP] > This is the recommended workflow for most projects because Feather can clean up after itself before production packaging. -### Level 3 — Guard manual requires +### Advanced manual integration -If you wire Feather by hand, wrap the entire setup in a conditional so the library is never loaded in production: +Manual Lua integration is still available for unusual projects, but it is no longer the recommended path. If you wire Feather by hand, wrap the entire setup in a conditional so the library is never loaded in production: ```lua if Config.IS_DEBUG then @@ -214,9 +160,9 @@ end No Lua code runs and no globals are created in release builds. -### Level 4 — Exclude from the release build +### Manual exclusion -Since Feather installs into a single directory, excluding it is a single glob: +If you manually vendored Feather, excluding it is a single glob: **Manual zip:** @@ -226,13 +172,6 @@ zip -r MyGame.love . \ -x "lib/feather/*" ``` -**[love-release](https://github.com/MisterDA/love-release)** — add to `.love-release.yml`: - -```yaml -exclude: - - feather/ -``` - **[makelove](https://github.com/pfirsich/makelove)** — add to `makelove.toml`: ```toml @@ -243,7 +182,7 @@ exclude = ["feather/**"] > [!IMPORTANT] > No Feather code or assets are present in the release build. This also eliminates the Console plugin as an attack surface entirely. -If you use manual mode, also exclude or remove: +If you used manual mode, also exclude or remove: ```txt feather.debugger.lua diff --git a/docs/usage.md b/docs/usage.md index a3965c25..eb825220 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,8 +1,21 @@ # Usage -## Manual Setup +## Setup -For full control over which plugins are loaded: +Use the CLI for normal development. It injects Feather for desktop runs and embeds a temporary debug runtime for web/mobile dev runs without requiring game-side Lua integration: + +```bash +feather init path/to/my-game +feather run path/to/my-game +feather run path/to/my-game --target web +feather run path/to/my-game --target android +feather run path/to/my-game --target ios +``` + +The direct Lua API is still available for unusual projects that intentionally vendor Feather themselves. + +> [!WARNING] +> Manual setup can leave Feather code, remote debugging hooks, or powerful plugins such as Console in places you did not intend to ship. Use it only if you understand the security consequences of accidental or unintended use. Prefer the CLI-managed workflow for normal development and releases. ```lua local FeatherDebugger = require "feather" @@ -72,11 +85,13 @@ debugger:error("Something went wrong") The Console is an **opt-in plugin** for evaluating Lua code directly inside the running game. It is not included by default. +Enable it from `feather.config.lua`: + ```lua -require("feather.auto").setup({ +return { include = { "console" }, pluginOptions = { console = { evalEnabled = true } }, -}) +} ``` Once enabled, open the **Console** tab in the desktop app and type any Lua expression. Return values are shown inline; `print()` output is captured automatically. @@ -95,8 +110,12 @@ return love.graphics.getStats() The step debugger pauses game execution at any line and lets you inspect local variables, closure values, and the call stack from the **Debugger** tab. +Enable it from `feather.config.lua`: + ```lua -require("feather.auto").setup({ debugger = true }) +return { + debugger = true, +} ``` Click any line number in the source view to add a breakpoint. While paused, use **Continue**, **Step Over**, **Step Into**, and **Step Out** to navigate execution. @@ -119,8 +138,12 @@ Use **Select Folder** in the Assets tab to set the session's Game Root when the Time Travel records per-frame observer snapshots into a ring buffer and lets you scrub backwards through history to find exactly when a value changed. +Enable it from `feather.config.lua`: + ```lua -require("feather.auto").setup({ include = { "time-travel" } }) +return { + include = { "time-travel" }, +} ``` Open the **Time Travel** tab, click **Start Recording**, reproduce the bug, then click **Stop & Load** to fetch and scrub through the captured frames. From 7b7c02799c206bc962d0b31961be65f1192355e7 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 22:18:49 -0400 Subject: [PATCH 57/68] feather: v1 --- cli/package.json | 2 +- package-lock.json | 6 +++--- package.json | 4 ++-- src-lua/feather/init.lua | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/cli/package.json b/cli/package.json index e0562ff0..6b9e7fe7 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@kyonru/feather", - "version": "0.10.0", + "version": "1.0.0", "description": "CLI for Feather — run and debug Love2D games without touching your game code", "license": "EPL-2.0", "keywords": [ diff --git a/package-lock.json b/package-lock.json index 45c4daf1..d87b732f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "feather", - "version": "0.10.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "feather", - "version": "0.10.0", + "version": "1.0.0", "workspaces": [ "cli" ], @@ -82,7 +82,7 @@ }, "cli": { "name": "@kyonru/feather", - "version": "0.10.0", + "version": "1.0.0", "license": "EPL-2.0", "dependencies": { "chalk": "5.3.0", diff --git a/package.json b/package.json index e1a76ae1..78624ad4 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "feather", - "description": "Feather is a Love2D devtool to debug and inspect your game in real-time. It provides a web-based interface to view and manipulate game state, visualize physics, and more.", + "description": "Feather is a LÖVE game development toolkit with a desktop inspector, zero-config CLI, plugin system, build/upload helpers, and package management.", "private": true, - "version": "0.10.0", + "version": "1.0.0", "type": "module", "workspaces": [ "cli" diff --git a/src-lua/feather/init.lua b/src-lua/feather/init.lua index 3808a8a6..41ccbb53 100644 --- a/src-lua/feather/init.lua +++ b/src-lua/feather/init.lua @@ -20,7 +20,7 @@ local FeatherUI = require(FEATHER_PATH .. ".ui") local get_current_dir = require(FEATHER_PATH .. ".utils").get_current_dir local format = require(FEATHER_PATH .. ".utils").format -local FEATHER_VERSION_NAME = "0.10.0" +local FEATHER_VERSION_NAME = "1.0.0" local FEATHER_API = 5 local FEATHER_VERSION = { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index dc75af73..541b4416 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1057,7 +1057,7 @@ dependencies = [ [[package]] name = "feather" -version = "0.10.0" +version = "1.0.0" dependencies = [ "axum", "futures-util", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b8374936..f02660cc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "feather" -version = "0.10.0" +version = "1.0.0" description = "Feather is a Love2D devtool to debug and inspect your game in real-time. It provides a web-based interface to view and manipulate game state, visualize physics, and more." authors = ["kyonru"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 68c96647..b0160ff4 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Feather", - "version": "0.10.0", + "version": "1.0.0", "identifier": "com.kyonru.love.feather", "build": { "beforeDevCommand": "npm run dev", From 4371eee76621c8dbf3eb982330722aa7a1ac7738 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sat, 16 May 2026 23:19:40 -0400 Subject: [PATCH 58/68] feather: improve application opening and remove custom shell commands --- cli/src/index.ts | 4 +- package-lock.json | 10 - package.json | 1 - src-tauri/Cargo.lock | 73 ----- src-tauri/Cargo.toml | 1 - src-tauri/capabilities/default.json | 22 +- src-tauri/src/cli_status.rs | 330 +++++++++++++++++++++ src-tauri/src/lib.rs | 34 ++- src/pages/log/index.tsx | 15 +- src/pages/settings/index.tsx | 428 +++++++++++++++++++++++++--- src/store/settings.ts | 8 + 11 files changed, 771 insertions(+), 155 deletions(-) create mode 100644 src-tauri/src/cli_status.rs diff --git a/cli/src/index.ts b/cli/src/index.ts index 05e198b7..272c5a71 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node import { Command } from 'commander'; +import { readFileSync } from 'node:fs'; import { runCliAction } from './lib/command.js'; import { runCommand } from './commands/run.js'; import { initCommand } from './commands/init.js'; @@ -31,6 +32,7 @@ import type { InitMode } from './ui/init/index.js'; const program = new Command(); const initModes = new Set(['cli', 'auto', 'manual']); +const cliVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version as string; function parseInitMode(value: string): InitMode { if (!initModes.has(value)) { @@ -42,7 +44,7 @@ function parseInitMode(value: string): InitMode { program .name('feather') .description('Run and debug Love2D games with Feather — zero game-side changes required') - .version('0.7.0'); + .version(cliVersion); program .command('run [game-path] [game-args...]') diff --git a/package-lock.json b/package-lock.json index d87b732f..6db495db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,6 @@ "@tauri-apps/plugin-dialog": "2.7.1", "@tauri-apps/plugin-fs": "2.5.1", "@tauri-apps/plugin-opener": "2.5.4", - "@tauri-apps/plugin-shell": "2.3.5", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "gif.js": "0.2.0", @@ -5236,15 +5235,6 @@ "@tauri-apps/api": "^2.11.0" } }, - "node_modules/@tauri-apps/plugin-shell": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", - "integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.10.1" - } - }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", diff --git a/package.json b/package.json index 78624ad4..f6c5fcde 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,6 @@ "@tauri-apps/plugin-dialog": "2.7.1", "@tauri-apps/plugin-fs": "2.5.1", "@tauri-apps/plugin-opener": "2.5.4", - "@tauri-apps/plugin-shell": "2.3.5", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "gif.js": "0.2.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 541b4416..65dc2c72 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -957,15 +957,6 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "endi" version = "1.1.0" @@ -1069,7 +1060,6 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-opener", - "tauri-plugin-shell", "tokio", "tokio-tungstenite", "uuid", @@ -2607,16 +2597,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "os_pipe" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db335f4760b14ead6290116f2427bf33a14d4f0617d49f78a246de10c1831224" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - [[package]] name = "pango" version = "0.18.3" @@ -3594,44 +3574,12 @@ dependencies = [ "digest", ] -[[package]] -name = "shared_child" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" -dependencies = [ - "libc", - "sigchld", - "windows-sys 0.60.2", -] - [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "sigchld" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" -dependencies = [ - "libc", - "os_pipe", - "signal-hook", -] - -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - [[package]] name = "signal-hook-registry" version = "1.4.6" @@ -4086,27 +4034,6 @@ dependencies = [ "zbus 4.0.1", ] -[[package]] -name = "tauri-plugin-shell" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b9ffadec5c3523f11e8273465cacb3d86ea7652a28e6e2a2e9b5c182f791d25" -dependencies = [ - "encoding_rs", - "log", - "open", - "os_pipe", - "regex", - "schemars 0.8.22", - "serde", - "serde_json", - "shared_child", - "tauri", - "tauri-plugin", - "thiserror 2.0.12", - "tokio", -] - [[package]] name = "tauri-runtime" version = "2.7.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f02660cc..2a6f5b3f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,7 +21,6 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = [] } serde = { version = "1", features = ["derive"] } serde_json = "1" -tauri-plugin-shell = "2" tauri-plugin-opener = "2" tauri-plugin-fs = "2" tauri-plugin-dialog = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 88f53879..2f4a9cdd 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -7,23 +7,6 @@ ], "permissions": [ "core:default", - "shell:allow-open", - { - "identifier": "shell:allow-execute", - "allow": [ - { - "name": "code", - "cmd": "sh", - "args": [ - "-c", - { - "validator": "\\S*\\/?code\\s+--goto\\s+\\S+" - } - ], - "sidecar": false - } - ] - }, "opener:default", { "identifier": "opener:allow-open-path", @@ -38,6 +21,9 @@ "allow": [ { "url": "https://github.com/Kyonru/feather/releases/*" + }, + { + "url": "https://kyonru.github.io/feather/*" } ] }, @@ -63,4 +49,4 @@ "dialog:allow-save", "fs:allow-write-text-file" ] -} \ No newline at end of file +} diff --git a/src-tauri/src/cli_status.rs b/src-tauri/src/cli_status.rs new file mode 100644 index 00000000..8397c633 --- /dev/null +++ b/src-tauri/src/cli_status.rs @@ -0,0 +1,330 @@ +use serde::Serialize; +use serde_json::Value; +use std::{ + env, + ffi::OsStr, + path::{Path, PathBuf}, + process::Command, +}; + +const INSTALL_DOCS_URL: &str = "https://kyonru.github.io/feather/installation/"; +const CLI_DOCS_URL: &str = "https://kyonru.github.io/feather/cli/"; + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct CliStatus { + installed: bool, + path: Option, + version: Option, + source: Option, + node_version: Option, + npm_version: Option, + error: Option, + install_docs_url: String, + cli_docs_url: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CliProjectStatus { + cli: CliStatus, + project_dir: String, + doctor: Option, + build_doctor: Option, + vendors: Option, + errors: Vec, +} + +pub fn status(cli_path: Option) -> CliStatus { + let node_version = command_first_line("node", &["--version"]); + let npm_version = command_first_line("npm", &["--version"]); + + for (candidate, source) in cli_candidates(cli_path) { + let version = command_first_line(candidate.as_os_str(), &["--version"]); + if let Some(version) = version { + return CliStatus { + installed: true, + path: Some(candidate.to_string_lossy().to_string()), + version: Some(version), + source: Some(source), + node_version, + npm_version, + error: None, + install_docs_url: INSTALL_DOCS_URL.to_string(), + cli_docs_url: CLI_DOCS_URL.to_string(), + }; + } + } + + CliStatus { + installed: false, + path: None, + version: None, + source: None, + node_version, + npm_version, + error: Some( + "Feather CLI was not found on PATH or in common npm global locations.".to_string(), + ), + install_docs_url: INSTALL_DOCS_URL.to_string(), + cli_docs_url: CLI_DOCS_URL.to_string(), + } +} + +pub fn project_status(project_dir: String, cli_path: Option) -> CliProjectStatus { + let cli = status(cli_path); + let mut errors = Vec::new(); + let mut doctor = None; + let mut build_doctor = None; + let mut vendors = None; + + if let Some(path) = &cli.path { + doctor = run_json(path, &["doctor", &project_dir, "--json"], &mut errors); + build_doctor = run_json( + path, + &["doctor", &project_dir, "--json", "--build-target", "all"], + &mut errors, + ); + vendors = run_json( + path, + &["build", "vendor", "list", "--dir", &project_dir, "--json"], + &mut errors, + ); + } + + CliProjectStatus { + cli, + project_dir, + doctor, + build_doctor, + vendors, + errors, + } +} + +fn run_json(command: &str, args: &[&str], errors: &mut Vec) -> Option { + match Command::new(command).args(args).output() { + Ok(output) => { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if stdout.is_empty() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + errors.push(format!( + "{} returned no JSON output: {}", + args.join(" "), + stderr + )); + return None; + } + match serde_json::from_str::(&stdout) { + Ok(value) => Some(value), + Err(err) => { + errors.push(format!("{} returned invalid JSON: {}", args.join(" "), err)); + None + } + } + } + Err(err) => { + errors.push(format!("Failed to run {}: {}", args.join(" "), err)); + None + } + } +} + +fn command_first_line>(command: S, args: &[&str]) -> Option { + let output = Command::new(command).args(args).output().ok()?; + if !output.status.success() { + return None; + } + + let text = if output.stdout.is_empty() { + String::from_utf8_lossy(&output.stderr) + } else { + String::from_utf8_lossy(&output.stdout) + }; + text.lines() + .next() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) +} + +fn cli_candidates(cli_path: Option) -> Vec<(PathBuf, String)> { + let mut candidates = Vec::new(); + if let Some(path) = cli_path + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + candidates.push((PathBuf::from(path), "configured".to_string())); + } + + candidates.push((PathBuf::from("feather"), "PATH".to_string())); + for dir in common_bin_dirs() { + candidates.push(( + dir.join(executable_name("feather")), + "common npm bin".to_string(), + )); + } + + dedupe_candidates(candidates) +} + +fn common_bin_dirs() -> Vec { + let mut dirs = vec![ + PathBuf::from("/usr/local/bin"), + PathBuf::from("/opt/homebrew/bin"), + PathBuf::from("/usr/bin"), + ]; + if let Some(home) = env::var_os("HOME").map(PathBuf::from) { + dirs.push(home.join(".npm-global/bin")); + dirs.push(home.join(".volta/bin")); + dirs.push(home.join(".local/bin")); + } + dirs +} + +fn executable_name(name: &str) -> String { + if cfg!(windows) { + format!("{name}.cmd") + } else { + name.to_string() + } +} + +fn dedupe_candidates(candidates: Vec<(PathBuf, String)>) -> Vec<(PathBuf, String)> { + let mut seen = Vec::::new(); + let mut result = Vec::new(); + for (path, source) in candidates { + if seen.iter().any(|item| item == &path) { + continue; + } + seen.push(path.clone()); + result.push((path, source)); + } + result +} + +pub fn resolve_source_location(project_root: &str, relative_file: &str) -> Result { + let root = canonical_dir(project_root, "Project root")?; + let relative = Path::new(relative_file); + if relative.is_absolute() { + return Err("Source file must be relative to the project root.".to_string()); + } + if relative + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err("Source file cannot escape the project root.".to_string()); + } + + let candidate = root.join(relative); + let resolved = if candidate.exists() { + candidate + .canonicalize() + .map_err(|err| format!("Could not resolve source file: {err}"))? + } else { + let parent = candidate.parent().unwrap_or(&root); + let resolved_parent = canonical_dir(parent, "Source file parent")?; + resolved_parent.join( + candidate + .file_name() + .ok_or("Source file is missing a file name.")?, + ) + }; + + if !resolved.starts_with(&root) { + return Err("Source file is outside the project root.".to_string()); + } + + Ok(resolved) +} + +pub fn open_source_location( + editor_path: String, + project_root: String, + relative_file: String, + line: Option, +) -> Result<(), String> { + let editor = validate_editor_path(&editor_path)?; + let source = resolve_source_location(&project_root, &relative_file)?; + let line = line.unwrap_or(1).max(1); + let goto = format!("{}:{line}", source.to_string_lossy()); + + Command::new(editor) + .args(["--goto", &goto]) + .spawn() + .map_err(|err| format!("Could not open VS Code: {err}"))?; + + Ok(()) +} + +fn validate_editor_path(editor_path: &str) -> Result<&str, String> { + let editor = editor_path.trim(); + if editor.is_empty() { + return Err("Set a VS Code executable path before opening source files.".to_string()); + } + if editor.contains('\0') { + return Err("Editor executable path contains an invalid character.".to_string()); + } + if editor.split_whitespace().count() > 1 && !Path::new(editor).exists() { + return Err("Editor executable path must not include arguments.".to_string()); + } + Ok(editor) +} + +fn canonical_dir>(path: P, label: &str) -> Result { + let path = path.as_ref(); + if !path.exists() { + return Err(format!("{label} does not exist.")); + } + let canonical = path + .canonicalize() + .map_err(|err| format!("Could not resolve {label}: {err}"))?; + if !canonical.is_dir() { + return Err(format!("{label} must be a directory.")); + } + Ok(canonical) +} + +#[cfg(test)] +mod tests { + use super::{resolve_source_location, validate_editor_path}; + use std::{fs, path::PathBuf}; + + fn temp_dir(name: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("feather-cli-status-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn resolves_project_relative_file() { + let dir = temp_dir("valid"); + fs::write(dir.join("main.lua"), "print('hi')").unwrap(); + let resolved = resolve_source_location(dir.to_str().unwrap(), "main.lua").unwrap(); + assert!(resolved.ends_with("main.lua")); + } + + #[test] + fn rejects_parent_escape() { + let dir = temp_dir("escape"); + let err = resolve_source_location(dir.to_str().unwrap(), "../main.lua").unwrap_err(); + assert!(err.contains("escape")); + } + + #[test] + fn rejects_editor_arguments() { + let err = validate_editor_path("code --goto").unwrap_err(); + assert!(err.contains("arguments")); + } + + #[test] + fn accepts_existing_editor_path_with_spaces() { + let dir = temp_dir("editor spaces"); + let editor = dir.join("Code CLI"); + fs::write(&editor, "").unwrap(); + let validated = validate_editor_path(editor.to_str().unwrap()).unwrap(); + assert!(validated.ends_with("Code CLI")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f6e2a4f4..372871f3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod cli_status; mod ws_server; use std::{ @@ -39,6 +40,33 @@ fn get_local_ips() -> Vec { .collect() } +#[tauri::command] +async fn get_cli_status(cli_path: Option) -> Result { + tauri::async_runtime::spawn_blocking(move || cli_status::status(cli_path)) + .await + .map_err(|err| err.to_string()) +} + +#[tauri::command] +async fn get_cli_project_status( + project_dir: String, + cli_path: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || cli_status::project_status(project_dir, cli_path)) + .await + .map_err(|err| err.to_string()) +} + +#[tauri::command] +fn open_source_location( + editor_path: String, + project_root: String, + relative_file: String, + line: Option, +) -> Result<(), String> { + cli_status::open_source_location(editor_path, project_root, relative_file, line) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // Shared session map and app ID between the WS server and Tauri commands @@ -49,7 +77,6 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_shell::init()) .manage(sessions.clone()) .manage(app_id.clone()) .setup(move |app| { @@ -66,7 +93,10 @@ pub fn run() { ws_server::get_active_sessions, ws_server::close_session, ws_server::set_app_id, - get_local_ips + get_local_ips, + get_cli_status, + get_cli_project_status, + open_source_location ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/pages/log/index.tsx b/src/pages/log/index.tsx index 8c95301c..234867a4 100644 --- a/src/pages/log/index.tsx +++ b/src/pages/log/index.tsx @@ -11,13 +11,14 @@ import { useConfig } from '@/hooks/use-config'; import { Log, LogType, useLogs } from '@/hooks/use-logs'; import { LuaBlock, TraceViewer } from '@/components/code'; import { isWeb } from '@/utils/platform'; -import { Command } from '@tauri-apps/plugin-shell'; +import { invoke } from '@tauri-apps/api/core'; import { useSettingsStore } from '@/store/settings'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { useConfigStore } from '@/store/config'; import { useSessionStore } from '@/store/session'; import { useQueryClient } from '@tanstack/react-query'; import { sessionQueryKey } from '@/hooks/use-ws-connection'; +import { toast } from 'sonner'; export const columns: ColumnDef[] = [ { @@ -51,12 +52,14 @@ export function TraceBlock({ code, basePath }: { code: string; basePath: string return; } - // TODO: add support for other OS (Windows) - // TODO: add support for other editors - // TODO: Test on Linux - await Command.create('code', ['-c', `${textEditorPath} --goto ${basePath}/${file}:${line}`]).execute(); + await invoke('open_source_location', { + editorPath: textEditorPath, + projectRoot: basePath, + relativeFile: file, + line, + }); } catch (e) { - console.log(e); + toast.error(e instanceof Error ? e.message : String(e), { position: 'bottom-center' }); } }} /> diff --git a/src/pages/settings/index.tsx b/src/pages/settings/index.tsx index cb5cb1b5..06333b75 100644 --- a/src/pages/settings/index.tsx +++ b/src/pages/settings/index.tsx @@ -1,5 +1,8 @@ -import { useState } from 'react'; -import { Button } from '@/components/ui/button'; +import { useEffect, useMemo, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { open as openFolderDialog } from '@tauri-apps/plugin-dialog'; +import { Button, CopyButton } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; import { Dialog, DialogClose, @@ -11,17 +14,21 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Separator } from '@/components/ui/separator'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useSettingsStore } from '@/store/settings'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { useConfig } from '@/hooks/use-config'; import { useConfigStore } from '@/store/config'; import { useSessionStore } from '@/store/session'; import { MobileConnection } from '@/components/mobile-connection'; +import { openUrl } from '@/utils/linking'; +import { version as appVersion } from '../../../package.json'; import { ActivityIcon, CodeIcon, CopyIcon, + ExternalLinkIcon, EyeIcon, EyeOffIcon, FolderIcon, @@ -29,7 +36,63 @@ import { NetworkIcon, RefreshCwIcon, ShieldIcon, + TerminalIcon, } from 'lucide-react'; +import { toast } from 'sonner'; + +const INSTALL_DOCS_URL = 'https://kyonru.github.io/feather/installation/'; +const CLI_DOCS_URL = 'https://kyonru.github.io/feather/cli/'; + +function normalizeVersion(version?: string | null) { + return version?.trim().replace(/^v/i, '') ?? ''; +} + +type CliStatus = { + installed: boolean; + path?: string | null; + version?: string | null; + source?: string | null; + nodeVersion?: string | null; + npmVersion?: string | null; + error?: string | null; + installDocsUrl: string; + cliDocsUrl: string; +}; + +type DoctorCheck = { + group: string; + label: string; + severity: 'pass' | 'warn' | 'fail' | 'info'; + detail?: string; + fix?: string; +}; + +type DoctorResult = { + failures?: number; + warnings?: number; + checks?: DoctorCheck[]; +}; + +type VendorResult = { + vendors?: Array<{ + target: string; + configured: boolean; + exists: boolean; + valid: boolean; + detail: string; + configuredPath?: string; + relativePath?: string; + }>; +}; + +type CliProjectStatus = { + cli: CliStatus; + projectDir: string; + doctor?: DoctorResult | null; + buildDoctor?: DoctorResult | null; + vendors?: VendorResult | null; + errors: string[]; +}; function Section({ icon: Icon, @@ -41,13 +104,13 @@ function Section({ children: React.ReactNode; }) { return ( -
+
- {title} +

{title}

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

{children}

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

{currentStatus.path}

+ )} + {currentStatus?.error &&

{currentStatus.error}

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

Node

+

{currentStatus.nodeVersion ?? 'not found'}

+
+
+

npm

+

{currentStatus.npmVersion ?? 'not found'}

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

Doctor warnings

+

{summary.warnings ?? 0}

+
+
+

Doctor failures

+

{summary.failures ?? 0}

+
+
+ )} + + {vendorEntries.length > 0 && ( +
+ +
+ {vendorEntries.map((vendor) => ( +
+ {vendor.target} + + {vendor.valid ? 'ready' : vendor.exists ? 'incomplete' : 'missing'} + +
+ ))} +
+
+ )} + + {importantChecks.length > 0 && ( +
+ +
+ {importantChecks.map((check, index) => ( +
+
+ + {check.label} + {check.group} +
+ {check.detail &&

{check.detail}

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

{error}

+ ))} +
+ )} +
+ ); +} + export function SettingsModal() { const open = useSettingsStore((state) => state.open); const setOpen = useSettingsStore((state) => state.setOpen); @@ -307,48 +620,77 @@ export function SettingsModal() { return ( - - + + Settings Changes are applied immediately and persisted automatically. -
-
- -
- - + +
+ + + + General + + + + Connection + + + + Security + + + + CLI + + +
-
- - - - -
+
+ +
+ +
+
+ +
+
+ +
+
- + +
+ + + + + + +
+
-
- - - -
+ +
+ + + + + +
+
- - -
- -
- - - -
- -
-
+ +
+ +
+
+
+ - + {data.length > 0 && ( - - - {data.length.toLocaleString()} shown + + + {data.length.toLocaleString()} of {all.length.toLocaleString()} shown )} diff --git a/src/pages/plugins/content.tsx b/src/pages/plugins/content.tsx index 0d47b993..749b1d81 100644 --- a/src/pages/plugins/content.tsx +++ b/src/pages/plugins/content.tsx @@ -2,7 +2,6 @@ import { useConfigStore } from '@/store/config'; import { useSettingsStore } from '@/store/settings'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; @@ -34,9 +33,10 @@ import { import { downloadFile } from '@/utils/file'; import { isWeb } from '@/utils/platform'; import { convertFileSrc } from '@tauri-apps/api/core'; -import { Bookmark, ChevronRight, DownloadIcon, ExternalLink } from 'lucide-react'; +import { Bookmark, CheckIcon, ChevronRight, DownloadIcon, ExternalLink } from 'lucide-react'; import { ReactNode, useCallback, useEffect, useState } from 'react'; import { Badge } from '@/components/ui/badge'; +import { cn } from '@/utils/styles'; const isDirectImageSrc = (src: string) => src.startsWith('data:') || src.startsWith('blob:'); const isResolvedBinarySrc = (src: string) => src.startsWith('blob:') || src.startsWith('data:'); @@ -576,6 +576,48 @@ const renderUiLabel = (node: PluginUiNode, control: ReactNode) => { ); }; +function PluginUiCheckbox({ + node, + onParamsChange, +}: { + node: PluginUiNode; + onParamsChange?: (params: Record) => void; +}) { + const name = getUiControlName(node); + const [checked, setChecked] = useState(node.checked === true); + + useEffect(() => { + setChecked(node.checked === true); + }, [node.checked]); + + return ( +
+ + {node.description ?

{node.description}

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

{node.description}

: null} -
-
- ); + return ; } if (node.type === 'switch') { diff --git a/src/pages/plugins/index.tsx b/src/pages/plugins/index.tsx index 71ea5bee..bfecbab5 100644 --- a/src/pages/plugins/index.tsx +++ b/src/pages/plugins/index.tsx @@ -7,13 +7,13 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { usePlugin, usePluginAction } from '@/hooks/use-plugin'; import { useConfigStore } from '@/store/config'; import { DynamicIcon, IconName } from 'lucide-react/dynamic'; -import { useCallback, useRef } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { Link, useHref } from 'react-router'; import { PluginContent } from './content'; -import { Checkbox } from '@/components/ui/checkbox'; import { openUrl } from '@/utils/linking'; import { PuzzleIcon, TriangleAlertIcon } from 'lucide-react'; import { FEATHER_PLUGIN_API } from '@/constants/feather-api'; +import { cn } from '@/utils/styles'; type PluginActionDefinition = { label: string; @@ -153,6 +153,8 @@ const pickInputProps = (props?: Record) => { }; const PluginAction = ({ label, action, icon, type, value, onClick, onFileClick, onChange, props, grouped }: PluginActionProps) => { + const [checked, setChecked] = useState(value === 'true'); + if (type === 'button') { return ( ); } diff --git a/src/pages/settings/index.tsx b/src/pages/settings/index.tsx index 06333b75..3fae6daf 100644 --- a/src/pages/settings/index.tsx +++ b/src/pages/settings/index.tsx @@ -126,7 +126,7 @@ function SettingsTabContent({ value, children }: { value: string; children: Reac return ( -
{children}
+
{children}
); @@ -452,6 +452,7 @@ function CliStatusPanel() { const cliInstalled = projectStatus?.cli.installed ?? status?.installed ?? false; const currentStatus = projectStatus?.cli ?? status; const summary = projectStatus?.buildDoctor ?? projectStatus?.doctor; + const doctorProjectDir = projectStatus?.projectDir || projectDir; const cliVersionMismatch = cliInstalled && Boolean(currentStatus?.version) && @@ -580,7 +581,12 @@ function CliStatusPanel() { {importantChecks.length > 0 && (
- +
+ + {doctorProjectDir && ( +

Project: {doctorProjectDir}

+ )} +
{importantChecks.map((check, index) => (
From ae2fc25ec2c2e9a043a75f9014b131cfd725325c Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sun, 17 May 2026 00:00:36 -0400 Subject: [PATCH 60/68] feather: add empty state --- src/router.tsx | 87 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 80 insertions(+), 7 deletions(-) diff --git a/src/router.tsx b/src/router.tsx index a488e76a..5d7a901b 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -1,7 +1,15 @@ import { useEffect } from 'react'; import { BrowserRouter, Routes, Route } from 'react-router'; import { toast } from 'sonner'; -import { MonitorIcon, SettingsIcon } from 'lucide-react'; +import { + BookOpenIcon, + CopyIcon, + ExternalLinkIcon, + MonitorIcon, + PlayIcon, + SettingsIcon, + TerminalIcon, +} from 'lucide-react'; import { AppSidebar } from '@/components/app-sidebar'; import { SiteHeader } from '@/components/site-header'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; @@ -22,6 +30,11 @@ import { AboutModal } from './pages/about'; import { useWsConnection } from './hooks/use-ws-connection'; import { useSessionStore } from './store/session'; import { useSettingsStore } from './store/settings'; +import { copyToClipboardWithMeta } from './utils/strings'; +import { openUrl } from './utils/linking'; + +const INSTALL_DOCS_URL = 'https://kyonru.github.io/feather/installation/'; +const CLI_DOCS_URL = 'https://kyonru.github.io/feather/cli/'; const Modals = () => { const disconnected = useConfigStore((state) => state.disconnected); @@ -52,11 +65,13 @@ const Modals = () => { function SessionEmptyState() { const sessions = useSessionStore((state) => state.sessions); const setSettingsOpen = useSettingsStore((state) => state.setOpen); + const cliProjectDir = useSettingsStore((state) => state.cliProjectDir); const hasSessions = Object.keys(sessions).length > 0; + const runCommand = `feather run ${cliProjectDir || 'path/to/my-game'}`; return ( -
-
+
+
@@ -69,10 +84,68 @@ function SessionEmptyState() {

{!hasSessions && ( - + <> +
+ + + + +
+

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

+ )}
From b9b1ad856a21e2f2da35e342e8db38def032432c Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sun, 17 May 2026 00:09:08 -0400 Subject: [PATCH 61/68] feather: add more info in the settings screen --- src/pages/about/index.tsx | 62 +++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/src/pages/about/index.tsx b/src/pages/about/index.tsx index 62c47829..7e306154 100644 --- a/src/pages/about/index.tsx +++ b/src/pages/about/index.tsx @@ -1,9 +1,4 @@ -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { useAboutStore } from '@/store/about'; import { openUrl } from '@/utils/linking'; @@ -18,13 +13,18 @@ import { ZapIcon, PlugZapIcon, MonitorIcon, + StarIcon, + ComputerIcon, } from 'lucide-react'; const FEATURES = [ { icon: ScrollTextIcon, text: 'Live logs, errors & stack traces' }, { icon: ZapIcon, text: 'Real-time performance & variable inspection' }, - { icon: PlugZapIcon, text: '18 built-in plugins — screenshots, REPL, profiler & more' }, + { icon: PlugZapIcon, text: '20+ built-in plugins — screenshots, REPL, profiler & more' }, { icon: MonitorIcon, text: 'Multi-session · mobile · disk mode' }, + { icon: ComputerIcon, text: 'CLI and build tools' }, + { icon: BookOpenIcon, text: 'Extensive docs, examples & API reference' }, + { icon: StarIcon, text: 'Open sourced with ❤️ by the community' }, ]; export function AboutModal() { @@ -41,8 +41,7 @@ export function AboutModal() { About Feather - {/* Hero */} -
+
🪶

Feather

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

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

New version available

-

- Update to get the latest features and fixes. -

+

Update to get the latest features and fixes.

- - From 3bc61bd4aa37b2ddf0f80a49ff82bdfe1c13b524 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sun, 17 May 2026 00:18:45 -0400 Subject: [PATCH 62/68] feather: update changelog --- CHANGELOG.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ ROADMAP.md | 26 +------------------------- 2 files changed, 46 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f5c6512..5bba21d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,50 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v1.0.0] - 2026-05-17 - The one with platform builds + +### Added + +- **Build pipeline** — `feather build` now supports `.love`, web, Android, iOS, Windows, macOS, Linux, and SteamOS targets. + - Added mobile release build support for Android and iOS. + - Added desktop runtime packaging for Windows, macOS, Linux, and SteamOS. + - Added web build output and web run support. + - Added vendor management with `feather build vendor add/list` for build templates and runtimes. +- **Upload pipeline** — added `feather upload` support for publishing build artifacts, including itch.io workflows. +- **Run targets** — `feather run` can now launch desktop, web, Android, and iOS development flows. + - Added mobile run cache support to speed up repeated Android/iOS iteration. + - Added Android/iOS device and simulator launch helpers. +- **CLI doctor expansion** — `feather doctor` now includes deeper environment, project, security, production, vendor, and build-target checks. + - Added `--production`, security-focused checks, and build-target diagnostics. + - Added structured JSON diagnostics used by the desktop app. +- **Desktop CLI awareness** — Settings now detects the Feather CLI, reports CLI version/path/source, checks Node/npm, and runs read-only project doctor/vendor summaries. + - Added CLI path override and project directory selection. + - Added CLI/Desktop version mismatch warnings. + - Added docs links and copyable fix commands for missing setup. +- **Safe editor launch** — replaced shell-based source opening with a dedicated Tauri command that validates editor paths and project-relative file locations before launching VS Code. +- **First-run guidance** — added no-session setup prompts for installing the CLI, opening docs, connecting a LÖVE project, and copying `feather run`. +- **Package catalog growth** — added packages including baton, beehive, cargo, g3d, knife, love-dialogue, lovebpm, lua-state-machine, shove, SYSL-text, and tiny-ecs. +- **Custom package installs** — added `feather package add` workflows for custom packages, branch/commit-based sources, submodules, and provenance metadata. +- **CLI tooling and tests** — added package helper scripts and a broader CLI command test suite for build, doctor, package, plugin, run, runtime, and upload commands. + +### Changed + +- Refactored CLI command structure, shared UI components, output formatting, and interactive workflows for init, package, plugin, remove, and update commands. +- Hardened package, plugin, and filesystem mutation paths with path safety checks, provenance tracking, trust validation, redaction, and more auditable output. +- Improved lockfile handling for package installs and audits. +- Improved package registry generation and switched package source references toward pinned commit hashes. +- Improved documentation for installation, usage, assets, debugger, recommendations, CLI workflows, and minimal examples. +- Refined desktop settings into tabbed sections with CLI, assets, editor, connection, and security controls. +- Improved light/dark theming, plugin sidebar states, plugin button-style inputs, observability controls, and empty states. +- Updated Lua runtime/plugin internals, including safer `auto.lua` loading, plugin manager improvements, and debug overlay support. + +### Fixed + +- Fixed custom package add behavior and terminal input cleanup. +- Fixed iOS build behavior and error messages. +- Removed Lua `goto` usage that could trigger runtime errors. +- Removed frontend dependency on arbitrary shell execution for opening source locations. + ## [v0.10.0] - 2026-05-13 - The one with the internal package manager ### Added @@ -353,6 +397,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - LuaRocks package. - GitHub Actions CI. +[v1.0.0]: https://github.com/Kyonru/feather/compare/v0.10.0...v1.0.0 [v0.10.0]: https://github.com/Kyonru/feather/compare/v0.9.3...v0.10.0 [v0.9.3]: https://github.com/Kyonru/feather/compare/v0.9.2...v0.9.3 [v0.9.2]: https://github.com/Kyonru/feather/compare/v0.9.0...v0.9.2 diff --git a/ROADMAP.md b/ROADMAP.md index 079e01ed..7579fc02 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -60,7 +60,7 @@ Turn Feather into a platform instead of only a debugger. --- -## 1.0.0 — Trust Release +## Beyond v1 — Trust Release Focus: Production-ready release with security, trust, and community adoption. @@ -69,13 +69,9 @@ Production-ready release with security, trust, and community adoption. - [ ] Security pass - [ ] Remote debugging encryption - - [ ] Command validation - - [ ] File/folder restrictions - - [ ] OS script protections ### Platform Readiness -- [ ] Add developer signature to avoid installation warnings - [ ] Add privacy policy - [ ] Improve crash recovery - [ ] Final compatibility validation @@ -114,23 +110,3 @@ Production-ready release with security, trust, and community adoption. - [ ] Optimize screenshot/memory handling --- - -## Ideas - -- Being able to generate (maybe from a couple of templates \ pre made docker images) android and ios projects where lua code can be embedded, similar to how expo for react native works. (explore how to make hot reloading possible, but not needed for first iteration). - - `feather build android` - - `feather build ios` - - `feather build steamos` - - `feather build web` -- Ideally when building android and ios, the project should use cache from previous build to reduce the amount of time it takes to see lua code in mobile. -- Step by step setup guide for Steam deck (what legally is allowed). -- Release command, takes care of removing feather if integrated, and prepare builds with release flavors - - `feather release android` -- Interactive itch.io \ steam releases (also non-interactive) - - `feather upload itch.io` -- feather needs to be able to read from a secret file and inject them to builds if possible. - - Doctor should say what's missing to be able to use these commands (sdks, where to get them, etc). - - maybe a `feather.yml` to configure the cli -- Easy integration with CLI - -Goals: Being as close as possible to expo From 33a460b65f9dd42c3fe1ddb423073a66e5056612 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sun, 17 May 2026 00:39:55 -0400 Subject: [PATCH 63/68] cli: upload workflow --- cli/src/commands/upload.ts | 141 ++++++++++++- cli/src/index.ts | 22 +- cli/src/lib/build/upload-safety.ts | 125 +++++++++++ cli/src/lib/build/upload.ts | 22 +- cli/src/ui/upload-workflow.tsx | 326 +++++++++++++++++++++++++++++ cli/test/commands/upload.test.mjs | 163 ++++++++++++++- 6 files changed, 793 insertions(+), 6 deletions(-) create mode 100644 cli/src/lib/build/upload-safety.ts create mode 100644 cli/src/ui/upload-workflow.tsx diff --git a/cli/src/commands/upload.ts b/cli/src/commands/upload.ts index 47a16c47..4d8ab08c 100644 --- a/cli/src/commands/upload.ts +++ b/cli/src/commands/upload.ts @@ -2,32 +2,150 @@ import { fail } from '../lib/command.js'; import { createSpinner, printBlank, + printDanger, printJson, printKeyValues, printMuted, printStatus, + printWarning, style, } from '../lib/output.js'; -import { isUploadTarget, uploadTargets, type UploadTarget } from '../lib/build/config.js'; +import { buildTargets, isBuildTarget, isUploadTarget, uploadTargets, type BuildTarget, type UploadTarget } from '../lib/build/config.js'; +import { runBuild } from '../lib/build/build.js'; import { runUpload } from '../lib/build/upload.js'; +import { chooseUploadWorkflow, type UploadWorkflowResult } from '../ui/upload-workflow.js'; +import type { UploadSafetyResult } from '../lib/build/upload-safety.js'; export type UploadCommandOptions = { dir?: string; config?: string; buildDir?: string; + project?: string; channel?: string; userVersion?: string; dryRun?: boolean; ifChanged?: boolean; hidden?: boolean; json?: boolean; + yes?: boolean; + build?: boolean; + outDir?: string; + release?: boolean; + allowUnsafe?: boolean; + clean?: boolean; + noCache?: boolean; + verbose?: boolean; + allowFeatherRuntime?: boolean; }; -export async function uploadCommand(targetValue: string, buildTarget: string | undefined, opts: UploadCommandOptions = {}): Promise { +type ResolvedUploadInput = { + targetValue: string; + buildTarget?: string; + opts: UploadCommandOptions; + confirmedUnsafe?: boolean; +}; + +function isInteractive(opts: UploadCommandOptions): boolean { + return Boolean(!opts.json && process.stdin.isTTY && process.stdout.isTTY); +} + +function printSafetyWarning(safety: UploadSafetyResult, warning: string): void { + printBlank(); + printDanger('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!'); + printDanger('! UPLOAD SAFETY WARNING !'); + printDanger('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!'); + printWarning(warning); + printKeyValues([ + ['Status', safety.status], + ['Artifact', safety.artifact], + ['Detected', safety.detectedFiles.length ? safety.detectedFiles.join(', ') : safety.reason ?? 'unknown'], + ]); + printDanger('Review this upload before sharing it with players.'); +} + +async function resolveUploadInput( + targetValue: string | undefined, + buildTarget: string | undefined, + opts: UploadCommandOptions, +): Promise { + if (!targetValue && isInteractive(opts)) { + const result = await chooseUploadWorkflow({ + dir: opts.dir, + config: opts.config, + buildDir: opts.buildDir, + project: opts.project, + channel: opts.channel, + userVersion: opts.userVersion, + }); + if (result.cancelled) fail('Upload cancelled.'); + return { + targetValue: result.target, + buildTarget: result.buildTarget, + confirmedUnsafe: result.allowFeatherRuntime, + opts: { + ...opts, + dir: result.dir, + config: result.config, + buildDir: result.buildDir, + project: result.project, + channel: result.channel, + userVersion: result.userVersion, + dryRun: result.dryRun, + build: result.build, + allowFeatherRuntime: result.allowFeatherRuntime, + yes: result.confirmed, + }, + }; + } + + if (!targetValue) { + fail('Upload target is required in non-interactive mode. Example: feather upload itch web --dir path/to/game --yes'); + } + + return { targetValue, buildTarget, opts }; +} + +export async function uploadCommand(targetValue: string | undefined, buildTarget: string | undefined, opts: UploadCommandOptions = {}): Promise { + const resolved = await resolveUploadInput(targetValue, buildTarget, opts); + targetValue = resolved.targetValue; + buildTarget = resolved.buildTarget; + opts = resolved.opts; + if (!isUploadTarget(targetValue)) { fail(`Unknown upload target: ${targetValue}`, { details: [`Available: ${uploadTargets.join(', ')}`] }); } const target: UploadTarget = targetValue; + if (buildTarget && !isBuildTarget(buildTarget)) { + fail(`Unknown build target: ${buildTarget}`, { details: [`Available: ${buildTargets.join(', ')}`] }); + } + if (!opts.dryRun && !opts.yes && !isInteractive(opts)) { + fail('Refusing to upload without --yes in non-interactive mode.'); + } + if (opts.build) { + if (!buildTarget || !isBuildTarget(buildTarget)) { + fail('A build target is required with --build. Example: feather upload itch web --build'); + } + const verbose = Boolean(opts.verbose && !opts.json && !opts.dryRun); + const buildSpinner = opts.json || opts.dryRun || verbose ? null : createSpinner(`Building ${buildTarget}…`).start(); + const buildResult = runBuild({ + target: buildTarget as BuildTarget, + projectDir: opts.dir, + configPath: opts.config, + outDir: opts.outDir, + clean: opts.clean, + dryRun: false, + allowUnsafe: opts.allowUnsafe, + release: opts.release, + noCache: opts.noCache, + verbose, + log: verbose ? printMuted : undefined, + }); + if (!buildResult.ok) { + buildSpinner?.fail(buildResult.error); + fail(buildResult.error, { silent: Boolean(buildSpinner) }); + } + buildSpinner?.succeed(`Built ${buildTarget}`); + } const spinner = opts.json || opts.dryRun ? null : createSpinner(`Uploading to ${target}…`).start(); const result = runUpload({ target, @@ -35,15 +153,31 @@ export async function uploadCommand(targetValue: string, buildTarget: string | u projectDir: opts.dir, configPath: opts.config, buildDir: opts.buildDir, + project: opts.project, channel: opts.channel, userVersion: opts.userVersion, dryRun: opts.dryRun, ifChanged: opts.ifChanged, hidden: opts.hidden, + allowFeatherRuntime: opts.allowFeatherRuntime || resolved.confirmedUnsafe, }); if (!result.ok) { spinner?.fail(result.error); + if (opts.json) { + printJson(result); + process.exitCode = 1; + return; + } + if (result.safety) { + printBlank(); + printKeyValues([ + ['Safety', result.safety.status], + ['Artifact', result.safety.artifact], + ['Reason', result.safety.reason], + ['Detected', result.safety.detectedFiles.join(', ')], + ]); + } fail(result.error, { silent: Boolean(spinner) }); } @@ -70,4 +204,7 @@ export async function uploadCommand(targetValue: string, buildTarget: string | u printBlank(); printMuted(`Command: ${result.command.join(' ')}`); } + if (!result.dryRun && result.warning) { + printSafetyWarning(result.safety, result.warning); + } } diff --git a/cli/src/index.ts b/cli/src/index.ts index 272c5a71..4b9e977a 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -278,27 +278,47 @@ buildVendor }))); program - .command('upload [build-target]') + .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 diff --git a/cli/src/lib/build/upload-safety.ts b/cli/src/lib/build/upload-safety.ts new file mode 100644 index 00000000..93865b11 --- /dev/null +++ b/cli/src/lib/build/upload-safety.ts @@ -0,0 +1,125 @@ +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { extname, join, relative } from 'node:path'; + +export type UploadSafetyStatus = 'safe' | 'unsafe' | 'unknown'; + +export type UploadSafetyResult = { + status: UploadSafetyStatus; + artifact: string; + detectedFiles: string[]; + reason?: string; +}; + +const FEATHER_PATTERNS = [ + /^feather(?:\/|$)/, + /^plugins(?:\/|$)/, + /^\.feather-main\.lua$/, + /^feather\.config\.lua$/, + /^feather\.debugger\.lua$/, + /^feather-build-manifest\.json$/, +]; + +function isFeatherPath(path: string): boolean { + const normalized = path.replace(/\\/g, '/').replace(/^\/+/, ''); + return FEATHER_PATTERNS.some((pattern) => pattern.test(normalized)); +} + +function inspectNames(artifact: string, names: string[]): UploadSafetyResult { + const detectedFiles = names.filter(isFeatherPath).sort((a, b) => a.localeCompare(b)); + return { + status: detectedFiles.length > 0 ? 'unsafe' : 'safe', + artifact, + detectedFiles, + }; +} + +function listDirectoryNames(root: string): string[] { + const names: string[] = []; + const visit = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const abs = join(dir, entry.name); + const rel = relative(root, abs).replace(/\\/g, '/'); + names.push(entry.isDirectory() ? `${rel}/` : rel); + if (entry.isDirectory()) visit(abs); + } + }; + visit(root); + return names; +} + +function zipEntryNames(zip: Buffer): string[] { + const names: string[] = []; + let offset = centralDirectoryOffset(zip); + while (offset + 46 <= zip.length) { + const signature = zip.readUInt32LE(offset); + if (signature !== 0x02014b50) break; + const compressedSize = zip.readUInt32LE(offset + 20); + const uncompressedSize = zip.readUInt32LE(offset + 24); + const nameLength = zip.readUInt16LE(offset + 28); + const extraLength = zip.readUInt16LE(offset + 30); + const commentLength = zip.readUInt16LE(offset + 32); + const localHeaderOffset = zip.readUInt32LE(offset + 42); + if (compressedSize === 0xffffffff || uncompressedSize === 0xffffffff || localHeaderOffset === 0xffffffff) { + throw new Error('ZIP64 archives are not supported for upload safety inspection.'); + } + const nameStart = offset + 46; + names.push(zip.subarray(nameStart, nameStart + nameLength).toString('utf8')); + offset = nameStart + nameLength + extraLength + commentLength; + } + return names; +} + +function centralDirectoryOffset(zip: Buffer): number { + const minOffset = Math.max(0, zip.length - 65557); + for (let offset = zip.length - 22; offset >= minOffset; offset -= 1) { + if (zip.readUInt32LE(offset) === 0x06054b50) { + return zip.readUInt32LE(offset + 16); + } + } + throw new Error('central directory not found'); +} + +export function inspectUploadArtifact(artifact: string): UploadSafetyResult { + if (!existsSync(artifact)) { + return { + status: 'unknown', + artifact, + detectedFiles: [], + reason: 'artifact does not exist', + }; + } + + const stat = statSync(artifact); + if (stat.isDirectory()) { + return inspectNames(artifact, listDirectoryNames(artifact)); + } + + const ext = extname(artifact).toLowerCase(); + if (ext === '.love' || ext === '.zip') { + try { + return inspectNames(artifact, zipEntryNames(readFileSync(artifact))); + } catch (error) { + return { + status: 'unknown', + artifact, + detectedFiles: [], + reason: `could not inspect ZIP archive: ${(error as Error).message}`, + }; + } + } + + return { + status: 'unknown', + artifact, + detectedFiles: [], + reason: `artifact type "${ext || 'unknown'}" cannot be safely inspected`, + }; +} + +export function uploadSafetyWarning(safety: UploadSafetyResult): string | null { + if (safety.status === 'safe') return null; + if (safety.status === 'unsafe') { + return `Feather runtime/debugging files were detected in ${safety.artifact}.`; + } + return `Feather could not verify whether ${safety.artifact} contains runtime/debugging files.`; +} diff --git a/cli/src/lib/build/upload.ts b/cli/src/lib/build/upload.ts index 492a232b..3de6ab57 100644 --- a/cli/src/lib/build/upload.ts +++ b/cli/src/lib/build/upload.ts @@ -3,16 +3,19 @@ import { spawnSync } from 'node:child_process'; import { resolve } from 'node:path'; import { loadBuildConfig, type LoadBuildConfigOptions, type UploadTarget } from './config.js'; import { artifactForTarget, readLatestManifest, resolveArtifactPath } from './files.js'; +import { inspectUploadArtifact, uploadSafetyWarning, type UploadSafetyResult } from './upload-safety.js'; export type UploadOptions = LoadBuildConfigOptions & { target: UploadTarget; buildTarget?: string; buildDir?: string; + project?: string; channel?: string; userVersion?: string; dryRun?: boolean; ifChanged?: boolean; hidden?: boolean; + allowFeatherRuntime?: boolean; }; export type UploadResult = { @@ -25,9 +28,12 @@ export type UploadResult = { channel: string; userVersion: string; command: string[]; + safety: UploadSafetyResult; + warning?: string; } | { ok: false; error: string; + safety?: UploadSafetyResult; }; export function runUpload(options: UploadOptions): UploadResult { @@ -46,10 +52,22 @@ export function runUpload(options: UploadOptions): UploadResult { if (!existsSync(artifact.path)) throw new Error(`Build artifact is missing: ${artifact.path}`); const itch = config.upload.itch ?? {}; - const project = itch.project; + const project = options.project ?? itch.project; if (!project) throw new Error('Itch upload requires upload.itch.project in feather.build.json.'); const channel = options.channel ?? itch.channels?.[buildTarget] ?? buildTarget; const userVersion = options.userVersion ?? config.version; + const safety = inspectUploadArtifact(resolveArtifactPath(artifact.path)); + const warning = uploadSafetyWarning(safety) ?? undefined; + if (!options.dryRun && safety.status !== 'safe' && !options.allowFeatherRuntime) { + return { + ok: false, + error: + safety.status === 'unsafe' + ? 'Upload blocked because Feather runtime/debugging files were detected. Pass --allow-feather-runtime only for intentional internal builds.' + : 'Upload blocked because Feather could not inspect the artifact for runtime/debugging files. Pass --allow-feather-runtime only if you have verified the artifact.', + safety, + }; + } const command = [ 'butler', 'push', @@ -77,6 +95,8 @@ export function runUpload(options: UploadOptions): UploadResult { channel, userVersion, command, + safety, + warning, }; } catch (err) { return { ok: false, error: (err as Error).message }; diff --git a/cli/src/ui/upload-workflow.tsx b/cli/src/ui/upload-workflow.tsx new file mode 100644 index 00000000..8b14a785 --- /dev/null +++ b/cli/src/ui/upload-workflow.tsx @@ -0,0 +1,326 @@ +import React, { useMemo, useState } from 'react'; +import { Box, Text, render, useApp, useInput } from 'ink'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { buildTargets, uploadTargets, type BuildTarget, type UploadTarget, loadBuildConfig } from '../lib/build/config.js'; +import { readLatestManifest } from '../lib/build/files.js'; +import { BooleanStep, SelectStep, TextInputStep } from './components.js'; + +export type UploadWorkflowResult = + | { cancelled: true } + | { + cancelled: false; + target: UploadTarget; + buildTarget: BuildTarget; + dir: string; + config?: string; + buildDir?: string; + project?: string; + channel?: string; + userVersion?: string; + build: boolean; + dryRun: boolean; + allowFeatherRuntime: boolean; + confirmed: boolean; + }; + +type UploadWorkflowInput = { + dir?: string; + config?: string; + buildDir?: string; + project?: string; + channel?: string; + userVersion?: string; +}; + +type Phase = + | 'dir' + | 'target' + | 'buildTarget' + | 'project' + | 'channel' + | 'version' + | 'build' + | 'dryRun' + | 'allowRuntime' + | 'confirm'; + +const title = 'feather upload'; +const total = 10; + +function defaultBuildTarget(dir: string, buildDir?: string): BuildTarget { + const config = loadConfigSafe(dir); + const outDir = buildDir ?? config?.outDir ?? 'builds'; + const manifest = readLatestManifest(join(dir, outDir)); + const target = manifest?.target; + return buildTargets.includes(target as BuildTarget) ? (target as BuildTarget) : 'web'; +} + +function loadConfigSafe(dir: string, config?: string) { + try { + return loadBuildConfig({ projectDir: dir, configPath: config }); + } catch { + return null; + } +} + +function Summary({ + values, + onConfirm, + onCancel, +}: { + values: Extract; + onConfirm: () => void; + onCancel: () => void; +}) { + useInput((input, key) => { + if (key.escape || input === 'n' || input === 'N') onCancel(); + if (key.return || input === 'y' || input === 'Y') onConfirm(); + }); + + return ( + + {' '}feather upload + {' '}Review upload plan + + {' '}Target: {values.target} + {' '}Build: {values.buildTarget} + {' '}Project dir: {values.dir} + {' '}Itch project: {values.project || '(from feather.build.json)'} + {' '}Channel: {values.channel || '(from feather.build.json/default)'} + {' '}Version: {values.userVersion || '(from feather.build.json)'} + {' '}Build first: {values.build ? 'yes' : 'no'} + {' '}Action: {values.dryRun ? 'dry run' : 'upload'} + {' '}Runtime override: {values.allowFeatherRuntime ? 'yes' : 'no'} + + + {' '}Enter/y upload plan · n/Esc cancel + + + ); +} + +function UploadWorkflow({ input, onComplete }: { input: UploadWorkflowInput; onComplete: (result: UploadWorkflowResult) => void }) { + const { exit } = useApp(); + const [phase, setPhase] = useState('dir'); + const [dir, setDir] = useState(input.dir ?? '.'); + const [target, setTarget] = useState('itch'); + const [buildTarget, setBuildTarget] = useState(() => defaultBuildTarget(input.dir ?? '.', input.buildDir)); + const [project, setProject] = useState(input.project ?? ''); + const [channel, setChannel] = useState(input.channel ?? ''); + const [version, setVersion] = useState(input.userVersion ?? ''); + const [build, setBuild] = useState(false); + const [dryRun, setDryRun] = useState(true); + const [allowRuntime, setAllowRuntime] = useState(false); + + const config = useMemo(() => loadConfigSafe(dir, input.config), [dir, input.config]); + const configProject = config?.upload.itch?.project ?? ''; + const configChannel = config?.upload.itch?.channels?.[buildTarget] ?? buildTarget; + const configVersion = config?.version ?? ''; + const manifestExists = useMemo(() => { + const outDir = input.buildDir ?? config?.outDir ?? 'builds'; + return existsSync(join(dir, outDir, 'feather-build-manifest.json')); + }, [dir, input.buildDir, config?.outDir]); + + const finish = (result: UploadWorkflowResult) => { + onComplete(result); + exit(); + }; + + const cancel = () => finish({ cancelled: true }); + const result = (): Extract => ({ + cancelled: false, + target, + buildTarget, + dir, + config: input.config, + buildDir: input.buildDir, + project: project || configProject || undefined, + channel: channel || configChannel || undefined, + userVersion: version || configVersion || undefined, + build, + dryRun, + allowFeatherRuntime: allowRuntime, + confirmed: true, + }); + + if (phase === 'dir') { + return ( + { + setDir(value || '.'); + setBuildTarget(defaultBuildTarget(value || '.', input.buildDir)); + setPhase('target'); + }} + /> + ); + } + + if (phase === 'target') { + return ( + value === 'itch' ? 'Upload with butler.' : 'Planned, not supported yet.')} + onSelect={(value) => { + setTarget(value as UploadTarget); + setPhase('buildTarget'); + }} + onCancel={cancel} + /> + ); + } + + if (phase === 'buildTarget') { + return ( + { + setBuildTarget(value as BuildTarget); + setPhase('project'); + }} + onCancel={cancel} + /> + ); + } + + if (phase === 'project') { + return ( + { + setProject(value); + setPhase('channel'); + }} + /> + ); + } + + if (phase === 'channel') { + return ( + { + setChannel(value); + setPhase('version'); + }} + /> + ); + } + + if (phase === 'version') { + return ( + { + setVersion(value); + setPhase('build'); + }} + /> + ); + } + + if (phase === 'build') { + return ( + { + setBuild(true); + setPhase('dryRun'); + }} + onCancel={() => { + setBuild(false); + setPhase('dryRun'); + }} + /> + ); + } + + if (phase === 'dryRun') { + return ( + { + setDryRun(true); + setPhase('allowRuntime'); + }} + onCancel={() => { + setDryRun(false); + setPhase('allowRuntime'); + }} + /> + ); + } + + if (phase === 'allowRuntime') { + return ( + { + setAllowRuntime(true); + setPhase('confirm'); + }} + onCancel={() => { + setAllowRuntime(false); + setPhase('confirm'); + }} + /> + ); + } + + return ( + finish(result())} + onCancel={cancel} + /> + ); +} + +export async function chooseUploadWorkflow(input: UploadWorkflowInput = {}): Promise { + return new Promise((resolve) => { + render(); + }); +} diff --git a/cli/test/commands/upload.test.mjs b/cli/test/commands/upload.test.mjs index bf188db3..6b9c159a 100644 --- a/cli/test/commands/upload.test.mjs +++ b/cli/test/commands/upload.test.mjs @@ -42,6 +42,26 @@ import { readStoredZipEntries, } from './helpers.mjs'; +function writeUnsafeUploadManifest(dir, { name = 'Unsafe Upload', project = 'tester/unsafe-upload', channel = 'html5' } = {}) { + const artifact = join(dir, 'builds', 'unsafe-web'); + mkdirSync(join(artifact, 'feather'), { recursive: true }); + writeFileSync(join(artifact, 'index.html'), 'Unsafe'); + writeFileSync(join(artifact, 'feather', 'debugger.lua'), 'return {}\n'); + writeBuildConfig(dir, { + name, + version: '1.0.0', + upload: { itch: { project, channels: { web: channel } } }, + }); + writeFileSync(join(dir, 'builds', 'feather-build-manifest.json'), `${JSON.stringify({ + name, + version: '1.0.0', + target: 'web', + createdAt: '2026-05-17T00:00:00.000Z', + artifacts: [{ target: 'web', type: 'html', path: artifact }], + }, null, 2)}\n`); + return artifact; +} + test('upload itch: dry-run uses build manifest and configured channel', () => { const dir = makeTmp(); writeGame(dir); @@ -90,7 +110,7 @@ fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ argv: process.a process.exit(0); `); - const result = run(['upload', 'itch', 'web', '--dir', dir, '--if-changed', '--hidden', '--json'], { env: envWithPath(binDir) }); + const result = run(['upload', 'itch', 'web', '--dir', dir, '--if-changed', '--hidden', '--yes', '--json'], { env: envWithPath(binDir) }); assert.equal(result.exitCode, 0, outputOf(result)); const record = JSON.parse(readFileSync(recordPath, 'utf8')); assert.equal(record.argv[0], 'push'); @@ -152,7 +172,146 @@ test('upload itch: desktop targets prefer installer-style artifacts over .love', test('upload steam: planned target fails cleanly', () => { const dir = makeTmp(); writeGame(dir); - const result = run(['upload', 'steam', '--dir', dir, '--json']); + const result = run(['upload', 'steam', '--dir', dir, '--yes', '--json']); assert.equal(result.exitCode, 1); assert.ok(outputOf(result).includes('planned but not supported yet')); }); + +test('upload itch: non-interactive real upload requires --yes', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeBuildConfig(dir, { + name: 'Confirm Upload', + version: '1.0.0', + targets: { web: { loveJsDir: 'love.js' } }, + upload: { itch: { project: 'tester/confirm-upload', channels: { web: 'html5' } } }, + }); + const build = run(['build', 'web', '--dir', dir, '--json']); + assert.equal(build.exitCode, 0, outputOf(build)); + + const result = run(['upload', 'itch', 'web', '--dir', dir]); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('--yes')); +}); + +test('upload itch: --build --dry-run builds an artifact and returns upload JSON without uploading', () => { + const dir = makeTmp(); + writeGame(dir); + writeFakeLoveJs(dir); + writeBuildConfig(dir, { + name: 'Build Upload', + version: '1.0.0', + targets: { web: { loveJsDir: 'love.js' } }, + upload: { itch: { project: 'tester/build-upload', channels: { web: 'html5' } } }, + }); + + const result = run(['upload', 'itch', 'web', '--dir', dir, '--build', '--dry-run', '--json']); + assert.equal(result.exitCode, 0, outputOf(result)); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, true); + assert.equal(parsed.dryRun, true); + assert.equal(parsed.project, 'tester/build-upload'); + assert.equal(parsed.safety.status, 'safe'); +}); + +test('upload itch: missing project reports a clear headless error', () => { + const dir = makeTmp(); + writeGame(dir); + const builds = join(dir, 'builds'); + mkdirSync(builds, { recursive: true }); + const artifact = join(builds, 'game.love'); + writeFileSync(artifact, 'not-a-real-upload'); + writeBuildConfig(dir, { name: 'Missing Project', version: '1.0.0' }); + writeFileSync(join(builds, 'feather-build-manifest.json'), `${JSON.stringify({ + name: 'Missing Project', + version: '1.0.0', + target: 'love', + createdAt: '2026-05-17T00:00:00.000Z', + artifacts: [{ target: 'love', type: 'love', path: artifact }], + }, null, 2)}\n`); + + const result = run(['upload', 'itch', 'love', '--dir', dir, '--dry-run']); + assert.equal(result.exitCode, 1); + assert.ok(outputOf(result).includes('upload.itch.project')); +}); + +test('upload itch: Feather runtime blocks headless upload unless explicitly allowed', () => { + const dir = makeTmp(); + writeGame(dir); + writeUnsafeUploadManifest(dir); + + const blocked = run(['upload', 'itch', 'web', '--dir', dir, '--yes', '--json']); + assert.equal(blocked.exitCode, 1); + const parsed = JSON.parse(blocked.stdout); + assert.equal(parsed.ok, false); + assert.equal(parsed.safety.status, 'unsafe'); + assert.ok(parsed.safety.detectedFiles.some((file) => file.startsWith('feather/'))); +}); + +test('upload itch: uninspectable artifact blocks headless upload unless explicitly allowed', () => { + const dir = makeTmp(); + writeGame(dir); + const builds = join(dir, 'builds'); + mkdirSync(builds, { recursive: true }); + const artifact = join(builds, 'game.exe'); + writeFileSync(artifact, 'opaque'); + writeBuildConfig(dir, { + name: 'Opaque Upload', + version: '1.0.0', + upload: { itch: { project: 'tester/opaque-upload', channels: { windows: 'windows' } } }, + }); + writeFileSync(join(builds, 'feather-build-manifest.json'), `${JSON.stringify({ + name: 'Opaque Upload', + version: '1.0.0', + target: 'windows', + createdAt: '2026-05-17T00:00:00.000Z', + artifacts: [{ target: 'windows', type: 'installer', path: artifact }], + }, null, 2)}\n`); + + const blocked = run(['upload', 'itch', 'windows', '--dir', dir, '--yes', '--json']); + assert.equal(blocked.exitCode, 1); + const parsed = JSON.parse(blocked.stdout); + assert.equal(parsed.ok, false); + assert.equal(parsed.safety.status, 'unknown'); +}); + +test('upload itch: unsafe override uploads and prints a large warning in text mode', () => { + const dir = makeTmp(); + writeGame(dir); + writeUnsafeUploadManifest(dir, { + name: 'Warn Upload', + project: 'tester/warn-upload', + channel: 'html5', + }); + const recordPath = join(dir, 'butler-record.json'); + const { binDir } = writeFakeCommand(dir, 'butler', ` +const fs = require('node:fs'); +fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ argv: process.argv.slice(2) }, null, 2)); +process.exit(0); +`); + + const result = run(['upload', 'itch', 'web', '--dir', dir, '--yes', '--allow-feather-runtime'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.ok(outputOf(result).includes('UPLOAD SAFETY WARNING')); + assert.ok(outputOf(result).includes('feather/')); +}); + +test('upload itch: unsafe override in json includes warning without non-json stdout', () => { + const dir = makeTmp(); + writeGame(dir); + writeUnsafeUploadManifest(dir, { + name: 'Json Warn Upload', + project: 'tester/json-warn-upload', + channel: 'html5', + }); + const { binDir } = writeFakeCommand(dir, 'butler', 'process.exit(0);'); + + const result = run(['upload', 'itch', 'web', '--dir', dir, '--yes', '--allow-feather-runtime', '--json'], { env: envWithPath(binDir) }); + assert.equal(result.exitCode, 0, outputOf(result)); + assert.equal(ANSI_RE.test(result.stdout), false); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, true); + assert.equal(parsed.safety.status, 'unsafe'); + assert.ok(parsed.warning.includes('Feather runtime')); +}); From d51e5bc5c910f270ed71a48031af3e2e2c066ff1 Mon Sep 17 00:00:00 2001 From: Robert Amarante Date: Sun, 17 May 2026 00:43:56 -0400 Subject: [PATCH 64/68] Potential fix for pull request finding 'CodeQL / Incomplete URL substring sanitization' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- cli/test/commands/build-vendor.test.mjs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/cli/test/commands/build-vendor.test.mjs b/cli/test/commands/build-vendor.test.mjs index a00ed2a8..86b7775c 100644 --- a/cli/test/commands/build-vendor.test.mjs +++ b/cli/test/commands/build-vendor.test.mjs @@ -65,7 +65,21 @@ test('build vendor add android --json: clones vendor and updates config', () => assert.equal(config.targets.android.loveAndroidDir, 'vendor/love-android'); const records = JSON.parse(readFileSync(recordPath, 'utf8')); assert.ok(records.some((record) => record.args.includes('--recurse-submodules'))); - assert.ok(records.some((record) => record.args.includes('https://github.com/love2d/love-android'))); + assert.ok( + records.some((record) => + record.args.some((arg) => { + try { + const url = new URL(arg); + return ( + url.hostname === 'github.com' && + /^\/love2d\/love-android(?:\.git)?\/?$/.test(url.pathname) + ); + } catch { + return false; + } + }), + ), + ); }); test('build vendor add web --json: clones love.js vendor and updates config', () => { From 10c817ceecf45a5ac541cf3b4a6b58888224ec22 Mon Sep 17 00:00:00 2001 From: Robert Amarante Date: Sun, 17 May 2026 00:44:28 -0400 Subject: [PATCH 65/68] Potential fix for pull request finding 'CodeQL / Incomplete URL substring sanitization' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- cli/test/commands/build-vendor.test.mjs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/cli/test/commands/build-vendor.test.mjs b/cli/test/commands/build-vendor.test.mjs index 86b7775c..7cf64802 100644 --- a/cli/test/commands/build-vendor.test.mjs +++ b/cli/test/commands/build-vendor.test.mjs @@ -125,7 +125,22 @@ test('build vendor add ios --json: clones vendor, installs Apple libraries, and const config = JSON.parse(readFileSync(join(dir, 'feather.build.json'), 'utf8')); assert.equal(config.targets.ios.loveIosDir, 'vendor/love-ios'); const records = JSON.parse(readFileSync(recordPath, 'utf8')); - assert.ok(records.some((record) => record.args.includes('https://github.com/love2d/love'))); + assert.ok( + records.some((record) => + record.args.some((arg) => { + try { + const url = new URL(arg); + return ( + url.protocol === 'https:' && + url.hostname === 'github.com' && + (url.pathname === '/love2d/love' || url.pathname === '/love2d/love.git' || url.pathname === '/love2d/love/') + ); + } catch { + return false; + } + }) + ) + ); }); test('build vendor add mobile --dry-run --json: reports planned vendors without writing', () => { From 91a206fadc40517f9c49aa5fd0d3d6313947527b Mon Sep 17 00:00:00 2001 From: Robert Amarante Date: Sun, 17 May 2026 00:46:56 -0400 Subject: [PATCH 66/68] Potential fix for pull request finding 'CodeQL / Incomplete URL substring sanitization' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- cli/test/commands/package.test.mjs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/cli/test/commands/package.test.mjs b/cli/test/commands/package.test.mjs index 473e90e3..ecfbb214 100644 --- a/cli/test/commands/package.test.mjs +++ b/cli/test/commands/package.test.mjs @@ -980,7 +980,16 @@ test('doctor --json reports untrusted lockfile source URLs', () => { const sourceCheck = labels.get('Package helper source'); assert.equal(sourceCheck.severity, 'warn'); - assert.ok(sourceCheck.detail.includes('example.com')); + const detailUrls = (sourceCheck.detail.match(/https?:\/\/[^\s)]+/g) || []) + .map((value) => { + try { + return new URL(value); + } catch { + return null; + } + }) + .filter(Boolean); + assert.ok(detailUrls.some((parsedUrl) => parsedUrl.hostname === 'example.com')); assert.ok(sourceCheck.fix.includes('--allow-untrusted')); assert.equal(labels.has('Package raw-helper source'), false); }); From 82f7147e439923eb038778fdaa0d50a6bc7b25fd Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sun, 17 May 2026 00:58:35 -0400 Subject: [PATCH 67/68] feather: update tests --- .github/workflows/cli-e2e.yml | 2 +- cli/src/commands/doctor/index.ts | 4 +- cli/test/commands/package.test.mjs | 84 +++++++++++++++++------------- e2e/app.spec.ts | 19 +++---- 4 files changed, 62 insertions(+), 47 deletions(-) diff --git a/.github/workflows/cli-e2e.yml b/.github/workflows/cli-e2e.yml index 1a346b8c..4a283ecb 100644 --- a/.github/workflows/cli-e2e.yml +++ b/.github/workflows/cli-e2e.yml @@ -10,7 +10,7 @@ on: - "package-lock.json" - ".github/workflows/cli-e2e.yml" push: - branches: [ main, next ] + branches: [main] paths: - "cli/**" - "src-lua/**" diff --git a/cli/src/commands/doctor/index.ts b/cli/src/commands/doctor/index.ts index ca6caecc..35f4284c 100644 --- a/cli/src/commands/doctor/index.ts +++ b/cli/src/commands/doctor/index.ts @@ -608,14 +608,14 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) provenanceByPackage.set(finding.id, [...(provenanceByPackage.get(finding.id) ?? []), finding]); } for (const [id, findings] of [...provenanceByPackage.entries()].sort(([a], [b]) => a.localeCompare(b))) { - const reasons = [...new Set(findings.map((finding) => finding.reason))].join(', '); + const urls = [...new Set(findings.map((finding) => finding.url))].slice(0, 3).join(', '); const targets = [...new Set(findings.map((finding) => finding.target))].slice(0, 3).join(', '); add( checks, 'Packages', `Package ${id} source`, 'warn', - `${findings.length} untrusted URL(s): ${reasons}${targets ? ` (${targets})` : ''}`, + `${findings.length} untrusted URL(s): ${urls}${targets ? ` (${targets})` : ''}`, `Review feather.lock.json; repair only if trusted with \`feather package install --dir ${projectDirArg} --allow-untrusted\`.`, ); } diff --git a/cli/test/commands/package.test.mjs b/cli/test/commands/package.test.mjs index ecfbb214..70e38cc1 100644 --- a/cli/test/commands/package.test.mjs +++ b/cli/test/commands/package.test.mjs @@ -809,18 +809,7 @@ test('init: --yes --mode auto patches main.lua with guarded markers', () => { test('remove: non-interactive destructive remove requires --yes', () => { const dir = makeTmp(); writeGame(dir); - run([ - 'init', - dir, - '--mode', - 'auto', - '--local-src', - LOCAL_SRC, - '--install-dir', - 'feather', - '--no-plugins', - '--yes', - ]); + run(['init', dir, '--mode', 'auto', '--local-src', LOCAL_SRC, '--install-dir', 'feather', '--no-plugins', '--yes']); const result = run(['remove', dir]); assert.equal(result.exitCode, 1); assert.ok(outputOf(result).includes('--yes')); @@ -830,18 +819,7 @@ test('remove: non-interactive destructive remove requires --yes', () => { test('remove: dry-run does not delete files or edit main.lua', () => { const dir = makeTmp(); writeGame(dir); - run([ - 'init', - dir, - '--mode', - 'auto', - '--local-src', - LOCAL_SRC, - '--install-dir', - 'feather', - '--no-plugins', - '--yes', - ]); + run(['init', dir, '--mode', 'auto', '--local-src', LOCAL_SRC, '--install-dir', 'feather', '--no-plugins', '--yes']); const beforeMain = readFileSync(join(dir, 'main.lua'), 'utf8'); const { exitCode } = run(['remove', dir, '--dry-run']); assert.equal(exitCode, 0); @@ -870,7 +848,14 @@ test('doctor --json reports package audit problems and unsafe config flags', () version: 'url', trust: 'experimental', source: { url: 'https://example.com/helper.lua' }, - files: [{ name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: sha256('expected') }], + files: [ + { + name: 'helper.lua', + url: 'https://example.com/helper.lua', + target: 'lib/helper.lua', + sha256: sha256('expected'), + }, + ], }, }); const result = run(['doctor', dir, '--json']); @@ -937,7 +922,14 @@ test('doctor --json reports package file recovery and stale bundled registry ver version: 'url', trust: 'experimental', source: { url: 'https://example.com/helper.lua' }, - files: [{ name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: sha256('expected') }], + files: [ + { + name: 'helper.lua', + url: 'https://example.com/helper.lua', + target: 'lib/helper.lua', + sha256: sha256('expected'), + }, + ], }, }); @@ -964,7 +956,14 @@ test('doctor --json reports untrusted lockfile source URLs', () => { version: 'url', trust: 'experimental', source: { kind: 'url', url: 'https://example.com/helper.lua', urls: ['https://example.com/helper.lua'] }, - files: [{ name: 'helper.lua', url: 'https://example.com/helper.lua', target: 'lib/helper.lua', sha256: sha256('expected') }], + files: [ + { + name: 'helper.lua', + url: 'https://example.com/helper.lua', + target: 'lib/helper.lua', + sha256: sha256('expected'), + }, + ], }, 'raw-helper': { version: 'main', @@ -1233,9 +1232,17 @@ test('custom add: repo install writes selected files and lockfile metadata', asy const lock = JSON.parse(readFileSync(join(dir, 'feather.lock.json'), 'utf8')); assert.equal(lock.packages['my-pkg'].version, 'v1.0.0'); assert.equal(lock.packages['my-pkg'].trust, 'experimental'); - assert.deepEqual(lock.packages['my-pkg'].source, { repo: 'me/pkg', tag: 'v1.0.0', resolvedRef: commitSha, commitSha }); + assert.deepEqual(lock.packages['my-pkg'].source, { + repo: 'me/pkg', + tag: 'v1.0.0', + resolvedRef: commitSha, + commitSha, + }); assert.equal(lock.packages['my-pkg'].files.length, 2); - assert.equal(lock.packages['my-pkg'].files[0].url, `https://raw.githubusercontent.com/me/pkg/${commitSha}/init.lua`); + assert.equal( + lock.packages['my-pkg'].files[0].url, + `https://raw.githubusercontent.com/me/pkg/${commitSha}/init.lua`, + ); }, ); }); @@ -1284,10 +1291,7 @@ test('custom add: URL install writes buffered files and lockfile metadata', asyn test('custom add: lockfile source validation rejects malformed optional provenance', async () => { const { validateLockfileSource } = await import('../../dist/lib/package/lockfile.js'); - assert.throws( - () => validateLockfileSource({ repo: 'me/pkg', tag: 'main', commitSha: 'abc123' }), - /commitSha/, - ); + assert.throws(() => validateLockfileSource({ repo: 'me/pkg', tag: 'main', commitSha: 'abc123' }), /commitSha/); assert.throws( () => validateLockfileSource({ kind: 'url', url: 'https://example.com/helper.lua', urls: [] }), /source\.urls/, @@ -1385,8 +1389,18 @@ test('restore: enriched url lockfiles still prefer per-file URLs', async () => { urls: ['https://example.com/a.lua', 'https://example.com/b.lua'], }, files: [ - { name: 'a.lua', url: 'https://example.com/a.lua', target: 'lib/mypkg/a.lua', sha256: sha256('return "a"') }, - { name: 'b.lua', url: 'https://example.com/b.lua', target: 'lib/mypkg/b.lua', sha256: sha256('return "b"') }, + { + name: 'a.lua', + url: 'https://example.com/a.lua', + target: 'lib/mypkg/a.lua', + sha256: sha256('return "a"'), + }, + { + name: 'b.lua', + url: 'https://example.com/b.lua', + target: 'lib/mypkg/b.lua', + sha256: sha256('return "b"'), + }, ], installedAt: new Date(0).toISOString(), }, diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index 18b72b9f..cd252895 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -28,7 +28,7 @@ test('shows no-session empty state and opens settings', async ({ page }) => { await expect(page.getByText('No session connected')).toBeVisible(); await expect(page.getByText('Start a game with Feather enabled')).toBeVisible(); - await page.getByRole('button', { name: 'Open Settings' }).click(); + await page.getByRole('button', { name: 'Connect a LÖVE project' }).click(); await expect(page.getByRole('dialog', { name: 'Settings' })).toBeVisible(); await expect(page.getByLabel('WebSocket Port')).toHaveValue('4004'); await expect(page.getByLabel('Connection Timeout (seconds)')).toHaveValue('15'); @@ -36,21 +36,22 @@ test('shows no-session empty state and opens settings', async ({ page }) => { test('persists settings changes across reloads', async ({ page }) => { await page.goto('/'); - await page.getByRole('button', { name: 'Open Settings' }).click(); + await page.getByRole('button', { name: 'Connect a LÖVE project' }).click(); - const port = page.getByLabel('WebSocket Port'); - const timeout = page.getByLabel('Connection Timeout (seconds)'); - const assetRoot = page.getByLabel('Asset Source Directory'); + await page.getByLabel('WebSocket Port').fill('4111'); + await page.getByLabel('Connection Timeout (seconds)').fill('22'); + + await page.getByRole('tab', { name: 'General' }).click(); + await page.getByLabel('Asset Source Directory').fill('/tmp/feather-assets'); - await port.fill('4111'); - await timeout.fill('22'); - await assetRoot.fill('/tmp/feather-assets'); await page.getByRole('dialog', { name: 'Settings' }).getByRole('button', { name: 'Close' }).first().click(); await page.reload(); - await page.getByRole('button', { name: 'Open Settings' }).click(); + await page.getByRole('button', { name: 'Connect a LÖVE project' }).click(); await expect(page.getByLabel('WebSocket Port')).toHaveValue('4111'); await expect(page.getByLabel('Connection Timeout (seconds)')).toHaveValue('22'); + + await page.getByRole('tab', { name: 'General' }).click(); await expect(page.getByLabel('Asset Source Directory')).toHaveValue('/tmp/feather-assets'); }); From c4f0b7041055be00a777058e020be9ffbf582a05 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Sun, 17 May 2026 01:02:30 -0400 Subject: [PATCH 68/68] ci: update test --- cli/test/commands/build.test.mjs | 95 ++++++++++++++++++++------------ 1 file changed, 61 insertions(+), 34 deletions(-) diff --git a/cli/test/commands/build.test.mjs b/cli/test/commands/build.test.mjs index 73c67094..f85a9b7c 100644 --- a/cli/test/commands/build.test.mjs +++ b/cli/test/commands/build.test.mjs @@ -61,8 +61,14 @@ test('build web: creates love archive, love.js html package, zip, and manifest', const parsed = JSON.parse(result.stdout); assert.equal(parsed.ok, true); assert.equal(parsed.target, 'web'); - assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'love'), true); - assert.equal(parsed.artifacts.some((artifact) => artifact.type === 'zip'), true); + assert.equal( + parsed.artifacts.some((artifact) => artifact.type === 'love'), + true, + ); + assert.equal( + parsed.artifacts.some((artifact) => artifact.type === 'zip'), + true, + ); assert.equal(existsSync(join(dir, 'builds', 'command-game-1.2.3.love')), true); assert.equal(existsSync(join(dir, 'builds', 'command-game-1.2.3-html.zip')), true); const index = readFileSync(join(dir, 'builds', 'command-game-1.2.3-html', 'index.html'), 'utf8'); @@ -83,7 +89,10 @@ test('build love: creates only a .love package and writes manifest', () => { const parsed = JSON.parse(result.stdout); assert.equal(parsed.ok, true); assert.equal(parsed.target, 'love'); - assert.deepEqual(parsed.artifacts.map((artifact) => artifact.type), ['love']); + assert.deepEqual( + parsed.artifacts.map((artifact) => artifact.type), + ['love'], + ); assert.equal(existsSync(join(dir, 'builds', 'love-package-2.0.0.love')), true); const manifest = JSON.parse(readFileSync(join(dir, 'builds', 'feather-build-manifest.json'), 'utf8')); assert.equal(manifest.target, 'love'); @@ -112,18 +121,27 @@ for (const target of ['windows', 'macos', 'linux', 'steamos']) { assert.equal(parsed.target, target); assert.equal(existsSync(join(dir, 'builds', 'desktop-game-2.0.0.love')), true); if (target === 'windows') { - assert.deepEqual(parsed.artifacts.map((artifact) => artifact.type), ['love', 'zip', 'installer']); + assert.deepEqual( + parsed.artifacts.map((artifact) => artifact.type), + ['love', 'zip', 'installer'], + ); assert.equal(existsSync(join(dir, 'builds', 'desktop-game-2.0.0-windows.zip')), true); assert.equal(existsSync(join(dir, 'builds', 'desktop-game-2.0.0-windows-installer.exe')), true); const entries = readStoredZipEntries(join(dir, 'builds', 'desktop-game-2.0.0-windows.zip')); assert.equal(entries.has('desktop-game.exe'), true); assert.equal(entries.has('love.exe'), false); } else if (target === 'macos') { - assert.deepEqual(parsed.artifacts.map((artifact) => artifact.type), ['love', 'zip', 'dmg']); + assert.deepEqual( + parsed.artifacts.map((artifact) => artifact.type), + ['love', 'zip', 'dmg'], + ); assert.equal(existsSync(join(dir, 'builds', 'desktop-game-2.0.0-macos.app.zip')), true); assert.equal(existsSync(join(dir, 'builds', 'desktop-game-2.0.0-macos.dmg')), true); } else { - assert.deepEqual(parsed.artifacts.map((artifact) => artifact.type), ['love', 'appimage', 'tar.gz']); + assert.deepEqual( + parsed.artifacts.map((artifact) => artifact.type), + ['love', 'appimage', 'tar.gz'], + ); assert.equal(existsSync(join(dir, 'builds', `desktop-game-2.0.0-${target}.AppImage`)), true); assert.equal(existsSync(join(dir, 'builds', `desktop-game-2.0.0-${target}.tar.gz`)), true); } @@ -162,43 +180,50 @@ test('build validation: rejects bad mobile config values and unsafe native paths productId: 'not a product id', targets: { android: { versionCode: 0, orientation: 'sideways', gradleTask: 'assemble debug' } }, }); - assert.deepEqual(androidIssues.map((issue) => issue.field), [ - 'productId', - 'targets.android.versionCode', - 'targets.android.orientation', - 'targets.android.gradleTask', - ]); - const androidReleaseIssues = validateAndroidBuildConfig({ - ...baseConfig, - targets: { - android: { - release: { - bundleTask: 'bundle release', - apkArtifactPath: '', - storePasswordEnv: '1BAD_ENV', - keyPasswordEnv: 'GOOD_ENV', + assert.deepEqual( + androidIssues.map((issue) => issue.field), + ['productId', 'targets.android.versionCode', 'targets.android.orientation', 'targets.android.gradleTask'], + ); + const fakeTestStorePasswordEnv = '1BAD_ENV'; + const fakeTestKeyPasswordEnv = 'GOOD_ENV'; + + const androidReleaseIssues = validateAndroidBuildConfig( + { + ...baseConfig, + targets: { + android: { + release: { + bundleTask: 'bundle release', + apkArtifactPath: '', + storePasswordEnv: fakeTestStorePasswordEnv, + keyPasswordEnv: fakeTestKeyPasswordEnv, + }, }, }, }, - }, true); + true, + ); assert.ok(androidReleaseIssues.some((issue) => issue.field === 'targets.android.release.bundleTask')); assert.ok(androidReleaseIssues.some((issue) => issue.field === 'targets.android.release.apkArtifactPath')); assert.ok(androidReleaseIssues.some((issue) => issue.field === 'targets.android.release.storePasswordEnv')); const iosIssues = validateIosBuildConfig({ ...baseConfig, - targets: { ios: { bundleIdentifier: 'bad id', scheme: 'bad;', simulatorArch: 'bad;', derivedDataPath: '../escape' } }, + targets: { + ios: { bundleIdentifier: 'bad id', scheme: 'bad;', simulatorArch: 'bad;', derivedDataPath: '../escape' }, + }, }); - assert.deepEqual(iosIssues.map((issue) => issue.field), [ - 'bundleIdentifier', - 'targets.ios.scheme', - 'targets.ios.simulatorArch', - 'targets.ios.derivedDataPath', - ]); - const iosReleaseIssues = validateIosBuildConfig({ - ...baseConfig, - targets: { ios: { release: { exportMethod: 'side-load', signingStyle: 'sometimes', teamId: 'BAD TEAM' } } }, - }, true); + assert.deepEqual( + iosIssues.map((issue) => issue.field), + ['bundleIdentifier', 'targets.ios.scheme', 'targets.ios.simulatorArch', 'targets.ios.derivedDataPath'], + ); + const iosReleaseIssues = validateIosBuildConfig( + { + ...baseConfig, + targets: { ios: { release: { exportMethod: 'side-load', signingStyle: 'sometimes', teamId: 'BAD TEAM' } } }, + }, + true, + ); assert.ok(iosReleaseIssues.some((issue) => issue.field === 'targets.ios.release.exportMethod')); assert.ok(iosReleaseIssues.some((issue) => issue.field === 'targets.ios.release.signingStyle')); assert.ok(iosReleaseIssues.some((issue) => issue.field === 'targets.ios.release.teamId')); @@ -229,7 +254,9 @@ test('build mobile: missing native template paths fail with actionable errors', assert.equal(android.exitCode, 1); assert.ok(outputOf(android).includes('targets.android.loveAndroidDir')); - const ios = run(['build', 'ios', '--dir', dir, '--allow-unsafe', '--json'], { env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0', FEATHER_TEST_ALLOW_IOS_BUILD: '1' } }); + const ios = run(['build', 'ios', '--dir', dir, '--allow-unsafe', '--json'], { + env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0', FEATHER_TEST_ALLOW_IOS_BUILD: '1' }, + }); assert.equal(ios.exitCode, 1); assert.ok(outputOf(ios).includes('targets.ios.loveIosDir')); });