diff --git a/src/components/PluginToggle.tsx b/src/components/PluginToggle.tsx index 59517a9..994fa7f 100644 --- a/src/components/PluginToggle.tsx +++ b/src/components/PluginToggle.tsx @@ -11,6 +11,8 @@ interface PluginToggleProps { flashCount?: number homepage?: string version?: string + disabled?: boolean + enabledLabel?: string } export function PluginToggle({ @@ -22,6 +24,8 @@ export function PluginToggle({ flashCount = 0, homepage, version, + disabled = false, + enabledLabel = 'Add', }: PluginToggleProps) { return (
@@ -68,8 +72,9 @@ export function PluginToggle({
diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 2a4c41c..0446667 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -44,3 +44,84 @@ export function humanizeStatus(status: string): string { .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' ') } + +/** + * Resolves plugin dependencies recursively from registry. + * Returns all plugins that should be enabled (selected plugins + their dependencies). + * Filters out "meshtastic" as it's a firmware version requirement, not a plugin. + */ +export function getDependedPlugins( + selectedPlugins: string[], + registry: Record }> +): string[] { + const result = new Set() + const visited = new Set() + + function resolveDependencies(pluginId: string) { + // Prevent circular dependencies + if (visited.has(pluginId)) return + visited.add(pluginId) + + const plugin = registry[pluginId] + if (!plugin || !plugin.dependencies) return + + // Process each dependency + for (const [depId] of Object.entries(plugin.dependencies)) { + // Skip "meshtastic" - it's a firmware version requirement, not a plugin + if (depId === 'meshtastic') continue + + // Only include dependencies that exist in the registry + if (depId in registry) { + result.add(depId) + // Recursively resolve transitive dependencies + resolveDependencies(depId) + } + } + } + + // Start with selected plugins + for (const pluginId of selectedPlugins) { + if (pluginId in registry) { + result.add(pluginId) + resolveDependencies(pluginId) + } + } + + return Array.from(result) +} + +/** + * Gets only the implicit dependencies (dependencies that are not explicitly selected). + * Returns a set of plugin IDs that are dependencies but not in the explicitly selected list. + */ +export function getImplicitDependencies( + explicitlySelectedPlugins: string[], + registry: Record }> +): Set { + const allDependencies = getDependedPlugins(explicitlySelectedPlugins, registry) + const explicitSet = new Set(explicitlySelectedPlugins) + return new Set(allDependencies.filter((id) => !explicitSet.has(id))) +} + +/** + * Checks if a plugin is required by any other explicitly selected plugin. + * Returns true if the plugin is a dependency (direct or transitive) of at least one explicitly selected plugin. + */ +export function isRequiredByOther( + pluginId: string, + explicitlySelectedPlugins: string[], + registry: Record }> +): boolean { + // Check if any explicitly selected plugin depends on this plugin + for (const selectedId of explicitlySelectedPlugins) { + if (selectedId === pluginId) continue // Skip self + + // Get all dependencies (including transitive) of this selected plugin + const allDeps = getDependedPlugins([selectedId], registry) + if (allDeps.includes(pluginId)) { + return true + } + } + + return false +} diff --git a/src/pages/BuildNew.tsx b/src/pages/BuildNew.tsx index a793680..66d3004 100644 --- a/src/pages/BuildNew.tsx +++ b/src/pages/BuildNew.tsx @@ -6,6 +6,11 @@ import { toast } from 'sonner' import { ModuleToggle } from '@/components/ModuleToggle' import { PluginToggle } from '@/components/PluginToggle' import { Button } from '@/components/ui/button' +import { + getDependedPlugins, + getImplicitDependencies, + isRequiredByOther, +} from '@/lib/utils' import { api } from '../../convex/_generated/api' import modulesData from '../../convex/modules.json' import registryData from '../../registry/registry.json' @@ -164,12 +169,33 @@ export default function BuildNew() { } // Set plugin config (convert array to object format) + // Only add explicitly selected plugins, not implicit dependencies if (config.pluginsEnabled && config.pluginsEnabled.length > 0) { + const allPluginSlugs = config.pluginsEnabled.map((pluginId) => { + return pluginId.includes('@') ? pluginId.split('@')[0] : pluginId + }) + + // Determine which plugins are required by others (implicit dependencies) + const requiredByOthers = new Set() + for (const pluginSlug of allPluginSlugs) { + if ( + isRequiredByOther( + pluginSlug, + allPluginSlugs, + registryData as Record< + string, + { dependencies?: Record } + > + ) + ) { + requiredByOthers.add(pluginSlug) + } + } + + // Only add plugins that are NOT required by others (explicitly selected) const pluginObj: Record = {} - config.pluginsEnabled.forEach((pluginId) => { - // Extract slug from "slug@version" format if present - const slug = pluginId.includes('@') ? pluginId.split('@')[0] : pluginId - if (slug in registryData) { + allPluginSlugs.forEach((slug) => { + if (slug in registryData && !requiredByOthers.has(slug)) { pluginObj[slug] = true } }) @@ -198,12 +224,85 @@ export default function BuildNew() { } const handleTogglePlugin = (id: string, enabled: boolean) => { + // Get current explicit selections + const explicitPlugins = Object.keys(pluginConfig).filter( + (pluginId) => pluginConfig[pluginId] === true + ) + + // Check if this plugin is currently an implicit dependency + const implicitDeps = getImplicitDependencies( + explicitPlugins, + registryData as Record< + string, + { dependencies?: Record } + > + ) + + // Check if this plugin is required by another explicitly selected plugin + const isRequired = isRequiredByOther( + id, + explicitPlugins, + registryData as Record< + string, + { dependencies?: Record } + > + ) + + // Don't allow toggling implicit dependencies at all + // (they should be disabled in the UI, but add this as a safeguard) + if (implicitDeps.has(id)) { + return // Can't toggle implicit dependencies + } + + // Don't allow disabling if it's required by another explicitly selected plugin + if (!enabled && isRequired) { + return // Can't disable required plugins + } + setPluginConfig((prev) => { const next = { ...prev } if (enabled) { + // Enabling: add to explicit selection (even if it was implicit) next[id] = true } else { + // Disabling: remove from explicit selection delete next[id] + + // Recompute what plugins are still needed after removal + const remainingExplicit = Object.keys(next).filter( + (pluginId) => next[pluginId] === true + ) + const allStillNeeded = getDependedPlugins( + remainingExplicit, + registryData as Record< + string, + { dependencies?: Record } + > + ) + + // Remove any plugins from config that are no longer needed + // BUT preserve all plugins that are currently explicitly selected (in remainingExplicit) + // This ensures that plugins that were explicitly selected remain explicitly selected + // even if they temporarily became implicit and then un-implicit + for (const pluginId of Object.keys(next)) { + if ( + next[pluginId] === true && + !allStillNeeded.includes(pluginId) && + !remainingExplicit.includes(pluginId) + ) { + // This plugin is no longer needed and is not in the remaining explicit list + // Only remove if it's truly not needed and wasn't explicitly selected + // Note: If a plugin is in `next` with value `true`, it should be in `remainingExplicit` + // So this condition should rarely be true, but we keep it as a safety check + delete next[pluginId] + } + } + + // Ensure all remaining explicitly selected plugins stay in config + // (they should already be there, but this ensures they remain even if they're not needed) + for (const pluginId of remainingExplicit) { + next[pluginId] = true + } } return next }) @@ -217,7 +316,21 @@ export default function BuildNew() { const enabledSlugs = Object.keys(pluginConfig).filter( (id) => pluginConfig[id] === true ) - const pluginsEnabled = enabledSlugs.map((slug) => { + + // Double-check: filter out any implicit dependencies that might have snuck in + // This ensures we only send explicitly selected plugins to the backend + const implicitDeps = getImplicitDependencies( + enabledSlugs, + registryData as Record< + string, + { dependencies?: Record } + > + ) + const explicitOnlySlugs = enabledSlugs.filter( + (slug) => !implicitDeps.has(slug) + ) + + const pluginsEnabled = explicitOnlySlugs.map((slug) => { const plugin = (registryData as Record)[ slug ] @@ -442,33 +555,77 @@ export default function BuildNew() {
- {Object.entries(registryData) - .sort(([, pluginA], [, pluginB]) => { - // Featured plugins first - const featuredA = pluginA.featured ?? false - const featuredB = pluginB.featured ?? false - if (featuredA !== featuredB) { - return featuredA ? -1 : 1 - } - // Then alphabetical by name - return pluginA.name.localeCompare(pluginB.name) - }) - .map(([slug, plugin]) => ( - - handleTogglePlugin(slug, enabled) + {(() => { + // Get explicitly selected plugins (user-selected) + const explicitPlugins = Object.keys(pluginConfig).filter( + (id) => pluginConfig[id] === true + ) + + // Compute implicit dependencies (dependencies that are not explicitly selected) + const implicitDeps = getImplicitDependencies( + explicitPlugins, + registryData as Record< + string, + { dependencies?: Record } + > + ) + + // Compute all enabled plugins (explicit + implicit) + const allEnabledPlugins = getDependedPlugins( + explicitPlugins, + registryData as Record< + string, + { dependencies?: Record } + > + ) + + return Object.entries(registryData) + .sort(([, pluginA], [, pluginB]) => { + // Featured plugins first + const featuredA = pluginA.featured ?? false + const featuredB = pluginB.featured ?? false + if (featuredA !== featuredB) { + return featuredA ? -1 : 1 } - featured={plugin.featured ?? false} - flashCount={pluginFlashCounts[slug] ?? 0} - homepage={plugin.homepage} - version={plugin.version} - /> - ))} + // Then alphabetical by name + return pluginA.name.localeCompare(pluginB.name) + }) + .map(([slug, plugin]) => { + // Check if plugin is required by another explicitly selected plugin + const isRequired = isRequiredByOther( + slug, + explicitPlugins, + registryData as Record< + string, + { dependencies?: Record } + > + ) + // Plugin is implicit if it's either: + // 1. Not explicitly selected but is a dependency, OR + // 2. Explicitly selected but required by another explicitly selected plugin + const isImplicit = + implicitDeps.has(slug) || + (explicitPlugins.includes(slug) && isRequired) + return ( + + handleTogglePlugin(slug, enabled) + } + disabled={isImplicit} + enabledLabel={isImplicit ? 'Required' : 'Add'} + featured={plugin.featured ?? false} + flashCount={pluginFlashCounts[slug] ?? 0} + homepage={plugin.homepage} + version={plugin.version} + /> + ) + }) + })()}
)} diff --git a/src/pages/BuildProgress.tsx b/src/pages/BuildProgress.tsx index 97d0722..1594db0 100644 --- a/src/pages/BuildProgress.tsx +++ b/src/pages/BuildProgress.tsx @@ -12,7 +12,11 @@ import { Link, useNavigate, useParams } from 'react-router-dom' import { toast } from 'sonner' import { BuildDownloadButton } from '@/components/BuildDownloadButton' import { Button } from '@/components/ui/button' -import { humanizeStatus } from '@/lib/utils' +import { + getDependedPlugins, + getImplicitDependencies, + humanizeStatus, +} from '@/lib/utils' import { api } from '../../convex/_generated/api' import { ArtifactType } from '../../convex/builds' import modulesData from '../../convex/modules.json' @@ -231,19 +235,76 @@ export default function BuildProgress() { (module) => build.config.modulesExcluded[module.id] === true ) - // Get enabled plugins - const enabledPlugins = (build.config.pluginsEnabled || []).map((pluginId) => { + // Get explicitly selected plugins from stored config + // The stored config only contains explicitly selected plugins (not resolved dependencies) + const explicitPluginSlugs = (build.config.pluginsEnabled || []).map( + (pluginId) => { + // Extract slug from "slug@version" format if present + return pluginId.includes('@') ? pluginId.split('@')[0] : pluginId + } + ) + + // Resolve dependencies to get all plugins that should be enabled + const allResolvedPlugins = getDependedPlugins( + explicitPluginSlugs, + registryData as Record }> + ) + + // Get implicit dependencies (dependencies that are not explicitly selected) + const implicitDeps = getImplicitDependencies( + explicitPluginSlugs, + registryData as Record }> + ) + + // Separate explicit and implicit plugins + const explicitPlugins: Array<{ + id: string + name: string + description: string + version: string + }> = [] + const implicitPlugins: Array<{ + id: string + name: string + description: string + version: string + }> = [] + + // Process explicitly selected plugins + ;(build.config.pluginsEnabled || []).forEach((pluginId) => { // Extract slug from "slug@version" format if present const slug = pluginId.includes('@') ? pluginId.split('@')[0] : pluginId - const pluginData = (registryData as Record)[slug] - return { + const pluginData = (registryData as Record< + string, + { name: string; description: string; version: string } + >)[slug] + const pluginInfo = { id: slug, name: pluginData?.name || slug, description: pluginData?.description || '', - version: pluginId.includes('@') ? pluginId.split('@')[1] : pluginData?.version || '', + version: pluginId.includes('@') + ? pluginId.split('@')[1] + : pluginData?.version || '', } + explicitPlugins.push(pluginInfo) }) + // Process implicit dependencies (resolved but not in stored config) + for (const slug of implicitDeps) { + const pluginData = (registryData as Record< + string, + { name: string; description: string; version: string } + >)[slug] + if (pluginData) { + implicitPlugins.push({ + id: slug, + name: pluginData.name || slug, + description: pluginData.description || '', + version: pluginData.version || '', + }) + } + } + return (
@@ -420,7 +481,9 @@ export default function BuildProgress() { )} {/* Build Configuration Summary */} - {(excludedModules.length > 0 || enabledPlugins.length > 0) && ( + {(excludedModules.length > 0 || + explicitPlugins.length > 0 || + implicitPlugins.length > 0) && (
{/* Excluded Modules */} {excludedModules.length > 0 && ( @@ -447,12 +510,45 @@ export default function BuildProgress() { )} {/* Enabled Plugins */} - {enabledPlugins.length > 0 && ( + {explicitPlugins.length > 0 && (

Enabled Plugins

- {enabledPlugins.map((plugin) => ( + {explicitPlugins.map((plugin) => ( +
+
+

+ {plugin.name} +

+ {plugin.version && ( + + v{plugin.version} + + )} +
+ {plugin.description && ( +

+ {plugin.description} +

+ )} +
+ ))} +
+
+
+ )} + + {/* Required Plugins (Implicit Dependencies) */} + {implicitPlugins.length > 0 && ( +
+

Required Plugins

+
+
+ {implicitPlugins.map((plugin) => (