feat: enhance plugin configuration with diagnostics options and refactor build hash computation

This commit is contained in:
Ben Allfree
2025-12-10 20:03:59 -08:00
parent 7349d81102
commit 56d13d3e08
9 changed files with 322 additions and 161 deletions

View File

@@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button"
import { TARGETS } from "@/constants/targets" import { TARGETS } from "@/constants/targets"
import type { Doc } from "@/convex/_generated/dataModel" import type { Doc } from "@/convex/_generated/dataModel"
import { ArtifactType, getArtifactFilenameBase } from "@/convex/lib/filename" import { ArtifactType, getArtifactFilenameBase } from "@/convex/lib/filename"
import { computeFlagsFromConfig } from "@/convex/lib/flags"
import modulesData from "@/convex/modules.json" import modulesData from "@/convex/modules.json"
import { getImplicitDependencies, humanizeStatus } from "@/lib/utils" import { getImplicitDependencies, humanizeStatus } from "@/lib/utils"
import registryData from "@/public/registry.json" 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}` ? `https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}`
: null : null
const shareUrl = `${window.location.origin}/builds?clone=${build.buildHash}` const shareUrl = `${window.location.origin}/builds?hash=${build.buildHash}`
const handleShare = async () => { const handleShare = async () => {
try { try {
@@ -64,7 +65,10 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
} }
const generateBashCommand = (): string => { const generateBashCommand = (): string => {
const flags = computeFlagsFromConfig(build.config) const flags = computeFlagsFromConfig(
build.config,
registryData as Record<string, { configOptions?: Record<string, { define: string }> }>
)
const target = build.config.target const target = build.config.target
const version = build.config.version const version = build.config.version
const plugins = build.config.pluginsEnabled || [] const plugins = build.config.pluginsEnabled || []
@@ -102,9 +106,8 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
} }
// Set build flags and build // Set build flags and build
if (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(`export PLATFORMIO_BUILD_FLAGS="${flags || ""}"`)
}
commands.push(`pio run -e ${target}`) commands.push(`pio run -e ${target}`)
return commands.join("\n") 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 () => { const handleRetry = async () => {
if (!build?._id || !onRetry) return if (!build?._id || !onRetry) return
try { try {
@@ -216,10 +210,10 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
<h2 className="text-2xl font-semibold mb-2 flex items-center gap-2"> <h2 className="text-2xl font-semibold mb-2 flex items-center gap-2">
{getStatusIcon()} {getStatusIcon()}
<a <a
href={`/builds?id=${build.buildHash}`} href={`/builds?hash=${build.buildHash}`}
onClick={e => { onClick={e => {
e.preventDefault() e.preventDefault()
navigate(`/builds?id=${build.buildHash}`) navigate(`/builds?hash=${build.buildHash}`)
}} }}
className="hover:text-cyan-400 transition-colors" className="hover:text-cyan-400 transition-colors"
> >

View File

@@ -40,6 +40,7 @@ export default function Builder({ cloneHash, pluginParam }: BuilderProps) {
const [selectedVersion, setSelectedVersion] = useState<string>(VERSIONS[0]) const [selectedVersion, setSelectedVersion] = useState<string>(VERSIONS[0])
const [moduleConfig, setModuleConfig] = useState<Record<string, boolean>>({}) const [moduleConfig, setModuleConfig] = useState<Record<string, boolean>>({})
const [pluginConfig, setPluginConfig] = useState<Record<string, boolean>>({}) const [pluginConfig, setPluginConfig] = useState<Record<string, boolean>>({})
const [pluginOptionsConfig, setPluginOptionsConfig] = useState<Record<string, Record<string, boolean>>>({})
const [isFlashing, setIsFlashing] = useState(false) const [isFlashing, setIsFlashing] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null) const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [showModuleOverrides, setShowModuleOverrides] = useState(false) const [showModuleOverrides, setShowModuleOverrides] = useState(false)
@@ -49,6 +50,12 @@ export default function Builder({ cloneHash, pluginParam }: BuilderProps) {
// Get all enabled plugins // Get all enabled plugins
const enabledPlugins = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true) const enabledPlugins = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
// Compute all enabled plugins (explicit + implicit)
const allEnabledPlugins = getDependedPlugins(
enabledPlugins,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
// Calculate plugin compatibility // Calculate plugin compatibility
const { compatibleTargets, filteredGroupedTargets, filteredTargetCategories } = usePluginCompatibility( const { compatibleTargets, filteredGroupedTargets, filteredTargetCategories } = usePluginCompatibility(
enabledPlugins, enabledPlugins,
@@ -132,6 +139,10 @@ export default function Builder({ cloneHash, pluginParam }: BuilderProps) {
setPluginConfig(pluginObj) setPluginConfig(pluginObj)
setShowPlugins(true) setShowPlugins(true)
} }
if (config.pluginConfigs) {
setPluginOptionsConfig(config.pluginConfigs)
}
}, [cloneHash, sharedBuild, handleSelectTarget, setActiveCategory, TARGET_CATEGORIES]) }, [cloneHash, sharedBuild, handleSelectTarget, setActiveCategory, TARGET_CATEGORIES])
const selectedTargetLabel = (selectedTarget && TARGETS[selectedTarget]?.name) || selectedTarget 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 () => { const handleFlash = async () => {
if (!selectedTarget) return if (!selectedTarget) return
setIsFlashing(true) setIsFlashing(true)
@@ -214,13 +246,27 @@ export default function Builder({ cloneHash, pluginParam }: BuilderProps) {
const plugin = (registryData as Record<string, { version: string }>)[slug] const plugin = (registryData as Record<string, { version: string }>)[slug]
return `${slug}@${plugin.version}` 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<string, Record<string, boolean>>
)
const result = await ensureBuildFromConfig({ const result = await ensureBuildFromConfig({
target: selectedTarget, target: selectedTarget,
version: selectedVersion, version: selectedVersion,
modulesExcluded: moduleConfig, modulesExcluded: moduleConfig,
pluginsEnabled: pluginsEnabled.length > 0 ? pluginsEnabled : undefined, 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) { } catch (error) {
const message = error instanceof Error ? error.message : String(error) const message = error instanceof Error ? error.message : String(error)
setErrorMessage("Failed to start build. Please try again.") setErrorMessage("Failed to start build. Please try again.")
@@ -273,13 +319,18 @@ export default function Builder({ cloneHash, pluginParam }: BuilderProps) {
<PluginConfig <PluginConfig
pluginConfig={pluginConfig} pluginConfig={pluginConfig}
pluginOptionsConfig={pluginOptionsConfig}
selectedTarget={selectedTarget} selectedTarget={selectedTarget}
pluginParam={pluginParam} pluginParam={pluginParam}
pluginFlashCounts={pluginFlashCounts} pluginFlashCounts={pluginFlashCounts}
showPlugins={showPlugins} showPlugins={showPlugins}
onToggleShow={() => setShowPlugins(prev => !prev)} onToggleShow={() => setShowPlugins(prev => !prev)}
onTogglePlugin={handleTogglePlugin} onTogglePlugin={handleTogglePlugin}
onReset={() => setPluginConfig({})} onTogglePluginOption={handleTogglePluginOption}
onReset={() => {
setPluginConfig({})
setPluginOptionsConfig({})
}}
/> />
<BuildActions <BuildActions

View File

@@ -1,3 +1,4 @@
import { Checkbox } from "@/components/ui/checkbox"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { Download, Star, Zap } from "lucide-react" import { Download, Star, Zap } from "lucide-react"
import { navigate } from "vike/client/router" import { navigate } from "vike/client/router"
@@ -55,6 +56,10 @@ interface PluginCardLinkToggleProps extends PluginCardBaseProps {
onToggle: (enabled: boolean) => void onToggle: (enabled: boolean) => void
disabled?: boolean disabled?: boolean
enabledLabel?: string enabledLabel?: string
diagnostics?: {
checked: boolean
onCheckedChange: (checked: boolean) => void
}
} }
type PluginCardProps = PluginCardToggleProps | PluginCardLinkProps | PluginCardLinkToggleProps type PluginCardProps = PluginCardToggleProps | PluginCardLinkProps | PluginCardLinkToggleProps
@@ -176,49 +181,35 @@ export function PluginCard(props: PluginCardProps) {
{isIncompatible && incompatibleReason && ( {isIncompatible && incompatibleReason && (
<p className="text-xs text-red-400 mt-1 font-medium">{incompatibleReason}</p> <p className="text-xs text-red-400 mt-1 font-medium">{incompatibleReason}</p>
)} )}
{/* Diagnostics checkbox - only show for link-toggle variant when enabled */}
{isLinkToggle && props.isEnabled && props.diagnostics && (
<div className="mt-2">
<label className="flex items-start gap-2 cursor-pointer group" htmlFor={`${id}-diagnostics`}>
<Checkbox
id={`${id}-diagnostics`}
checked={props.diagnostics.checked}
onCheckedChange={props.diagnostics.onCheckedChange}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-slate-300 group-hover:text-white transition-colors">
Include Diagnostics
</div>
<div className="text-xs text-slate-500 mt-0.5">Enable diagnostic logging for this plugin</div>
</div>
</label>
</div>
)}
</div> </div>
</div> </div>
{/* Metadata row */} {/* Footer with metadata and toggle */}
<div className="flex items-center gap-3 flex-wrap text-xs text-slate-400"> <div className="flex items-center justify-between gap-3 mt-auto pt-2 border-t border-slate-700/50">
{version && <span className="text-slate-500">v{version}</span>} {/* Metadata row */}
{isLinkToggle && flashCount !== undefined && ( <div className="flex items-center gap-3 flex-wrap text-xs text-slate-400">
<div className="flex items-center gap-1"> {version && <span className="text-slate-500">v{version}</span>}
<svg {isLinkToggle && flashCount !== undefined && (
xmlns="http://www.w3.org/2000/svg" <div className="flex items-center gap-1">
width="16"
height="16"
viewBox="0 0 24 24"
className="text-slate-400"
fill="currentColor"
role="img"
aria-label="Download"
>
<path d="m14 2l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm4 18V9h-5V4H6v16zm-6-1l-4-4h2.5v-3h3v3H16z" />
</svg>
<span>{flashCount}</span>
</div>
)}
{isLink && downloads !== undefined && (
<div className="flex items-center gap-1">
<Download className="w-3.5 h-3.5" />
<span>{downloads.toLocaleString()}</span>
</div>
)}
{homepage &&
homepage !== repo &&
(isLink || isLinkToggle) &&
(isLink ? (
<button
type="button"
onClick={e => {
e.preventDefault()
e.stopPropagation()
window.open(homepage, "_blank", "noopener,noreferrer")
}}
className="hover:opacity-80 transition-opacity"
aria-label="Homepage"
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="16" width="16"
@@ -227,103 +218,131 @@ export function PluginCard(props: PluginCardProps) {
className="text-slate-400" className="text-slate-400"
fill="currentColor" fill="currentColor"
role="img" role="img"
aria-label="Download"
>
<path d="m14 2l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm4 18V9h-5V4H6v16zm-6-1l-4-4h2.5v-3h3v3H16z" />
</svg>
<span>{flashCount}</span>
</div>
)}
{isLink && downloads !== undefined && (
<div className="flex items-center gap-1">
<Download className="w-3.5 h-3.5" />
<span>{downloads.toLocaleString()}</span>
</div>
)}
{homepage &&
homepage !== repo &&
(isLink || isLinkToggle) &&
(isLink ? (
<button
type="button"
onClick={e => {
e.preventDefault()
e.stopPropagation()
window.open(homepage, "_blank", "noopener,noreferrer")
}}
className="hover:opacity-80 transition-opacity"
aria-label="Homepage" aria-label="Homepage"
> >
<path <svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
className="text-slate-400"
fill="currentColor" fill="currentColor"
d="m12 3l11 8.25l-1.2 1.6L20 11.5V21H4v-9.5l-1.8 1.35l-1.2-1.6zm-4.65 9.05q0 1.325 1.425 2.825T12 18q1.8-1.625 3.225-3.125t1.425-2.825q0-1.1-.75-1.825T14.1 9.5q-.65 0-1.188.263T12 10.45q-.375-.425-.937-.687T9.9 9.5q-1.05 0-1.8.725t-.75 1.825" role="img"
/> aria-label="Homepage"
</svg> >
</button> <path
) : ( fill="currentColor"
<a d="m12 3l11 8.25l-1.2 1.6L20 11.5V21H4v-9.5l-1.8 1.35l-1.2-1.6zm-4.65 9.05q0 1.325 1.425 2.825T12 18q1.8-1.625 3.225-3.125t1.425-2.825q0-1.1-.75-1.825T14.1 9.5q-.65 0-1.188.263T12 10.45q-.375-.425-.937-.687T9.9 9.5q-1.05 0-1.8.725t-.75 1.825"
href={homepage} />
target="_blank" </svg>
rel="noopener noreferrer" </button>
onClick={e => e.stopPropagation()} ) : (
className="hover:opacity-80 transition-opacity" <a
> href={homepage}
<svg target="_blank"
xmlns="http://www.w3.org/2000/svg" rel="noopener noreferrer"
width="16" onClick={e => e.stopPropagation()}
height="16" className="hover:opacity-80 transition-opacity"
viewBox="0 0 24 24"
className="text-slate-400"
fill="currentColor"
role="img"
aria-label="Homepage"
> >
<path <svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
className="text-slate-400"
fill="currentColor" fill="currentColor"
d="m12 3l11 8.25l-1.2 1.6L20 11.5V21H4v-9.5l-1.8 1.35l-1.2-1.6zm-4.65 9.05q0 1.325 1.425 2.825T12 18q1.8-1.625 3.225-3.125t1.425-2.825q0-1.1-.75-1.825T14.1 9.5q-.65 0-1.188.263T12 10.45q-.375-.425-.937-.687T9.9 9.5q-1.05 0-1.8.725t-.75 1.825" role="img"
/> aria-label="Homepage"
</svg> >
</a> <path
))} fill="currentColor"
{starsBadgeUrl && d="m12 3l11 8.25l-1.2 1.6L20 11.5V21H4v-9.5l-1.8 1.35l-1.2-1.6zm-4.65 9.05q0 1.325 1.425 2.825T12 18q1.8-1.625 3.225-3.125t1.425-2.825q0-1.1-.75-1.825T14.1 9.5q-.65 0-1.188.263T12 10.45q-.375-.425-.937-.687T9.9 9.5q-1.05 0-1.8.725t-.75 1.825"
repo && />
(isLink ? ( </svg>
<button </a>
type="button" ))}
onClick={e => { {starsBadgeUrl &&
e.preventDefault() repo &&
e.stopPropagation() (isLink ? (
window.open(repo, "_blank", "noopener,noreferrer") <button
}} type="button"
className="hover:opacity-80 transition-opacity" onClick={e => {
aria-label="GitHub repository" e.preventDefault()
> e.stopPropagation()
<img src={starsBadgeUrl} alt="GitHub stars" className="h-4" /> window.open(repo, "_blank", "noopener,noreferrer")
</button> }}
) : ( className="hover:opacity-80 transition-opacity"
<a aria-label="GitHub repository"
href={repo} >
target="_blank" <img src={starsBadgeUrl} alt="GitHub stars" className="h-4" />
rel="noopener noreferrer" </button>
onClick={e => e.stopPropagation()} ) : (
className="hover:opacity-80 transition-opacity" <a
> href={repo}
<img src={starsBadgeUrl} alt="GitHub stars" className="h-4" /> target="_blank"
</a> rel="noopener noreferrer"
))} onClick={e => e.stopPropagation()}
</div> className="hover:opacity-80 transition-opacity"
{/* Build Now button - absolutely positioned in lower right */} >
{isLink && ( <img src={starsBadgeUrl} alt="GitHub stars" className="h-4" />
<div className="absolute bottom-4 right-4 z-10"> </a>
))}
</div>
{/* Toggle switch or Build Now button */}
{isLinkToggle ? (
<Switch
checked={props.isEnabled}
onCheckedChange={props.onToggle}
disabled={props.disabled}
labelLeft="Skip"
labelRight={props.enabledLabel || "Add"}
className={props.isEnabled ? "bg-green-600" : "bg-slate-600"}
/>
) : isLink ? (
<button <button
onClick={e => { onClick={e => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
navigate(`/builds?plugin=${id}`) navigate(`/builds?plugin=${id}`)
}} }}
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-cyan-400 bg-cyan-400/10 border border-cyan-400/20 rounded hover:bg-cyan-400/20 transition-colors cursor-pointer" className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-cyan-400 bg-cyan-400/10 border border-cyan-400/20 rounded hover:bg-cyan-400/20 transition-colors cursor-pointer shrink-0"
> >
<Zap className="w-3 h-3" /> <Zap className="w-3 h-3" />
Build Now Build Now
</button> </button>
</div> ) : null}
)} </div>
{/* Toggle switch - absolutely positioned in lower right */}
{isLinkToggle && (
<div className="absolute bottom-4 right-4 z-10">
<div className="flex flex-col items-end gap-1 shrink-0">
<Switch
checked={props.isEnabled}
onCheckedChange={props.onToggle}
disabled={props.disabled}
labelLeft="Skip"
labelRight={props.enabledLabel || "Add"}
className={props.isEnabled ? "bg-green-600" : "bg-slate-600"}
/>
</div>
</div>
)}
</> </>
)} )}
</> </>
) )
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 isIncompatible
? "border-slate-800 bg-slate-900/30 opacity-60 cursor-not-allowed" ? "border-slate-800 bg-slate-900/30 opacity-60 cursor-not-allowed"
: prominent : prominent

View File

@@ -10,23 +10,27 @@ import { ChevronDown, ChevronRight } from "lucide-react"
interface PluginConfigProps { interface PluginConfigProps {
pluginConfig: Record<string, boolean> pluginConfig: Record<string, boolean>
pluginOptionsConfig: Record<string, Record<string, boolean>>
selectedTarget: string selectedTarget: string
pluginParam?: string pluginParam?: string
pluginFlashCounts: Record<string, number> pluginFlashCounts: Record<string, number>
showPlugins: boolean showPlugins: boolean
onToggleShow: () => void onToggleShow: () => void
onTogglePlugin: (id: string, enabled: boolean) => void onTogglePlugin: (id: string, enabled: boolean) => void
onTogglePluginOption: (pluginId: string, optionKey: string, enabled: boolean) => void
onReset: () => void onReset: () => void
} }
export function PluginConfig({ export function PluginConfig({
pluginConfig, pluginConfig,
pluginOptionsConfig,
selectedTarget, selectedTarget,
pluginParam, pluginParam,
pluginFlashCounts, pluginFlashCounts,
showPlugins, showPlugins,
onToggleShow, onToggleShow,
onTogglePlugin, onTogglePlugin,
onTogglePluginOption,
onReset, onReset,
}: PluginConfigProps) { }: PluginConfigProps) {
const pluginCount = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true).length const pluginCount = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true).length
@@ -86,13 +90,13 @@ export function PluginConfig({
{Object.entries(registryData) {Object.entries(registryData)
.sort(([, pluginA], [, pluginB]) => { .sort(([, pluginA], [, pluginB]) => {
// Featured plugins first // Featured plugins first
const featuredA = pluginA.featured ?? false const featuredA = (pluginA as { featured?: boolean }).featured ?? false
const featuredB = pluginB.featured ?? false const featuredB = (pluginB as { featured?: boolean }).featured ?? false
if (featuredA !== featuredB) { if (featuredA !== featuredB) {
return featuredA ? -1 : 1 return featuredA ? -1 : 1
} }
// Then alphabetical by name // 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]) => { .map(([slug, plugin]) => {
// Check if plugin is required by another explicitly selected 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 // Check if this is the preselected plugin from URL
const isPreselected = pluginParam === slug const isPreselected = pluginParam === slug
const pluginRegistry = plugin as {
featured?: boolean
}
const isPluginEnabled = allEnabledPlugins.includes(slug)
const pluginOptions = pluginOptionsConfig[slug] ?? {}
return ( return (
<PluginCard <PluginCard
key={`${slug}-${selectedTarget}`} key={`${slug}-${selectedTarget}`}
@@ -137,16 +147,24 @@ export function PluginConfig({
name={plugin.name} name={plugin.name}
description={plugin.description} description={plugin.description}
imageUrl={plugin.imageUrl} imageUrl={plugin.imageUrl}
isEnabled={allEnabledPlugins.includes(slug)} isEnabled={isPluginEnabled}
onToggle={enabled => onTogglePlugin(slug, enabled)} onToggle={enabled => onTogglePlugin(slug, enabled)}
disabled={isImplicit || isIncompatible || isPreselected} disabled={isImplicit || isIncompatible || isPreselected}
enabledLabel={isPreselected ? "Locked" : isImplicit ? "Required" : "Add"} enabledLabel={isPreselected ? "Locked" : isImplicit ? "Required" : "Add"}
incompatibleReason={isIncompatible ? "Not compatible with this target" : undefined} incompatibleReason={isIncompatible ? "Not compatible with this target" : undefined}
featured={plugin.featured ?? false} featured={pluginRegistry.featured ?? false}
flashCount={pluginFlashCounts[slug] ?? 0} flashCount={pluginFlashCounts[slug] ?? 0}
homepage={plugin.homepage} homepage={plugin.homepage}
version={plugin.version} version={plugin.version}
repo={plugin.repo} repo={plugin.repo}
diagnostics={
isPluginEnabled
? {
checked: pluginOptions.diagnostics ?? false,
onCheckedChange: checked => onTogglePluginOption(slug, "diagnostics", checked === true),
}
: undefined
}
/> />
) )
})} })}

View File

@@ -15,6 +15,7 @@ import type * as builds from "../builds.js";
import type * as helpers from "../helpers.js"; import type * as helpers from "../helpers.js";
import type * as http from "../http.js"; import type * as http from "../http.js";
import type * as lib_filename from "../lib/filename.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 lib_r2 from "../lib/r2.js";
import type * as plugins from "../plugins.js"; import type * as plugins from "../plugins.js";
import type * as profiles from "../profiles.js"; import type * as profiles from "../profiles.js";
@@ -33,6 +34,7 @@ declare const fullApi: ApiFromModules<{
helpers: typeof helpers; helpers: typeof helpers;
http: typeof http; http: typeof http;
"lib/filename": typeof lib_filename; "lib/filename": typeof lib_filename;
"lib/flags": typeof lib_flags;
"lib/r2": typeof lib_r2; "lib/r2": typeof lib_r2;
plugins: typeof plugins; plugins: typeof plugins;
profiles: typeof profiles; profiles: typeof profiles;

View File

@@ -4,6 +4,7 @@ import { api, internal } from "./_generated/api"
import type { Doc, Id } from "./_generated/dataModel" import type { Doc, Id } from "./_generated/dataModel"
import { internalMutation, mutation, query } from "./_generated/server" import { internalMutation, mutation, query } from "./_generated/server"
import { ArtifactType, getArtifactFilenameBase } from "./lib/filename" import { ArtifactType, getArtifactFilenameBase } from "./lib/filename"
import { computeFlagsFromConfig } from "./lib/flags"
import { generateSignedDownloadUrl } from "./lib/r2" import { generateSignedDownloadUrl } from "./lib/r2"
import { buildFields } from "./schema" import { buildFields } from "./schema"
@@ -31,18 +32,8 @@ export const getByHash = query({
}, },
}) })
/** // Re-export for backward compatibility
* Computes flags string from build config. export { computeFlagsFromConfig } from "./lib/flags"
* 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(" ")
}
/** /**
* Encodes a byte array to base62 string. * Encodes a byte array to base62 string.
@@ -77,16 +68,39 @@ async function computeBuildHashInternal(
version: string, version: string,
target: string, target: string,
flags: string, flags: string,
plugins: string[] plugins: string[],
pluginConfig?: Record<string, Record<string, boolean>>
): Promise<string> { ): Promise<string> {
// Input is now the exact parameters used for the build // Input is now the exact parameters used for the build
// Sort plugins array for consistent hashing // Sort plugins array for consistent hashing
const sortedPlugins = [...plugins].sort() 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<string, boolean>
)
acc[pluginSlug] = sortedOptions
return acc
},
{} as Record<string, Record<string, boolean>>
)
: undefined
const input = JSON.stringify({ const input = JSON.stringify({
version, version,
target, target,
flags, flags,
plugins: sortedPlugins, plugins: sortedPlugins,
pluginConfig: sortedPluginConfig,
}) })
// Use Web Crypto API for SHA-256 hashing // Use Web Crypto API for SHA-256 hashing
@@ -103,10 +117,13 @@ async function computeBuildHashInternal(
* Computes buildHash from build config. * Computes buildHash from build config.
* This is the single source of truth for build hash computation. * This is the single source of truth for build hash computation.
*/ */
export async function computeBuildHash(config: Doc<"builds">["config"]): Promise<{ hash: string; flags: string }> { export async function computeBuildHash(
const flags = computeFlagsFromConfig(config) config: Doc<"builds">["config"],
registryData?: Record<string, { configOptions?: Record<string, { define: string }> }>
): Promise<{ hash: string; flags: string }> {
const flags = computeFlagsFromConfig(config, registryData)
const plugins = config.pluginsEnabled ?? [] 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 } return { hash, flags }
} }
@@ -185,6 +202,8 @@ export const ensureBuildFromConfig = mutation({
version: v.string(), version: v.string(),
modulesExcluded: v.optional(v.record(v.string(), v.boolean())), modulesExcluded: v.optional(v.record(v.string(), v.boolean())),
pluginsEnabled: v.optional(v.array(v.string())), 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()), profileName: v.optional(v.string()),
profileDescription: v.optional(v.string()), profileDescription: v.optional(v.string()),
}, },
@@ -195,10 +214,15 @@ export const ensureBuildFromConfig = mutation({
modulesExcluded: args.modulesExcluded ?? {}, modulesExcluded: args.modulesExcluded ?? {},
target: args.target, target: args.target,
pluginsEnabled: args.pluginsEnabled, pluginsEnabled: args.pluginsEnabled,
pluginConfigs: args.pluginConfigs,
} }
// Compute build hash (single source of truth) // 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<string, { configOptions?: Record<string, { define: string }> }>
| undefined
const { hash: buildHash, flags } = await computeBuildHash(config, registryData)
const existingBuild = await ctx.db const existingBuild = await ctx.db
.query("builds") .query("builds")

52
convex/lib/flags.ts Normal file
View File

@@ -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, { configOptions?: Record<string, { define: string }> }>
): 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(" ")
}

View File

@@ -7,6 +7,7 @@ export const buildConfigFields = {
modulesExcluded: v.record(v.string(), v.boolean()), modulesExcluded: v.record(v.string(), v.boolean()),
target: v.string(), target: v.string(),
pluginsEnabled: v.optional(v.array(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 = { export const profileFields = {

View File

@@ -11,12 +11,12 @@ export default function BuildsPage() {
const pageContext = usePageContext() const pageContext = usePageContext()
const urlSearchParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null const urlSearchParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null
const cloneHash = urlSearchParams?.get("clone") const cloneHash = urlSearchParams?.get("clone")
const buildId = urlSearchParams?.get("id") const buildHash = urlSearchParams?.get("hash")
const pluginParam = urlSearchParams?.get("plugin") const pluginParam = urlSearchParams?.get("plugin")
// If we have a build ID, show the build progress page // If we have a build hash, show the build progress page
if (buildId) { if (buildHash) {
return <BuildViewPage buildHash={buildId} /> return <BuildViewPage buildHash={buildHash} />
} }
// Otherwise, show the builder (handles clone and plugin params) // Otherwise, show the builder (handles clone and plugin params)