From 56d13d3e08edb4f905cfbf0bc9c8f91ca061fa08 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Wed, 10 Dec 2025 20:03:59 -0800 Subject: [PATCH] feat: enhance plugin configuration with diagnostics options and refactor build hash computation --- components/BuildProgress.tsx | 26 ++-- components/Builder.tsx | 55 +++++++- components/PluginCard.tsx | 253 +++++++++++++++++++---------------- components/PluginConfig.tsx | 28 +++- convex/_generated/api.d.ts | 2 + convex/builds.ts | 58 +++++--- convex/lib/flags.ts | 52 +++++++ convex/schema.ts | 1 + pages/builds/+Page.tsx | 8 +- 9 files changed, 322 insertions(+), 161 deletions(-) create mode 100644 convex/lib/flags.ts diff --git a/components/BuildProgress.tsx b/components/BuildProgress.tsx index 78faf54..8b37ea6 100644 --- a/components/BuildProgress.tsx +++ b/components/BuildProgress.tsx @@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button" import { TARGETS } from "@/constants/targets" import type { Doc } from "@/convex/_generated/dataModel" import { ArtifactType, getArtifactFilenameBase } from "@/convex/lib/filename" +import { computeFlagsFromConfig } from "@/convex/lib/flags" import modulesData from "@/convex/modules.json" import { getImplicitDependencies, humanizeStatus } from "@/lib/utils" import registryData from "@/public/registry.json" @@ -48,7 +49,7 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t ? `https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}` : null - const shareUrl = `${window.location.origin}/builds?clone=${build.buildHash}` + const shareUrl = `${window.location.origin}/builds?hash=${build.buildHash}` const handleShare = async () => { try { @@ -64,7 +65,10 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t } const generateBashCommand = (): string => { - const flags = computeFlagsFromConfig(build.config) + const flags = computeFlagsFromConfig( + build.config, + registryData as Record }> + ) const target = build.config.target const version = build.config.version const plugins = build.config.pluginsEnabled || [] @@ -102,9 +106,8 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t } // Set build flags and build - if (flags) { - commands.push(`export PLATFORMIO_BUILD_FLAGS="${flags}"`) - } + // Always export PLATFORMIO_BUILD_FLAGS (even if empty) so users can see what was used + commands.push(`export PLATFORMIO_BUILD_FLAGS="${flags || ""}"`) commands.push(`pio run -e ${target}`) return commands.join("\n") @@ -129,15 +132,6 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t } } - // Compute build flags from config (same logic as computeFlagsFromConfig in convex/builds.ts) - const computeFlagsFromConfig = (config: typeof build.config): string => { - return Object.keys(config.modulesExcluded) - .sort() - .filter(module => config.modulesExcluded[module]) - .map((moduleExcludedName: string) => `-D${moduleExcludedName}=1`) - .join(" ") - } - const handleRetry = async () => { if (!build?._id || !onRetry) return try { @@ -216,10 +210,10 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t

