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) => (