diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ff0cff4..5742d51 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,6 +19,7 @@ * [Flatpak](#flatpak) * [Snap](#snap) * [Homebrew](#homebrew) + * [Universal Targets](#universal-targets-npm--script) * [Icon System](#5-icon-system) * [Valid Categories](#6-valid-categories) 4. [Adding Distributions](#adding-distributions) @@ -140,12 +141,18 @@ All applications are defined in category-specific JSON files within [`src/lib/ap "arch": "exact-package-name", // pacman OR AUR package name "flatpak": "com.vendor.AppId", // FULL Flatpak App ID (reverse DNS) "snap": "snap-name", // Add --classic if needed - "homebrew": "formula-name" // Formula (CLI) or '--cask name' (GUI) + "homebrew": "formula-name", // Formula (CLI) or '--cask name' (GUI) + "npm": "@scope/package-name", // Global npm install (universal fallback) + "script": "curl -fsSL ... | bash" // Custom install script (universal fallback) }, + "note": "Context for universal targets", // Shown on hover for universal-fallback apps "unavailableReason": "Markdown install instructions" } ``` +> [!NOTE] +> **Priority system**: Native distro targets (e.g., `arch`, `ubuntu`) always take precedence over universal targets (`npm`, `script`). If an app has both `arch: "ollama"` and `script: "curl ..."`, the script is only used when the user selects a distro where no native package exists. + ### 3. Unavailable Reason Guidelines This field renders Markdown when a target is missing. It serves as the manual fallback instruction. @@ -217,6 +224,64 @@ Homebrew (macOS/Linux) has two package types. Check [formulae.brew.sh](https://f * Run `brew search ` locally to confirm type. * We skip `--cask` targets on Linux installs automatically. +#### Universal Targets (npm & script) + +Universal targets provide cross-distro installation via package managers or custom scripts. They serve as **fallbacks** — only used when no native distro target exists for the selected distribution. + +* **`npm`**: Install via `npm install -g`. Requires Node.js runtime on the system. +* **`script`**: Raw shell command (typically a `curl | bash` installer). Runs directly. + +> [!IMPORTANT] +> **Native targets always take priority.** If an app defines `arch: "ollama"` alongside `script: "curl ..."`, the script target is completely ignored when Arch is selected. The fallback only activates for distros without a native package. + +**When to use each:** + +| Target | Use Case | Example | +| :--- | :--- | :--- | +| `npm` | CLI tools distributed via npmjs.com | `"npm": "@google/gemini-cli"` | +| `script` | Apps with official install scripts | `"script": "curl -fsSL https://ollama.com/install.sh \| sh"` | + +**Real examples from the codebase:** + +```json +// Ollama: native on arch/fedora/nix/homebrew, falls back to script on ubuntu/debian +{ + "id": "ollama", + "targets": { + "fedora": "ollama", + "arch": "ollama", + "nix": "ollama", + "homebrew": "ollama", + "script": "curl -fsSL https://ollama.com/install.sh | sh" + }, + "note": "Falls back to official installer (ollama.com/install.sh) on distros without a native package." +} + +// Gemini CLI: npm-only (plus homebrew) +{ + "id": "gemini-cli", + "targets": { + "npm": "@google/gemini-cli", + "homebrew": "gemini-cli" + }, + "note": "Requires Node.js runtime. Installed globally via npm where native packages are unavailable." +} +``` + +**The `note` field:** +* Used to explain the universal target behavior to users (shown on hover). +* **Required** when using `npm` or `script` targets. +* Keep concise and professional. Examples: + * ✅ `"Falls back to official installer (ollama.com/install.sh) on distros without a native package."` + * ✅ `"Requires Node.js runtime. Installed globally via npm where native packages are unavailable."` + * ❌ `"Installed globally via npm."` *(too terse, doesn't explain when or why)* + +**Script safety rules:** +1. Only use **official** installer scripts from the app's own domain. +2. Always use `curl -fsSL` flags (fail silently on errors, follow redirects, show errors). +3. Never combine multiple pipes or add `sudo` — the install script handles privileges itself. +4. Verify the script URL is stable and maintained by the upstream project. + ### 5. Icon System Every app needs an icon! Our JSON format makes it super simple to add icons using [Iconify](https://iconify.design/). @@ -259,7 +324,7 @@ Just change the `"type"` to `"url"`: Use **exactly** one of these: * Web Browsers • Communication • Media • Creative • Gaming • Office * Dev: Languages • Dev: Editors • Dev: Tools -* Terminal • CLI Tools • VPN & Network • Security • File Sharing • System +* Terminal • CLI Tools • AI Tools • VPN & Network • Security • File Sharing • System --- @@ -316,6 +381,8 @@ Create a new file `src/lib/scripts/.ts`. This file must export a funct - [ ] Snap `--classic` flag verification. - [ ] Nix unfree packages added to JSON. - [ ] Homebrew Casks prefixed correctly. +- [ ] Universal targets: `npm`/`script` only used as fallbacks, `note` field provided. +- [ ] Script URLs verified as official, stable endpoints. - [ ] `npm run lint` & `npm run test` passed. --- @@ -345,6 +412,9 @@ Brief description of changes. ## Testing - [ ] `npm run dev` working - [ ] `npm run build` passed +- [ ] `npm run test` passed +- [ ] `npm run lint` passed + ## Screenshots (if applicable) diff --git a/src/components/app/AppItem.tsx b/src/components/app/AppItem.tsx index 06084b0..c4a08ab 100644 --- a/src/components/app/AppItem.tsx +++ b/src/components/app/AppItem.tsx @@ -21,6 +21,7 @@ const COLOR_MAP: Record = { 'green': '#22c55e', 'teal': '#14b8a6', 'gray': '#6b7280', + 'fuchsia': '#d946ef', }; interface AppItemProps { @@ -54,10 +55,22 @@ export const AppItem = memo(function AppItem({ }: AppItemProps) { const getUnavailableText = () => { const distroName = distros.find(d => d.id === selectedDistro)?.name || ''; - return app.unavailableReason || `Not available in ${distroName} repos`; + let msg = ''; + if (!isAvailable) { + msg = app.unavailableReason || `Not available in ${distroName} repos`; + } + if (app.note) { + msg = msg ? `${msg} — ${app.note}` : app.note; + } + return msg; }; const isAur = selectedDistro === 'arch' && app.targets?.arch && isAurPackage(app.targets.arch); + const universalTarget = !app.targets?.[selectedDistro] ? ( + app.targets?.npm ? 'npm' : + app.targets?.script ? 'script' : null + ) : null; + const hexColor = COLOR_MAP[color] || COLOR_MAP['gray']; const checkboxColor = isAur ? '#1793d1' : hexColor; @@ -111,7 +124,8 @@ export const AppItem = memo(function AppItem({ }} onMouseEnter={(e) => { e.stopPropagation(); - onTooltipEnter(app.description, e); + const tooltipText = app.note ? `${app.description} — ${app.note}` : app.description; + onTooltipEnter(tooltipText, e); }} onMouseLeave={(e) => { e.stopPropagation(); @@ -142,15 +156,31 @@ export const AppItem = memo(function AppItem({ )} + {universalTarget && ( + { + e.stopPropagation(); + onTooltipEnter(`Installed via ${universalTarget}. Requires correct runtime.`, e); + }} + onMouseLeave={(e) => { + e.stopPropagation(); + onTooltipLeave(); + }} + > + {universalTarget} + + )} - {!isAvailable && ( + {(!isAvailable || universalTarget) && (
{ e.stopPropagation(); onTooltipEnter(getUnavailableText(), e); }} onMouseLeave={(e) => { e.stopPropagation(); onTooltipLeave(); }} > )}
+ ); }); diff --git a/src/components/app/CategoryHeader.tsx b/src/components/app/CategoryHeader.tsx index 14f456f..1bbb454 100644 --- a/src/components/app/CategoryHeader.tsx +++ b/src/components/app/CategoryHeader.tsx @@ -3,7 +3,7 @@ import { ChevronRight, Globe, MessageCircle, Code2, FileCode, Wrench, Terminal, Command, Play, Palette, Gamepad2, Briefcase, - Network, Lock, Share2, Cpu, type LucideIcon + Network, Lock, Share2, Cpu, Sparkles, type LucideIcon } from 'lucide-react'; const CATEGORY_ICONS: Record = { @@ -22,6 +22,7 @@ const CATEGORY_ICONS: Record = { 'Security': Lock, 'File Sharing': Share2, 'System': Cpu, + 'AI Tools': Sparkles, }; const COLOR_MAP: Record = { @@ -40,6 +41,7 @@ const COLOR_MAP: Record = { 'green': '#22c55e', 'teal': '#14b8a6', 'gray': '#6b7280', + 'fuchsia': '#d946ef', }; // Category header. diff --git a/src/components/app/CategorySection.tsx b/src/components/app/CategorySection.tsx index f3a8c69..1a4db6b 100644 --- a/src/components/app/CategorySection.tsx +++ b/src/components/app/CategorySection.tsx @@ -43,7 +43,8 @@ const categoryColors: Record = { 'Dev: Languages': 'rose', 'Dev: Tools': 'slate', 'Terminal': 'zinc', - 'CLI Tools': 'gray' + 'CLI Tools': 'gray', + 'AI Tools': 'fuchsia' }; function CategorySectionComponent({ diff --git a/src/hooks/useLinuxInit.ts b/src/hooks/useLinuxInit.ts index 5a3ecfb..7be1512 100644 --- a/src/hooks/useLinuxInit.ts +++ b/src/hooks/useLinuxInit.ts @@ -1,7 +1,7 @@ 'use client'; import { useState, useMemo, useCallback, useEffect } from 'react'; -import { distros, apps, type DistroId } from '@/lib/data'; +import { distros, apps, type DistroId, isAppAvailable as globalIsAppAvailable } from '@/lib/data'; import { isAurPackage } from '@/lib/aur'; import { isUnfreePackage } from '@/lib/nixUnfree'; @@ -59,8 +59,7 @@ export function useLinuxInit(): UseLinuxInitReturn { const validApps = appIds.filter(id => { const app = apps.find(a => a.id === id); if (!app) return false; - const pkg = app.targets[savedDistro || 'ubuntu']; - return pkg !== undefined && pkg !== null; + return globalIsAppAvailable(app, savedDistro || 'ubuntu'); }); setSelectedApps(new Set(validApps)); } @@ -131,8 +130,7 @@ export function useLinuxInit(): UseLinuxInitReturn { const isAppAvailable = useCallback((appId: string): boolean => { const app = apps.find(a => a.id === appId); if (!app) return false; - const packageName = app.targets[selectedDistro]; - return packageName !== undefined && packageName !== null; + return globalIsAppAvailable(app, selectedDistro); }, [selectedDistro]); const getPackageName = useCallback((appId: string): string | null => { @@ -147,11 +145,8 @@ export function useLinuxInit(): UseLinuxInitReturn { const newSelected = new Set(); prevSelected.forEach(appId => { const app = apps.find(a => a.id === appId); - if (app) { - const packageName = app.targets[distroId]; - if (packageName !== undefined && packageName !== null) { - newSelected.add(appId); - } + if (app && globalIsAppAvailable(app, distroId)) { + newSelected.add(appId); } }); return newSelected; @@ -161,8 +156,7 @@ export function useLinuxInit(): UseLinuxInitReturn { const toggleApp = useCallback((appId: string) => { const app = apps.find(a => a.id === appId); if (!app) return; - const pkg = app.targets[selectedDistro]; - if (pkg === undefined || pkg === null) return; + if (!globalIsAppAvailable(app, selectedDistro)) return; setSelectedApps(prev => { const newSet = new Set(prev); @@ -177,10 +171,7 @@ export function useLinuxInit(): UseLinuxInitReturn { const selectAll = useCallback(() => { const allAvailable = apps - .filter(app => { - const pkg = app.targets[selectedDistro]; - return pkg !== undefined && pkg !== null; - }) + .filter(app => globalIsAppAvailable(app, selectedDistro)) .map(app => app.id); setSelectedApps(new Set(allAvailable)); }, [selectedDistro]); @@ -190,10 +181,7 @@ export function useLinuxInit(): UseLinuxInitReturn { }, []); const availableCount = useMemo(() => { - return apps.filter(app => { - const pkg = app.targets[selectedDistro]; - return pkg !== undefined && pkg !== null; - }).length; + return apps.filter(app => globalIsAppAvailable(app, selectedDistro)).length; }, [selectedDistro]); const generatedCommand = useMemo(() => { @@ -205,57 +193,84 @@ export function useLinuxInit(): UseLinuxInitReturn { if (!distro) return ''; const packageNames: string[] = []; + const npmPkgs: string[] = []; + const scriptPkgs: string[] = []; + selectedApps.forEach(appId => { const app = apps.find(a => a.id === appId); if (app) { const pkg = app.targets[selectedDistro]; - if (pkg) packageNames.push(pkg); + if (pkg) { + packageNames.push(pkg); + } else { + if (app.targets.npm) npmPkgs.push(app.targets.npm); + if (app.targets.script) scriptPkgs.push(app.targets.script); + } } }); - if (packageNames.length === 0) return '# No packages selected'; + const extras: string[] = []; + if (npmPkgs.length > 0) extras.push(`npm install -g ${npmPkgs.join(' ')}`); + + const extrasStr = extras.join(' && '); + + const appendExtras = (cmd: string) => { + if (!cmd || cmd.startsWith('#')) return extrasStr || cmd; + return extrasStr ? `${cmd} && ${extrasStr}` : cmd; + }; + + const appendScripts = (cmd: string) => { + if (scriptPkgs.length === 0) return cmd; + const scriptsStr = scriptPkgs.join(' && '); + return cmd ? `${cmd} && ${scriptsStr}` : scriptsStr; + }; + + if (packageNames.length === 0 && extras.length === 0 && scriptPkgs.length === 0) { + return '# No packages selected'; + } + + let baseCmd = ''; + + if (packageNames.length > 0) { + if (selectedDistro === 'nix') { + const sortedPkgs = packageNames.filter(p => p.trim()).sort(); + const pkgList = sortedPkgs.map(p => ` ${p}`).join('\\n'); + baseCmd = `environment.systemPackages = with pkgs; [\\n${pkgList}\\n];`; + } else if (selectedDistro === 'snap') { + if (packageNames.length === 1) { + baseCmd = `${distro.installPrefix} ${packageNames[0]}`; + } else { + baseCmd = packageNames.map(p => `sudo snap install ${p}`).join(' && '); + } + } else if (selectedDistro === 'arch' && aurPackageInfo.hasAur) { + if (!hasYayInstalled) { + const helperName = selectedHelper; // yay or paru + const installHelperCmd = `sudo pacman -S --needed git base-devel && git clone https://aur.archlinux.org/${helperName}.git /tmp/${helperName} && cd /tmp/${helperName} && makepkg -si --noconfirm && cd - && rm -rf /tmp/${helperName}`; + const installCmd = `${helperName} -S --needed --noconfirm ${packageNames.join(' ')}`; + baseCmd = `${installHelperCmd} && ${installCmd}`; + } else { + baseCmd = `${selectedHelper} -S --needed --noconfirm ${packageNames.join(' ')}`; + } + } else if (selectedDistro === 'homebrew') { + const formulae = packageNames.filter(p => !p.startsWith('--cask ')); + const casks = packageNames.filter(p => p.startsWith('--cask ')).map(p => p.replace('--cask ', '')); + const parts: string[] = []; + if (formulae.length > 0) parts.push(`brew install ${formulae.join(' ')}`); + if (casks.length > 0) parts.push(`brew install --cask ${casks.join(' ')}`); + baseCmd = parts.join(' && '); + } else { + baseCmd = `${distro.installPrefix} ${packageNames.join(' ')}`; + } + } if (selectedDistro === 'nix') { - const sortedPkgs = packageNames.filter(p => p.trim()).sort(); - const pkgList = sortedPkgs.map(p => ` ${p}`).join('\n'); - return `environment.systemPackages = with pkgs; [\n${pkgList}\n];`; + let combined = baseCmd; + if (extrasStr) combined += '\\n# NPM limits:\\n# ' + extrasStr; + if (scriptPkgs.length > 0) combined += '\\n# Custom scripts:\\n# ' + scriptPkgs.join('\\n# '); + return combined; } - if (selectedDistro === 'snap') { - if (packageNames.length === 1) { - return `${distro.installPrefix} ${packageNames[0]}`; - } - return packageNames.map(p => `sudo snap install ${p}`).join(' && '); - } - - if (selectedDistro === 'arch' && aurPackageInfo.hasAur) { - if (!hasYayInstalled) { - const helperName = selectedHelper; // yay or paru - - const installHelperCmd = `sudo pacman -S --needed git base-devel && git clone https://aur.archlinux.org/${helperName}.git /tmp/${helperName} && cd /tmp/${helperName} && makepkg -si --noconfirm && cd - && rm -rf /tmp/${helperName}`; - - const installCmd = `${helperName} -S --needed --noconfirm ${packageNames.join(' ')}`; - - return `${installHelperCmd} && ${installCmd}`; - } else { - return `${selectedHelper} -S --needed --noconfirm ${packageNames.join(' ')}`; - } - } - - if (selectedDistro === 'homebrew') { - const formulae = packageNames.filter(p => !p.startsWith('--cask ')); - const casks = packageNames.filter(p => p.startsWith('--cask ')).map(p => p.replace('--cask ', '')); - const parts: string[] = []; - if (formulae.length > 0) { - parts.push(`brew install ${formulae.join(' ')}`); - } - if (casks.length > 0) { - parts.push(`brew install --cask ${casks.join(' ')}`); - } - return parts.join(' && ') || '# No packages selected'; - } - - return `${distro.installPrefix} ${packageNames.join(' ')}`; + return appendScripts(appendExtras(baseCmd)); }, [selectedDistro, selectedApps, aurPackageInfo.hasAur, hasYayInstalled, selectedHelper]); return { diff --git a/src/lib/apps/ai-tools.json b/src/lib/apps/ai-tools.json new file mode 100644 index 0000000..38dce70 --- /dev/null +++ b/src/lib/apps/ai-tools.json @@ -0,0 +1,128 @@ +[ + { + "id": "opencode", + "name": "OpenCode", + "description": "AI-powered coding platform and environment", + "category": "AI Tools", + "targets": { + "homebrew": "opencode", + "arch": "opencode", + "nix": "opencode", + "script": "curl -fsSL https://opencode.ai/install | bash" + }, + "note": "Falls back to official installer (opencode.ai/install) on distros without a native package.", + "icon": { + "type": "iconify", + "set": "mdi", + "name": "code-braces", + "color": "#3B82F6" + } + }, + { + "id": "codex", + "name": "OpenAI Codex", + "description": "AI model that parses natural language and generates code", + "category": "AI Tools", + "targets": { + "npm": "@openai/codex", + "homebrew": "codex", + "nix": "codex" + }, + "note": "Requires Node.js runtime. Installed globally via npm where native packages are unavailable.", + "icon": { + "type": "iconify", + "set": "simple-icons", + "name": "openai", + "color": "#412991" + } + }, + { + "id": "gemini-cli", + "name": "Gemini CLI", + "description": "Command-line interface for Google's Gemini API", + "category": "AI Tools", + "targets": { + "npm": "@google/gemini-cli", + "homebrew": "gemini-cli", + "nix": "gemini-cli" + }, + "note": "Requires Node.js runtime. Installed globally via npm where native packages are unavailable.", + "icon": { + "type": "iconify", + "set": "simple-icons", + "name": "googlegemini", + "color": "#8E75B2" + } + }, + { + "id": "claude-code", + "name": "Claude Code", + "description": "Anthropic's official CLI tool for Claude", + "category": "AI Tools", + "targets": { + "homebrew": "--cask claude-code", + "nix": "claude-code", + "script": "curl -fsSL https://claude.ai/install.sh | bash" + }, + "note": "Uses official installer script (claude.ai/install.sh) on Linux distros without a native package.", + "icon": { + "type": "iconify", + "set": "simple-icons", + "name": "anthropic", + "color": "#D97757" + } + }, + { + "id": "ollama", + "name": "Ollama", + "description": "Get up and running with large language models locally", + "category": "AI Tools", + "targets": { + "fedora": "ollama", + "opensuse": "ollama", + "arch": "ollama", + "nix": "ollama", + "homebrew": "ollama", + "script": "curl -fsSL https://ollama.com/install.sh | sh" + }, + "note": "Falls back to official installer (ollama.com/install.sh) on distros without a native package.", + "icon": { + "type": "url", + "url": "https://ollama.com/public/icon-64x64.png" + } + }, + { + "id": "llama-cpp", + "name": "llama.cpp", + "description": "Inference of LLaMA model in pure C/C++", + "category": "AI Tools", + "targets": { + "nix": "llama-cpp", + "homebrew": "llama.cpp" + }, + "unavailableReason": "Available via [brew or nix](https://github.com/ggml-org/llama.cpp/blob/master/docs/install.md), [Docker](https://github.com/ggml-org/llama.cpp/blob/master/docs/docker.md), pre-built [releases](https://github.com/ggml-org/llama.cpp/releases), or [build from source](https://github.com/ggml-org/llama.cpp/blob/master/docs/build.md).", + "icon": { + "type": "iconify", + "set": "mdi", + "name": "head-cog", + "color": "#64748B" + } + }, + { + "id": "jan", + "name": "Jan", + "description": "Open-source ChatGPT-alternative that runs locally offline", + "category": "AI Tools", + "targets": { + "arch": "jan-bin", + "nix": "jan", + "homebrew": "--cask jan", + "flatpak": "ai.jan.Jan" + }, + "unavailableReason": "Not in most official repos. Use [flatpak](https://flathub.org/en/apps/ai.jan.Jan) or Download AppImage or .deb package from [jan.ai/download](https://jan.ai/download).", + "icon": { + "type": "url", + "url": "https://www.jan.ai/_next/static/media/logo-jan.db83c5f0.svg" + } + } +] \ No newline at end of file diff --git a/src/lib/apps/dev-editors.json b/src/lib/apps/dev-editors.json index 4da0983..903bc06 100644 --- a/src/lib/apps/dev-editors.json +++ b/src/lib/apps/dev-editors.json @@ -1,4 +1,22 @@ [ + { + "id": "cursor", + "name": "Cursor", + "description": "AI-powered code editor based on VS Code", + "category": "Dev: Editors", + "targets": { + "arch": "cursor-bin", + "nix": "code-cursor", + "homebrew": "--cask cursor" + }, + "unavailableReason": "Only available via [AUR](https://aur.archlinux.org/packages/cursor-bin) or Nix. Download from [cursor.com/download](https://cursor.com/download).", + "icon": { + "type": "iconify", + "set": "simple-icons", + "name": "cursor", + "color": "#232020ff" + } + }, { "id": "vscode", "name": "VS Code", @@ -38,97 +56,6 @@ "color": "#2F80ED" } }, - { - "id": "vim", - "name": "Vim", - "description": "The classic modal text editor that started it all", - "category": "Dev: Editors", - "targets": { - "ubuntu": "vim", - "debian": "vim", - "arch": "vim", - "fedora": "vim-enhanced", - "opensuse": "vim", - "nix": "vim", - "flatpak": "org.vim.Vim", - "snap": "vim-editor", - "homebrew": "vim" - }, - "icon": { - "type": "iconify", - "set": "simple-icons", - "name": "vim", - "color": "#019733" - } - }, - { - "id": "neovim", - "name": "Neovim", - "description": "Modernized Vim with better extensibility", - "category": "Dev: Editors", - "targets": { - "ubuntu": "neovim", - "debian": "neovim", - "arch": "neovim", - "fedora": "neovim", - "opensuse": "neovim", - "nix": "neovim", - "flatpak": "com.neovim.Neovim", - "snap": "nvim --classic", - "homebrew": "neovim" - }, - "icon": { - "type": "iconify", - "set": "simple-icons", - "name": "neovim", - "color": "#57A143" - } - }, - { - "id": "helix", - "name": "Helix", - "description": "Modal editor with LSP and tree-sitter built-in", - "category": "Dev: Editors", - "targets": { - "arch": "helix", - "fedora": "helix", - "opensuse": "helix", - "nix": "helix", - "flatpak": "com.helix-editor.Helix", - "snap": "helix --classic", - "homebrew": "helix" - }, - "unavailableReason": "Not in official Debian/Ubuntu repos. For Ubuntu, add the PPA: `sudo add-apt-repository ppa:maveonair/helix-editor && sudo apt update && sudo apt install helix`. For Debian, download .deb from [GitHub releases](https://github.com/helix-editor/helix/releases).", - "icon": { - "type": "iconify", - "set": "mdi", - "name": "dna", - "color": "#4E2F7F" - } - }, - { - "id": "micro", - "name": "Micro", - "description": "Easy-to-use terminal text editor like nano", - "category": "Dev: Editors", - "targets": { - "arch": "micro", - "ubuntu": "micro", - "debian": "micro", - "fedora": "micro", - "opensuse": "micro-editor", - "nix": "micro-editor", - "flatpak": "io.github.zyedidia.micro", - "snap": "micro --classic", - "homebrew": "micro" - }, - "icon": { - "type": "iconify", - "set": "simple-icons", - "name": "microeditor", - "color": "#2E3192" - } - }, { "id": "zed", "name": "Zed", @@ -148,134 +75,6 @@ "color": "#084CCF" } }, - { - "id": "sublime", - "name": "Sublime Text", - "description": "Lightning-fast proprietary text editor", - "category": "Dev: Editors", - "targets": { - "arch": "sublime-text-4", - "nix": "sublime", - "flatpak": "com.sublimetext.three", - "snap": "sublime-text --classic", - "homebrew": "--cask sublime-text" - }, - "unavailableReason": "Not in official repos. Use [Flatpak](https://flathub.org/en/apps/com.sublimetext.three)/[Snap](https://snapcraft.io/sublime-text) or follow instructions at [sublimetext.com/docs/linux_repositories](https://www.sublimetext.com/docs/linux_repositories.html).", - "icon": { - "type": "iconify", - "set": "simple-icons", - "name": "sublimetext", - "color": "#FF9800" - } - }, - { - "id": "arduino", - "name": "Arduino IDE", - "description": "IDE for Arduino microcontroller development", - "category": "Dev: Editors", - "targets": { - "ubuntu": "arduino", - "debian": "arduino", - "arch": "arduino", - "fedora": "arduino", - "nix": "arduino-ide", - "flatpak": "cc.arduino.IDE2", - "snap": "arduino", - "homebrew": "--cask arduino-ide" - }, - "unavailableReason": "Arduino IDE is not in official openSUSE repos. Use [Flatpak](https://flathub.org/en/apps/cc.arduino.IDE2) or [Snap](https://snapcraft.io/arduino) instead.", - "icon": { - "type": "iconify", - "set": "simple-icons", - "name": "arduino", - "color": "#00878F" - } - }, - { - "id": "cursor", - "name": "Cursor", - "description": "AI-powered code editor based on VS Code", - "category": "Dev: Editors", - "targets": { - "arch": "cursor-bin", - "nix": "code-cursor", - "homebrew": "--cask cursor" - }, - "unavailableReason": "Only available via [AUR](https://aur.archlinux.org/packages/cursor-bin) or Nix. Download from [cursor.com/download](https://cursor.com/download).", - "icon": { - "type": "iconify", - "set": "simple-icons", - "name": "cursor", - "color": "#232020ff" - } - }, - { - "id": "kate", - "name": "Kate", - "description": "Feature-rich text editor by KDE with syntax highlighting", - "category": "Dev: Editors", - "targets": { - "ubuntu": "kate", - "debian": "kate", - "arch": "kate", - "fedora": "kate", - "opensuse": "kate", - "nix": "kdePackages.kate", - "flatpak": "org.kde.kate", - "snap": "kate --classic", - "homebrew": "--cask kate" - }, - "icon": { - "type": "iconify", - "set": "simple-icons", - "name": "kde", - "color": "#1D99F3" - } - }, - { - "id": "emacs", - "name": "Emacs", - "description": "Extensible, customizable, free/libre text editor", - "category": "Dev: Editors", - "targets": { - "ubuntu": "emacs", - "debian": "emacs", - "arch": "emacs", - "fedora": "emacs", - "opensuse": "emacs", - "nix": "emacs", - "flatpak": "org.gnu.emacs", - "snap": "emacs --classic", - "homebrew": "--cask emacs-app" - }, - "icon": { - "type": "iconify", - "set": "simple-icons", - "name": "gnuemacs", - "color": "#7F5AB6" - } - }, - { - "id": "geany", - "name": "Geany", - "description": "Fast and lightweight IDE", - "category": "Dev: Editors", - "targets": { - "ubuntu": "geany", - "debian": "geany", - "arch": "geany", - "fedora": "geany", - "opensuse": "geany", - "nix": "geany", - "flatpak": "org.geany.Geany", - "homebrew": "--cask geany" - }, - "unavailableReason": "Snap is unmaintained, download from [Geany](https://www.geany.org/download/releases/) or use [Flatpak](https://flathub.org/en/apps/org.geany.Geany).", - "icon": { - "type": "url", - "url": "https://www.geany.org/static/img/geany.svg" - } - }, { "id": "intellij-idea", "name": "Intellij IDEA", @@ -332,5 +131,206 @@ "set": "logos", "name": "clion" } + }, + { + "id": "arduino", + "name": "Arduino IDE", + "description": "IDE for Arduino microcontroller development", + "category": "Dev: Editors", + "targets": { + "ubuntu": "arduino", + "debian": "arduino", + "arch": "arduino", + "fedora": "arduino", + "nix": "arduino-ide", + "flatpak": "cc.arduino.IDE2", + "snap": "arduino", + "homebrew": "--cask arduino-ide" + }, + "unavailableReason": "Arduino IDE is not in official openSUSE repos. Use [Flatpak](https://flathub.org/en/apps/cc.arduino.IDE2) or [Snap](https://snapcraft.io/arduino) instead.", + "icon": { + "type": "iconify", + "set": "simple-icons", + "name": "arduino", + "color": "#00878F" + } + }, + { + "id": "sublime", + "name": "Sublime Text", + "description": "Lightning-fast proprietary text editor", + "category": "Dev: Editors", + "targets": { + "arch": "sublime-text-4", + "nix": "sublime", + "flatpak": "com.sublimetext.three", + "snap": "sublime-text --classic", + "homebrew": "--cask sublime-text" + }, + "unavailableReason": "Not in official repos. Use [Flatpak](https://flathub.org/en/apps/com.sublimetext.three)/[Snap](https://snapcraft.io/sublime-text) or follow instructions at [sublimetext.com/docs/linux_repositories](https://www.sublimetext.com/docs/linux_repositories.html).", + "icon": { + "type": "iconify", + "set": "simple-icons", + "name": "sublimetext", + "color": "#FF9800" + } + }, + { + "id": "kate", + "name": "Kate", + "description": "Feature-rich text editor by KDE with syntax highlighting", + "category": "Dev: Editors", + "targets": { + "ubuntu": "kate", + "debian": "kate", + "arch": "kate", + "fedora": "kate", + "opensuse": "kate", + "nix": "kdePackages.kate", + "flatpak": "org.kde.kate", + "snap": "kate --classic", + "homebrew": "--cask kate" + }, + "icon": { + "type": "iconify", + "set": "simple-icons", + "name": "kde", + "color": "#1D99F3" + } + }, + { + "id": "geany", + "name": "Geany", + "description": "Fast and lightweight IDE", + "category": "Dev: Editors", + "targets": { + "ubuntu": "geany", + "debian": "geany", + "arch": "geany", + "fedora": "geany", + "opensuse": "geany", + "nix": "geany", + "flatpak": "org.geany.Geany", + "homebrew": "--cask geany" + }, + "unavailableReason": "Snap is unmaintained, download from [Geany](https://www.geany.org/download/releases/) or use [Flatpak](https://flathub.org/en/apps/org.geany.Geany).", + "icon": { + "type": "url", + "url": "https://www.geany.org/static/img/geany.svg" + } + }, + { + "id": "neovim", + "name": "Neovim", + "description": "Modernized Vim with better extensibility", + "category": "Dev: Editors", + "targets": { + "ubuntu": "neovim", + "debian": "neovim", + "arch": "neovim", + "fedora": "neovim", + "opensuse": "neovim", + "nix": "neovim", + "flatpak": "com.neovim.Neovim", + "snap": "nvim --classic", + "homebrew": "neovim" + }, + "icon": { + "type": "iconify", + "set": "simple-icons", + "name": "neovim", + "color": "#57A143" + } + }, + { + "id": "vim", + "name": "Vim", + "description": "The classic modal text editor that started it all", + "category": "Dev: Editors", + "targets": { + "ubuntu": "vim", + "debian": "vim", + "arch": "vim", + "fedora": "vim-enhanced", + "opensuse": "vim", + "nix": "vim", + "flatpak": "org.vim.Vim", + "snap": "vim-editor", + "homebrew": "vim" + }, + "icon": { + "type": "iconify", + "set": "simple-icons", + "name": "vim", + "color": "#019733" + } + }, + { + "id": "helix", + "name": "Helix", + "description": "Modal editor with LSP and tree-sitter built-in", + "category": "Dev: Editors", + "targets": { + "arch": "helix", + "fedora": "helix", + "opensuse": "helix", + "nix": "helix", + "flatpak": "com.helix-editor.Helix", + "snap": "helix --classic", + "homebrew": "helix" + }, + "unavailableReason": "Not in official Debian/Ubuntu repos. For Ubuntu, add the PPA: `sudo add-apt-repository ppa:maveonair/helix-editor && sudo apt update && sudo apt install helix`. For Debian, download .deb from [GitHub releases](https://github.com/helix-editor/helix/releases).", + "icon": { + "type": "iconify", + "set": "mdi", + "name": "dna", + "color": "#4E2F7F" + } + }, + { + "id": "micro", + "name": "Micro", + "description": "Easy-to-use terminal text editor like nano", + "category": "Dev: Editors", + "targets": { + "arch": "micro", + "ubuntu": "micro", + "debian": "micro", + "fedora": "micro", + "opensuse": "micro-editor", + "nix": "micro-editor", + "flatpak": "io.github.zyedidia.micro", + "snap": "micro --classic", + "homebrew": "micro" + }, + "icon": { + "type": "iconify", + "set": "simple-icons", + "name": "microeditor", + "color": "#2E3192" + } + }, + { + "id": "emacs", + "name": "Emacs", + "description": "Extensible, customizable, free/libre text editor", + "category": "Dev: Editors", + "targets": { + "ubuntu": "emacs", + "debian": "emacs", + "arch": "emacs", + "fedora": "emacs", + "opensuse": "emacs", + "nix": "emacs", + "flatpak": "org.gnu.emacs", + "snap": "emacs --classic", + "homebrew": "--cask emacs-app" + }, + "icon": { + "type": "iconify", + "set": "simple-icons", + "name": "gnuemacs", + "color": "#7F5AB6" + } } -] +] \ No newline at end of file diff --git a/src/lib/data.ts b/src/lib/data.ts index 2b1bd10..021a7ba 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -13,9 +13,11 @@ import vpnNetwork from './apps/vpn-network.json'; import security from './apps/security.json'; import fileSharing from './apps/file-sharing.json'; import system from './apps/system.json'; +import aiTools from './apps/ai-tools.json'; export type DistroId = 'ubuntu' | 'debian' | 'arch' | 'fedora' | 'opensuse' | 'nix' | 'flatpak' | 'snap' | 'homebrew'; +export type UniversalTargetId = 'npm' | 'script'; export type Category = | 'Web Browsers' @@ -32,7 +34,8 @@ export type Category = | 'VPN & Network' | 'Security' | 'File Sharing' - | 'System'; + | 'System' + | 'AI Tools'; export type IconDef = | { type: 'iconify'; set: string; name: string; color?: string } @@ -52,8 +55,9 @@ export interface AppData { description: string; category: Category; icon: IconDef; - targets: Partial>; + targets: Partial>; unavailableReason?: string; + note?: string; } export const getIconUrl = (icon: IconDef): string => { @@ -94,7 +98,8 @@ export const apps: AppData[] = [ ...(vpnNetwork as AppData[]), ...(security as AppData[]), ...(fileSharing as AppData[]), - ...(system as AppData[]) + ...(system as AppData[]), + ...(aiTools as AppData[]) ]; export const categories: Category[] = [ @@ -113,6 +118,7 @@ export const categories: Category[] = [ 'Dev: Tools', 'Terminal', 'CLI Tools', + 'AI Tools', ]; export const getAppsByCategory = (category: Category): AppData[] => { @@ -120,5 +126,7 @@ export const getAppsByCategory = (category: Category): AppData[] => { }; export const isAppAvailable = (app: AppData, distro: DistroId): boolean => { - return distro in app.targets; + return (distro in app.targets) || + ('npm' in app.targets) || + ('script' in app.targets); }; diff --git a/src/lib/generateInstallScript.ts b/src/lib/generateInstallScript.ts index 7b216ca..506c8a2 100644 --- a/src/lib/generateInstallScript.ts +++ b/src/lib/generateInstallScript.ts @@ -1,6 +1,10 @@ import { distros, type DistroId } from './data'; import { getSelectedPackages, + getUniversalPackages, + generateUniversalScript, + generateAsciiHeader, + generateSharedUtils, generateUbuntuScript, generateDebianScript, generateArchScript, @@ -22,43 +26,84 @@ export function generateInstallScript(options: GenerateOptions): string { const { distroId, selectedAppIds, helper = 'yay' } = options; const distro = distros.find(d => d.id === distroId); - if (!distro) return '#!/bin/bash\necho "Error: Unknown distribution"\nexit 1'; + if (!distro) return '#!/bin/bash\\necho "Error: Unknown distribution"\\nexit 1'; + const uScript = generateUniversalScript(selectedAppIds, distroId); const packages = getSelectedPackages(selectedAppIds, distroId); - if (packages.length === 0) return '#!/bin/bash\necho "No packages selected"\nexit 0'; + + if (packages.length === 0 && !uScript) return '#!/bin/bash\\necho "No packages selected"\\nexit 0'; + + const injectUniversal = (script: string) => script.replace('\\nprint_summary', '\\n' + uScript + '\\nprint_summary'); + + let scriptContent = ''; switch (distroId) { - case 'ubuntu': return generateUbuntuScript(packages); - case 'debian': return generateDebianScript(packages); - case 'arch': return generateArchScript(packages, helper); - case 'fedora': return generateFedoraScript(packages); - case 'opensuse': return generateOpenSUSEScript(packages); - case 'nix': return generateNixConfig(packages); - case 'flatpak': return generateFlatpakScript(packages); - case 'snap': return generateSnapScript(packages); - case 'homebrew': return generateHomebrewScript(packages); - default: return '#!/bin/bash\necho "Unsupported distribution"\nexit 1'; + case 'ubuntu': scriptContent = injectUniversal(generateUbuntuScript(packages)); break; + case 'debian': scriptContent = injectUniversal(generateDebianScript(packages)); break; + case 'arch': scriptContent = injectUniversal(generateArchScript(packages, helper)); break; + case 'fedora': scriptContent = injectUniversal(generateFedoraScript(packages)); break; + case 'opensuse': scriptContent = injectUniversal(generateOpenSUSEScript(packages)); break; + case 'flatpak': scriptContent = injectUniversal(generateFlatpakScript(packages)); break; + case 'snap': scriptContent = injectUniversal(generateSnapScript(packages)); break; + case 'homebrew': scriptContent = injectUniversal(generateHomebrewScript(packages)); break; + case 'nix': + if (packages.length === 0) return '# Nix\\n\\n# Generic Installers (Please run separately outside NixOS configuration):\\n' + uScript; + return generateNixConfig(packages) + '\\n\\n# ----------------------------------------\\n# NOTE: Universal packages (npm) cannot be strictly placed in environment.systemPackages.\\n# You may need to run these commands in a standard terminal:\\n# \\n/* \\n' + uScript + '\\n*/\\n'; + default: return '#!/bin/bash\\necho "Unsupported distribution"\\nexit 1'; } + + if (packages.length === 0) { + const universalCount = getUniversalPackages(selectedAppIds, 'npm', distroId).length + + getUniversalPackages(selectedAppIds, 'script', distroId).length; + return generateAsciiHeader(distro.name, universalCount) + + generateSharedUtils(distro.name.toLowerCase(), universalCount) + + uScript + + '\nprint_summary\n'; + } + + return scriptContent; } export function generateCommandline(options: GenerateOptions): string { const { selectedAppIds, distroId } = options; + + const npmPkgs = getUniversalPackages(selectedAppIds, 'npm', distroId); + const scriptPkgs = getUniversalPackages(selectedAppIds, 'script', distroId); + + const extras: string[] = []; + if (npmPkgs.length > 0) extras.push(`npm install -g ${npmPkgs.map(p => p.pkg).join(' ')}`); + + const extrasStr = extras.length > 0 ? (extras.join(' && ')) : ''; + const appendExtras = (cmd: string) => { + if (!cmd || cmd.startsWith('#')) return extrasStr ? extrasStr : cmd; + return extrasStr ? `${cmd} && ${extrasStr}` : cmd; + }; + const appendScripts = (cmd: string) => { + if (scriptPkgs.length === 0) return cmd; + const scriptsStr = scriptPkgs.map(p => p.pkg).join(' && '); + if (!cmd || cmd.startsWith('#')) return scriptsStr; + return `${cmd} && ${scriptsStr}`; + }; + const packages = getSelectedPackages(selectedAppIds, distroId); - if (packages.length === 0) return '# No packages selected'; + if (packages.length === 0 && extras.length === 0 && scriptPkgs.length === 0) return '# No packages selected'; const pkgList = packages.map(p => p.pkg).join(' '); switch (distroId) { case 'ubuntu': - case 'debian': return `sudo apt install -y ${pkgList}`; - case 'arch': return `yay -S --needed --noconfirm ${pkgList}`; - case 'fedora': return `sudo dnf install -y ${pkgList}`; - case 'opensuse': return `sudo zypper install -y ${pkgList}`; - case 'nix': return generateNixConfig(packages); - case 'flatpak': return `flatpak install flathub -y ${pkgList}`; - case 'snap': - if (packages.length === 1) return `sudo snap install ${pkgList}`; - return packages.map(p => `sudo snap install ${p.pkg}`).join(' && '); + case 'debian': return appendScripts(appendExtras(pkgList ? `sudo apt install -y ${pkgList}` : '')); + case 'arch': return appendScripts(appendExtras(pkgList ? `yay -S --needed --noconfirm ${pkgList}` : '')); + case 'fedora': return appendScripts(appendExtras(pkgList ? `sudo dnf install -y ${pkgList}` : '')); + case 'opensuse': return appendScripts(appendExtras(pkgList ? `sudo zypper install -y ${pkgList}` : '')); + case 'nix': return generateNixConfig(packages); // Nix handles its own thing without extras as simple cmds + case 'flatpak': return appendScripts(appendExtras(pkgList ? `flatpak install flathub -y ${pkgList}` : '')); + case 'snap': { + let cmd = ''; + if (packages.length === 1) cmd = `sudo snap install ${pkgList}`; + else if (packages.length > 1) cmd = packages.map(p => `sudo snap install ${p.pkg}`).join(' && '); + return appendScripts(appendExtras(cmd)); + } case 'homebrew': { const formulae = packages.filter(p => !p.pkg.startsWith('--cask ')); const casks = packages.filter(p => p.pkg.startsWith('--cask ')); @@ -69,8 +114,8 @@ export function generateCommandline(options: GenerateOptions): string { if (casks.length > 0) { parts.push(`brew install --cask ${casks.map(p => p.pkg.replace('--cask ', '')).join(' ')}`); } - return parts.join(' && ') || '# No packages selected'; + return appendScripts(appendExtras(parts.join(' && '))); } - default: return `# Install: ${pkgList}`; + default: return appendScripts(appendExtras(pkgList ? `# Install: ${pkgList}` : '')); } } diff --git a/src/lib/nix-unfree.json b/src/lib/nix-unfree.json index 63b6d5a..271563b 100644 --- a/src/lib/nix-unfree.json +++ b/src/lib/nix-unfree.json @@ -26,6 +26,7 @@ "jetbrains.idea", "jetbrains.pycharm", "jetbrains.clion", - "microsoft-edge" + "microsoft-edge", + "claude-code" ] } \ No newline at end of file diff --git a/src/lib/scripts/index.ts b/src/lib/scripts/index.ts index 6d1be9c..19ba4e3 100644 --- a/src/lib/scripts/index.ts +++ b/src/lib/scripts/index.ts @@ -1,6 +1,6 @@ -export { escapeShellString, getSelectedPackages, type PackageInfo } from './shared'; +export { escapeShellString, getSelectedPackages, getUniversalPackages, generateUniversalScript, generateAsciiHeader, generateSharedUtils, type PackageInfo } from './shared'; export { generateUbuntuScript } from './ubuntu'; export { generateDebianScript } from './debian'; export { generateArchScript } from './arch'; diff --git a/src/lib/scripts/shared.ts b/src/lib/scripts/shared.ts index 4e32cbd..160ef72 100644 --- a/src/lib/scripts/shared.ts +++ b/src/lib/scripts/shared.ts @@ -1,4 +1,4 @@ -import { apps, type DistroId, type AppData } from '../data'; +import { apps, type DistroId, type AppData, type UniversalTargetId } from '../data'; export interface PackageInfo { app: AppData; @@ -21,6 +21,33 @@ export function getSelectedPackages(selectedAppIds: Set, distroId: Distr .map(app => ({ app, pkg: app.targets[distroId]! })); } +export function getUniversalPackages(selectedAppIds: Set, target: UniversalTargetId, distroId?: DistroId): PackageInfo[] { + return Array.from(selectedAppIds) + .map(id => apps.find(a => a.id === id)) + .filter((app): app is AppData => !!app && !!app.targets[target] && (!distroId || !app.targets[distroId])) + .map(app => ({ app, pkg: app.targets[target]! })); +} + +export function generateUniversalScript(selectedAppIds: Set, distroId?: DistroId): string { + let script = ''; + const npmPkgs = getUniversalPackages(selectedAppIds, 'npm', distroId); + const scriptPkgs = getUniversalPackages(selectedAppIds, 'script', distroId); + + if (npmPkgs.length === 0 && scriptPkgs.length === 0) { + return ''; + } + + if (npmPkgs.length > 0) { + script += `if command -v npm >/dev/null 2>&1; then\\n info "Installing Node.js (npm) packages..."\\n${npmPkgs.map(p => ` with_retry npm install -g ${escapeShellString(p.pkg)}`).join('\\n')}\\nelse\\n warn "npm is not installed. Skipping: ${npmPkgs.map(p => escapeShellString(p.app.name)).join(', ')}"\\nfi\\n\\n`; + } + + if (scriptPkgs.length > 0) { + script += `info "Running custom install scripts..."\\n${scriptPkgs.map(p => `info "Running script for ${escapeShellString(p.app.name)}..."\\n${p.pkg}`).join('\\n')}\\n\\n`; + } + + return script; +} + export function generateAsciiHeader(distroName: string, pkgCount: number): string { const date = new Date().toISOString().split('T')[0]; return `#!/bin/bash diff --git a/src/lib/verified-flatpaks.json b/src/lib/verified-flatpaks.json index 4280767..bb66562 100644 --- a/src/lib/verified-flatpaks.json +++ b/src/lib/verified-flatpaks.json @@ -1,6 +1,6 @@ { "meta": { - "fetchedAt": "2026-03-25T08:54:15.513Z" + "fetchedAt": "2026-03-26T11:12:34.224Z" }, "count": 702, "apps": [ @@ -174,6 +174,7 @@ "com.tomjwatson.Emote", "com.toolstack.Folio", "com.ulaa.Ulaa", + "com.unicornsonlsd.finamp", "com.usebottles.bottles", "com.usebruno.Bruno", "com.valvesoftware.SteamLink", @@ -227,7 +228,6 @@ "eu.jumplink.Learn6502", "eu.nokun.MirrorHall", "eu.vcmi.VCMI", - "fr.arnaudmichel.launcherstudio", "fr.handbrake.ghb", "fr.quentium.acters", "garden.jamie.Morphosis", @@ -255,6 +255,7 @@ "io.frama.tractor.carburetor", "io.freetubeapp.FreeTube", "io.gdevelop.ide", + "io.github.AshBuk.FingerGo", "io.github.BrisklyDev.Brisk", "io.github.CyberTimon.RapidRAW", "io.github.DenysMb.Kontainer", @@ -326,7 +327,6 @@ "io.github.linx_systems.ClamUI", "io.github.lluciocc.Vish", "io.github.lullabyX.sone", - "io.github.maddecoder.Classic-RBDOOM-3-BFG", "io.github.marco_calautti.DeltaPatcher", "io.github.martchus.syncthingtray", "io.github.martinrotter.rssguard", @@ -357,7 +357,6 @@ "io.github.realmazharhussain.GdmSettings", "io.github.revisto.drum-machine", "io.github.rfrench3.scopebuddy-gui", - "io.github.schwarzen.colormydesktop", "io.github.seadve.Kooha", "io.github.seadve.Mousai", "io.github.sepehr_rs.Sudoku", @@ -444,6 +443,7 @@ "net.sapples.LiveCaptions", "net.shadps4.shadPS4", "net.sourceforge.VMPK", + "net.sourceforge.m64py.M64Py", "net.supertuxkart.SuperTuxKart", "net.trowell.typesetter", "net.waterfox.waterfox",