{getStatusIcon()} { e.preventDefault() - navigate(`/builds?id=${build.buildHash}`) + navigate(`/builds?hash=${build.buildHash}`) }} className="hover:text-cyan-400 transition-colors" > diff --git a/components/Builder.tsx b/components/Builder.tsx index 98636ec..6324a58 100644 --- a/components/Builder.tsx +++ b/components/Builder.tsx @@ -40,6 +40,7 @@ export default function Builder({ cloneHash, pluginParam }: BuilderProps) { const [selectedVersion, setSelectedVersion] = useState(VERSIONS[0]) const [moduleConfig, setModuleConfig] = useState>({}) const [pluginConfig, setPluginConfig] = useState>({}) + const [pluginOptionsConfig, setPluginOptionsConfig] = useState>>({}) const [isFlashing, setIsFlashing] = useState(false) const [errorMessage, setErrorMessage] = useState(null) const [showModuleOverrides, setShowModuleOverrides] = useState(false) @@ -49,6 +50,12 @@ export default function Builder({ cloneHash, pluginParam }: BuilderProps) { // Get all enabled plugins const enabledPlugins = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true) + // Compute all enabled plugins (explicit + implicit) + const allEnabledPlugins = getDependedPlugins( + enabledPlugins, + registryData as Record }> + ) + // Calculate plugin compatibility const { compatibleTargets, filteredGroupedTargets, filteredTargetCategories } = usePluginCompatibility( enabledPlugins, @@ -132,6 +139,10 @@ export default function Builder({ cloneHash, pluginParam }: BuilderProps) { setPluginConfig(pluginObj) setShowPlugins(true) } + + if (config.pluginConfigs) { + setPluginOptionsConfig(config.pluginConfigs) + } }, [cloneHash, sharedBuild, handleSelectTarget, setActiveCategory, TARGET_CATEGORIES]) const selectedTargetLabel = (selectedTarget && TARGETS[selectedTarget]?.name) || selectedTarget @@ -197,6 +208,27 @@ export default function Builder({ cloneHash, pluginParam }: BuilderProps) { }) } + const handleTogglePluginOption = (pluginId: string, optionKey: string, enabled: boolean) => { + setPluginOptionsConfig(prev => { + const next = { ...prev } + if (!next[pluginId]) { + next[pluginId] = {} + } + const pluginOptions = { ...next[pluginId] } + if (enabled) { + pluginOptions[optionKey] = true + } else { + delete pluginOptions[optionKey] + } + if (Object.keys(pluginOptions).length === 0) { + delete next[pluginId] + } else { + next[pluginId] = pluginOptions + } + return next + }) + } + const handleFlash = async () => { if (!selectedTarget) return setIsFlashing(true) @@ -214,13 +246,27 @@ export default function Builder({ cloneHash, pluginParam }: BuilderProps) { const plugin = (registryData as Record)[slug] return `${slug}@${plugin.version}` }) + + // Filter plugin config to only include enabled plugins + const filteredPluginConfig = Object.keys(pluginOptionsConfig).reduce( + (acc, pluginId) => { + if (allEnabledPlugins.includes(pluginId)) { + acc[pluginId] = pluginOptionsConfig[pluginId] + } + return acc + }, + {} as Record> + ) + const result = await ensureBuildFromConfig({ target: selectedTarget, version: selectedVersion, modulesExcluded: moduleConfig, pluginsEnabled: pluginsEnabled.length > 0 ? pluginsEnabled : undefined, + pluginConfigs: Object.keys(filteredPluginConfig).length > 0 ? filteredPluginConfig : undefined, + registryData: registryData, }) - navigate(`/builds?id=${result.buildHash}`) + navigate(`/builds?hash=${result.buildHash}`) } catch (error) { const message = error instanceof Error ? error.message : String(error) setErrorMessage("Failed to start build. Please try again.") @@ -273,13 +319,18 @@ export default function Builder({ cloneHash, pluginParam }: BuilderProps) { setShowPlugins(prev => !prev)} onTogglePlugin={handleTogglePlugin} - onReset={() => setPluginConfig({})} + onTogglePluginOption={handleTogglePluginOption} + onReset={() => { + setPluginConfig({}) + setPluginOptionsConfig({}) + }} /> void disabled?: boolean enabledLabel?: string + diagnostics?: { + checked: boolean + onCheckedChange: (checked: boolean) => void + } } type PluginCardProps = PluginCardToggleProps | PluginCardLinkProps | PluginCardLinkToggleProps @@ -176,49 +181,35 @@ export function PluginCard(props: PluginCardProps) { {isIncompatible && incompatibleReason && (

{incompatibleReason}

)} + {/* Diagnostics checkbox - only show for link-toggle variant when enabled */} + {isLinkToggle && props.isEnabled && props.diagnostics && ( +
+ +
+ )} - {/* Metadata row */} -
- {version && v{version}} - {isLinkToggle && flashCount !== undefined && ( -
- - - - {flashCount} -
- )} - {isLink && downloads !== undefined && ( -
- - {downloads.toLocaleString()} -
- )} - {homepage && - homepage !== repo && - (isLink || isLinkToggle) && - (isLink ? ( - - ) : ( -
e.stopPropagation()} - className="hover:opacity-80 transition-opacity" - > - + + + + ) : ( + e.stopPropagation()} + className="hover:opacity-80 transition-opacity" > - - - - ))} - {starsBadgeUrl && - repo && - (isLink ? ( - - ) : ( - e.stopPropagation()} - className="hover:opacity-80 transition-opacity" - > - GitHub stars - - ))} -
- {/* Build Now button - absolutely positioned in lower right */} - {isLink && ( -
+ role="img" + aria-label="Homepage" + > + + + + ))} + {starsBadgeUrl && + repo && + (isLink ? ( + + ) : ( + e.stopPropagation()} + className="hover:opacity-80 transition-opacity" + > + GitHub stars + + ))} +
+ {/* Toggle switch or Build Now button */} + {isLinkToggle ? ( + + ) : isLink ? ( - - )} - {/* Toggle switch - absolutely positioned in lower right */} - {isLinkToggle && ( -
-
- -
-
- )} + ) : null} + )} ) - const baseClassName = `relative flex ${isToggle ? "items-start gap-4" : "flex-col gap-3"} p-4 rounded-lg border-2 transition-colors h-full ${ + const baseClassName = `relative flex ${isToggle ? "items-start gap-4" : "flex-col"} p-4 rounded-lg border-2 transition-colors h-full ${ isIncompatible ? "border-slate-800 bg-slate-900/30 opacity-60 cursor-not-allowed" : prominent diff --git a/components/PluginConfig.tsx b/components/PluginConfig.tsx index 14801b9..c02b284 100644 --- a/components/PluginConfig.tsx +++ b/components/PluginConfig.tsx @@ -10,23 +10,27 @@ import { ChevronDown, ChevronRight } from "lucide-react" interface PluginConfigProps { pluginConfig: Record + pluginOptionsConfig: Record> selectedTarget: string pluginParam?: string pluginFlashCounts: Record showPlugins: boolean onToggleShow: () => void onTogglePlugin: (id: string, enabled: boolean) => void + onTogglePluginOption: (pluginId: string, optionKey: string, enabled: boolean) => void onReset: () => void } export function PluginConfig({ pluginConfig, + pluginOptionsConfig, selectedTarget, pluginParam, pluginFlashCounts, showPlugins, onToggleShow, onTogglePlugin, + onTogglePluginOption, onReset, }: PluginConfigProps) { const pluginCount = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true).length @@ -86,13 +90,13 @@ export function PluginConfig({ {Object.entries(registryData) .sort(([, pluginA], [, pluginB]) => { // Featured plugins first - const featuredA = pluginA.featured ?? false - const featuredB = pluginB.featured ?? false + const featuredA = (pluginA as { featured?: boolean }).featured ?? false + const featuredB = (pluginB as { featured?: boolean }).featured ?? false if (featuredA !== featuredB) { return featuredA ? -1 : 1 } // Then alphabetical by name - return pluginA.name.localeCompare(pluginB.name) + return (pluginA as { name: string }).name.localeCompare((pluginB as { name: string }).name) }) .map(([slug, plugin]) => { // Check if plugin is required by another explicitly selected plugin @@ -129,6 +133,12 @@ export function PluginConfig({ // Check if this is the preselected plugin from URL const isPreselected = pluginParam === slug + const pluginRegistry = plugin as { + featured?: boolean + } + const isPluginEnabled = allEnabledPlugins.includes(slug) + const pluginOptions = pluginOptionsConfig[slug] ?? {} + return ( onTogglePlugin(slug, enabled)} disabled={isImplicit || isIncompatible || isPreselected} enabledLabel={isPreselected ? "Locked" : isImplicit ? "Required" : "Add"} incompatibleReason={isIncompatible ? "Not compatible with this target" : undefined} - featured={plugin.featured ?? false} + featured={pluginRegistry.featured ?? false} flashCount={pluginFlashCounts[slug] ?? 0} homepage={plugin.homepage} version={plugin.version} repo={plugin.repo} + diagnostics={ + isPluginEnabled + ? { + checked: pluginOptions.diagnostics ?? false, + onCheckedChange: checked => onTogglePluginOption(slug, "diagnostics", checked === true), + } + : undefined + } /> ) })} diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index a358b64..3838b44 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -15,6 +15,7 @@ import type * as builds from "../builds.js"; import type * as helpers from "../helpers.js"; import type * as http from "../http.js"; import type * as lib_filename from "../lib/filename.js"; +import type * as lib_flags from "../lib/flags.js"; import type * as lib_r2 from "../lib/r2.js"; import type * as plugins from "../plugins.js"; import type * as profiles from "../profiles.js"; @@ -33,6 +34,7 @@ declare const fullApi: ApiFromModules<{ helpers: typeof helpers; http: typeof http; "lib/filename": typeof lib_filename; + "lib/flags": typeof lib_flags; "lib/r2": typeof lib_r2; plugins: typeof plugins; profiles: typeof profiles; diff --git a/convex/builds.ts b/convex/builds.ts index 725224f..889f365 100644 --- a/convex/builds.ts +++ b/convex/builds.ts @@ -4,6 +4,7 @@ import { api, internal } from "./_generated/api" import type { Doc, Id } from "./_generated/dataModel" import { internalMutation, mutation, query } from "./_generated/server" import { ArtifactType, getArtifactFilenameBase } from "./lib/filename" +import { computeFlagsFromConfig } from "./lib/flags" import { generateSignedDownloadUrl } from "./lib/r2" import { buildFields } from "./schema" @@ -31,18 +32,8 @@ export const getByHash = query({ }, }) -/** - * Computes flags string from build config. - * Only excludes modules explicitly marked as excluded (config[id] === true). - */ -export function computeFlagsFromConfig(config: Doc<"builds">["config"]): string { - // Sort modules to ensure consistent order - return Object.keys(config.modulesExcluded) - .sort() - .filter(module => config.modulesExcluded[module]) - .map((moduleExcludedName: string) => `-D${moduleExcludedName}=1`) - .join(" ") -} +// Re-export for backward compatibility +export { computeFlagsFromConfig } from "./lib/flags" /** * Encodes a byte array to base62 string. @@ -77,16 +68,39 @@ async function computeBuildHashInternal( version: string, target: string, flags: string, - plugins: string[] + plugins: string[], + pluginConfig?: Record> ): Promise { // Input is now the exact parameters used for the build // Sort plugins array for consistent hashing const sortedPlugins = [...plugins].sort() + // Sort plugin config for consistent hashing + const sortedPluginConfig = pluginConfig + ? Object.keys(pluginConfig) + .sort() + .reduce( + (acc, pluginSlug) => { + const sortedOptions = Object.keys(pluginConfig[pluginSlug]) + .sort() + .reduce( + (opts, optKey) => { + opts[optKey] = pluginConfig[pluginSlug][optKey] + return opts + }, + {} as Record + ) + acc[pluginSlug] = sortedOptions + return acc + }, + {} as Record> + ) + : undefined const input = JSON.stringify({ version, target, flags, plugins: sortedPlugins, + pluginConfig: sortedPluginConfig, }) // Use Web Crypto API for SHA-256 hashing @@ -103,10 +117,13 @@ async function computeBuildHashInternal( * Computes buildHash from build config. * This is the single source of truth for build hash computation. */ -export async function computeBuildHash(config: Doc<"builds">["config"]): Promise<{ hash: string; flags: string }> { - const flags = computeFlagsFromConfig(config) +export async function computeBuildHash( + config: Doc<"builds">["config"], + registryData?: Record }> +): Promise<{ hash: string; flags: string }> { + const flags = computeFlagsFromConfig(config, registryData) const plugins = config.pluginsEnabled ?? [] - const hash = await computeBuildHashInternal(config.version, config.target, flags, plugins) + const hash = await computeBuildHashInternal(config.version, config.target, flags, plugins, config.pluginConfigs) return { hash, flags } } @@ -185,6 +202,8 @@ export const ensureBuildFromConfig = mutation({ version: v.string(), modulesExcluded: v.optional(v.record(v.string(), v.boolean())), pluginsEnabled: v.optional(v.array(v.string())), + pluginConfigs: v.optional(v.record(v.string(), v.record(v.string(), v.boolean()))), + registryData: v.optional(v.any()), profileName: v.optional(v.string()), profileDescription: v.optional(v.string()), }, @@ -195,10 +214,15 @@ export const ensureBuildFromConfig = mutation({ modulesExcluded: args.modulesExcluded ?? {}, target: args.target, pluginsEnabled: args.pluginsEnabled, + pluginConfigs: args.pluginConfigs, } // Compute build hash (single source of truth) - const { hash: buildHash, flags } = await computeBuildHash(config) + // Registry data is optional - diagnostics works for all plugins without registry lookup + const registryData = args.registryData as + | Record }> + | undefined + const { hash: buildHash, flags } = await computeBuildHash(config, registryData) const existingBuild = await ctx.db .query("builds") diff --git a/convex/lib/flags.ts b/convex/lib/flags.ts new file mode 100644 index 0000000..f4e9e95 --- /dev/null +++ b/convex/lib/flags.ts @@ -0,0 +1,52 @@ +import type { Doc } from "../_generated/dataModel" + +/** + * Computes flags string from build config. + * Only excludes modules explicitly marked as excluded (config[id] === true). + * Also includes plugin config options (e.g., diagnostics). + * + * @param config - Build configuration with modulesExcluded and pluginConfigs + * @param registryData - Optional registry data for custom config options (not needed for diagnostics) + */ +export function computeFlagsFromConfig( + config: Doc<"builds">["config"], + registryData?: Record }> +): string { + const flags: string[] = [] + + // Sort modules to ensure consistent order + const moduleFlags = Object.keys(config.modulesExcluded) + .sort() + .filter(module => config.modulesExcluded[module]) + .map((moduleExcludedName: string) => `-D${moduleExcludedName}=1`) + flags.push(...moduleFlags) + + // Add plugin config options (diagnostics is available for all plugins) + if (config.pluginConfigs) { + for (const [pluginSlug, pluginOptions] of Object.entries(config.pluginConfigs)) { + // Handle diagnostics option (available for all plugins) + if (pluginOptions.diagnostics) { + // Convert plugin slug to uppercase define name (e.g., "lofs" -> "LOFS_PLUGIN_DIAGNOSTICS") + const defineName = `${pluginSlug.toUpperCase().replace(/-/g, "_")}_PLUGIN_DIAGNOSTICS` + flags.push(`-D${defineName}`) + } + + // Handle other custom config options from registry (if any) + if (registryData) { + const plugin = registryData[pluginSlug] + if (plugin?.configOptions) { + for (const [optionKey, enabled] of Object.entries(pluginOptions)) { + if (optionKey !== "diagnostics" && enabled) { + const option = plugin.configOptions[optionKey] + if (option?.define) { + flags.push(`-D${option.define}`) + } + } + } + } + } + } + } + + return flags.join(" ") +} diff --git a/convex/schema.ts b/convex/schema.ts index bb683a2..49c3a6d 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -7,6 +7,7 @@ export const buildConfigFields = { modulesExcluded: v.record(v.string(), v.boolean()), target: v.string(), pluginsEnabled: v.optional(v.array(v.string())), + pluginConfigs: v.optional(v.record(v.string(), v.record(v.string(), v.boolean()))), } export const profileFields = { diff --git a/pages/builds/+Page.tsx b/pages/builds/+Page.tsx index 93f86fd..71d3461 100644 --- a/pages/builds/+Page.tsx +++ b/pages/builds/+Page.tsx @@ -11,12 +11,12 @@ export default function BuildsPage() { const pageContext = usePageContext() const urlSearchParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null const cloneHash = urlSearchParams?.get("clone") - const buildId = urlSearchParams?.get("id") + const buildHash = urlSearchParams?.get("hash") const pluginParam = urlSearchParams?.get("plugin") - // If we have a build ID, show the build progress page - if (buildId) { - return + // If we have a build hash, show the build progress page + if (buildHash) { + return } // Otherwise, show the builder (handles clone and plugin params)