| Title | ModuleConfigCapability: Analysis, Design, and Implementation Guide | |||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Ticket | GOJA-053 | |||||||||||||||||||||||||
| Status | active | |||||||||||||||||||||||||
| Topics |
|
|||||||||||||||||||||||||
| DocType | design | |||||||||||||||||||||||||
| Intent | long-term | |||||||||||||||||||||||||
| Owners | ||||||||||||||||||||||||||
| RelatedFiles |
|
|||||||||||||||||||||||||
| ExternalSources |
|
|||||||||||||||||||||||||
| Summary | Comprehensive analysis of the xgoja capability model and design for ModuleConfigCapability, enabling provider CLI sections to patch module config before Module.New() | |||||||||||||||||||||||||
| LastUpdated | 2026-06-03 08:00:00 -0400 | |||||||||||||||||||||||||
| WhatFor | Implementation guide for a new intern to understand the capability system and implement pre-runtime config patching | |||||||||||||||||||||||||
| WhenToUse | Reference when implementing ModuleConfigCapability, understanding xgoja provider architecture, or adding new provider capabilities |
go-go-goja's xgoja system lets you generate standalone JavaScript runtime binaries from a YAML spec. Each binary is composed of provider packages that contribute native Go modules, CLI flags, and runtime lifecycle hooks through a capability interface system.
Today, providers can expose CLI flags via ConfigSectionCapability and run post-creation setup via RuntimeInitializerCapability, but there is a gap: parsed CLI/config/env flag values cannot influence how a module is constructed. The Module.New(...) factory receives only the static config from xgoja.yaml, while parsed flag values arrive after the runtime already exists.
This document analyzes the entire capability model, explains every moving part an intern needs to understand, evaluates the ModuleConfigCapability proposal from issue #52, identifies code that is confusing or could be refactored, and provides a step-by-step implementation guide.
- Problem Statement
- Current Architecture Deep Dive
- 2.1 The Big Picture: How an xgoja Binary is Born
- 2.2 Provider Registry and Package Model
- 2.3 The Capability Interface Hierarchy
- 2.4 Runtime Factory and Module Instantiation
- 2.5 The Glazed Integration: Sections, Flags, and Values
- 2.6 Built-in Command Flow (eval, run, repl, jsverbs)
- 2.7 Command Providers
- Gap Analysis
- The Proposal: ModuleConfigCapability
- What I Found Confusing
- Refactoring Opportunities
- Typing vs. Config HashMaps
- Implementation Plan
- Testing Strategy
- Risks and Open Questions
- API Reference
- File Reference
The concrete motivating case is Geppetto — the AI inference engine provider. Geppetto needs to expose CLI/config flags like --geppetto-profile-registries and --geppetto-default-profile that configure how the geppetto module is instantiated. These values must be available during Module.New(...), because that is where profile registry loading and geppettomodule.Options construction happen.
Currently, the only way to pass these values is through the xgoja.yaml static config. A post-runtime RuntimeInitializerCapability is too late — the module factory has already run with whatever config was in the YAML file.
What we want:
CLI flags / env vars / config file values
↓ (parsed by Glazed)
↓ (patched into ModuleInstance.Config BEFORE Module.New)
Module.New(ModuleContext{Config: merged-config})
↓
RuntimeInitializerCapability (for post-creation side effects)
What we have today:
CLI flags / env vars / config file values
↓ (parsed by Glazed, but only used AFTER runtime exists)
Module.New(ModuleContext{Config: xgoja.yaml-config-only})
↓
RuntimeInitializerCapability (receives parsed values, too late for factory config)
This section is written for someone who has never seen the codebase. Every subsystem is explained with prose, diagrams, file references, and key type signatures.
An xgoja binary is a generated Go program that embeds a spec (JSON/YAML) describing which provider packages, runtime profiles, commands, and assets to include. The generation flow is:
xgoja.yaml (spec file)
↓ go generate / code generation
main.go (generated, calls app.NewRootCommand)
↓ go build
my-xgoja-binary (standalone CLI)
The generated main.go looks approximately like:
func main() {
registry := providerapi.NewRegistry()
core.Register(registry) // registers "go-go-goja-core" package
host.Register(registry) // registers "go-go-goja-host" package
http.Register(registry) // registers "go-go-goja-http" package
geppetto.Register(registry) // registers "geppetto" package
// ... more providers
specJSON := `{ ... embedded spec ... }`
root, err := app.NewRootCommand(app.Options{
Providers: registry,
SpecJSON: specJSON,
EmbeddedJSVerbs: embeddedFS,
EmbeddedHelp: helpFS,
EmbeddedAssets: assetsFS,
})
// ...
root.Execute()
}Key points:
- Every provider's
Register()function populates theproviderapi.Registrywith entries: modules, capabilities, verb sources, help sources, and command set providers. - The embedded spec JSON tells the
app.Hostwhich packages are active, which runtime profiles exist, and which modules go into each profile. - The
Hostconstructs aRuntimeFactoryand attaches commands to a Cobra root command.
The provider registry is a simple, flat namespace keyed by package ID. Each package contains:
| Entry Type | Purpose | Storage |
|---|---|---|
Module |
A require()-loadable native module |
pkg.Modules map[string]Module |
PackageCapability |
An optional behavior extension (sections, init, etc.) | pkg.PackageCapabilities map[string]PackageCapability |
VerbSource |
A filesystem of JavaScript verb definitions | pkg.VerbSources map[string]VerbSource |
HelpSource |
A filesystem of Glazed help markdown | pkg.HelpSources map[string]HelpSource |
CommandSetProvider |
A factory for package-owned CLI commands | pkg.CommandSetProviders map[string]CommandSetProvider |
File: pkg/xgoja/providerapi/registry.go
type Package struct {
ID string
Modules map[string]Module
VerbSources map[string]VerbSource
HelpSources map[string]HelpSource
PackageCapabilities map[string]PackageCapability
CommandSetProviders map[string]CommandSetProvider
}Entries are registered via the functional options pattern. Each entry type implements the Entry interface:
type Entry interface {
applyToPackage(*Package) error
}So Module, VerbSource, HelpSource, PackageCapability wrappers, and CommandSetProvider all implement applyToPackage. The Registry.Package(id, entries...) function creates a new package and applies all entries in order.
Capabilities are registered via WithPackageCapability(capability):
func WithPackageCapability(capability PackageCapability) Entry {
return capabilityEntry{capability: capability}
}Important: capabilities are package-scoped, not module-scoped. If a package has two modules (e.g., fs and node:fs), the same capabilities are attached to both when the app layer resolves module descriptors. This is a design choice that avoids N×M capability-to-module wiring but can be confusing.
Capabilities are defined in pkg/xgoja/providerapi/capabilities.go. The hierarchy is:
PackageCapability (marker interface)
├── ConfigSectionCapability
│ ConfigSections(SectionContext) → []schema.Section
│
└── RuntimeInitializerCapability
InitRuntimeFromSections(ctx, vals, RuntimeHandle) → error
PackageCapability is the common marker:
type PackageCapability interface {
CapabilityID() string
}ConfigSectionCapability lets a provider declare Glazed sections (groups of flags) that should appear on built-in commands or command provider commands:
type ConfigSectionCapability interface {
PackageCapability
ConfigSections(SectionContext) ([]schema.Section, error)
}When the app builds a command, it calls factory.sectionsForRuntimeProfile() which iterates over every selected module's package capabilities, type-asserts each to ConfigSectionCapability, and collects all sections. These sections become Cobra flags via Glazed.
RuntimeInitializerCapability lets a provider run code after the runtime has been created:
type RuntimeInitializerCapability interface {
PackageCapability
InitRuntimeFromSections(context.Context, *values.Values, RuntimeHandle) error
}The RuntimeHandle is a minimal abstraction over the concrete engine.Runtime:
type RuntimeHandle interface {
Runtime() *goja.Runtime
Close(context.Context) error
}There's also RuntimeCloserRegistry for attaching cleanup hooks:
type RuntimeCloserRegistry interface {
AddCloser(func(context.Context) error) error
}The ModuleDescriptor bridges providers and the app layer:
type ModuleDescriptor struct {
PackageID string
ModuleID string
As string
Module Module
PackageCapabilities []PackageCapability
}When the RuntimeFactory resolves a runtime profile, it produces one ModuleDescriptor per module instance. Every descriptor carries the full set of package capabilities from the module's owning package. This means all capabilities from a package are visible on every module from that package, which is how providerutil.CollectConfigSections and providerutil.InitRuntimeFromSections find and invoke capabilities.
The app.RuntimeFactory is the bridge between the spec and the engine. It lives in pkg/xgoja/app/factory.go.
type RuntimeFactory struct {
providers *providerapi.Registry
spec *Spec
services providerapi.HostServices
}NewRuntime(ctx, profile, opts...) does the following:
- Look up the runtime profile in
spec.Runtimes. - For each
ModuleInstancein the profile'sModuleslist:- Resolve the
Modulefrom the provider registry. - Create a
providerRuntimeModuleSpecthat wraps the instance config.
- Resolve the
- Feed all module specs into
engine.NewBuilder().WithModules(modules...).Build(). - The engine builder creates a
Factoryand callsFactory.NewRuntime().
The critical path is in providerRuntimeModuleSpec.RegisterRuntimeModule():
func (s providerRuntimeModuleSpec) RegisterRuntimeModule(ctx *engine.RuntimeModuleContext, reg *require.Registry) error {
config, err := json.Marshal(s.instance.Config) // ← ONLY xgoja.yaml config!
if err != nil {
return fmt.Errorf("marshal config for %s.%s: %w", s.instance.Package, s.instance.Name, err)
}
loader, err := s.module.New(providerapi.ModuleContext{
Context: ctx.Context,
Name: s.instance.Name,
As: s.instance.Alias(),
Config: config, // ← No parsed CLI values here!
Host: s.services,
RuntimeOwner: ctx.Owner,
})
// ...
reg.RegisterNativeModule(s.instance.Alias(), loader)
return nil
}This is the single choke point where Module.New receives its config. The config comes exclusively from s.instance.Config, which is the map[string]any from the YAML spec. No parsed CLI flags or config file values are available here.
Glazed is the CLI framework that powers all xgoja commands. The integration has three stages:
Stage 1: Section Collection (command construction time)
moduleSections, _, sectionErr := factory.sectionsForRuntimeProfile("run", profile)
// → collects ConfigSectionCapability sections from all selected modules
// → these become schema.Section objects with fieldsStage 2: Flag Parsing (command execution time)
Glazed's CobraParserConfig handles the plumbing. When a command runs:
- Cobra parses flags.
- Glazed middlewares layer in values from: defaults → config file → env → args → Cobra flags.
- The result is a
*values.Valuesobject, which is an ordered map of section slugs toSectionValues.
Stage 3: Value Consumption (post-runtime)
After factory.NewRuntime() returns a runtime, the command calls:
initRuntimeFromSections(ctx, vals, rt, selectedModules)
// → iterates over RuntimeInitializerCapability implementations
// → each one receives the parsed vals and can configure the runtimeThe values.Values structure:
type Values struct {
*orderedmap.OrderedMap[string, *SectionValues]
}
// DecodeSectionInto decodes a named section's field values into a struct:
func (p *Values) DecodeSectionInto(sectionKey string, dst interface{}) errorThis is how providers extract typed data from parsed flags:
var settings FixtureSettings // struct with `glazed:"field-name"` tags
vals.DecodeSectionInto("fixture", &settings)All four built-in commands follow the same pattern. Here's the run command as a representative example:
Construction time:
func newRunCommand(factory *RuntimeFactory, spec *Spec) cmds.Command {
profile := commandRuntime(spec.Commands.Run, firstRuntime(spec))
moduleSections, _, sectionErr := factory.sectionsForRuntimeProfile("run", profile)
// ... create CommandDescription with moduleSections ...
}Execution time:
func (c *runCommand) Run(ctx context.Context, vals *values.Values) error {
settings := runSettings{}
vals.DecodeSectionInto(schema.DefaultSlug, &settings) // file, runtime, keep-alive
selectedModules, _ := c.factory.selectedModuleDescriptors(settings.Runtime)
// Step 1: Create runtime (NO parsed flag values influence this!)
rt, _ := factory.NewRuntime(ctx, profile, requireOpt)
// Step 2: Post-runtime initialization (parsed flag values available here)
initRuntimeFromSections(ctx, vals, rt, selectedModules)
// Step 3: Execute the script
rt.Require.Require(scriptPath)
}The timing problem is clear: between Step 1 and Step 2, Module.New() has already run with only the YAML config. Step 2 can only do post-hoc patching (setting globals, starting servers, etc.), not change how the module was constructed.
For jsverbs, the pattern is the same but happens inside a verb invoker callback:
func(...) (interface{}, error) {
rt, _ := factory.NewRuntime(ctx, profile, require.WithLoader(registry.RequireLoader()))
initRuntimeFromSections(ctx, parsedValues, rt, selectedModules)
return registry.InvokeInRuntime(ctx, rt, verb, parsedValues)
}For the TUI REPL, the runtime is created once and kept alive for the session:
func newXGojaTUIEvaluator(...) (*xgojaTUIEvaluator, error) {
rt, _ := factory.NewRuntime(ctx, profile)
initRuntimeFromSections(ctx, vals, rt, selectedModules)
// ... wrap in JavaScriptEvaluator ...
}Command providers are the most complex path. A CommandSetProvider creates its own Glazed commands that may also need xgoja runtimes. The key difference: command providers receive a CommandSetContext that includes:
type CommandSetContext struct {
Context context.Context
PackageID string
Name string
Mount string
RuntimeProfile string
Config json.RawMessage
Host HostServices
Providers *Registry
RuntimeFactory RuntimeFactory // ← can create runtimes
SelectedModules []ModuleDescriptor
}The command provider can call ctx.RuntimeFactory.NewRuntime() to create a runtime for its own commands. Currently, it does NOT pass parsed values to this factory — it only gets the YAML-config-driven behavior.
The testprovider package demonstrates the full pattern:
func NewFixtureCommandSet(ctx providerapi.CommandSetContext) (*providerapi.CommandSet, error) {
sections, _ := sectionsFromSelectedModules(ctx) // collects sections from capabilities
commands := []cmds.Command{
&fixtureBareCommand{CommandDescription: fixtureDescription("bare", "...", sections)},
// ...
}
return &providerapi.CommandSet{Commands: commands}, nil
}When the bare command runs, it has access to parsed vals but can only use RuntimeInitializerCapability after the fact.
| Need | Current Support | Gap |
|---|---|---|
| Declare CLI flags that influence module construction | ConfigSectionCapability declares flags but values are only available post-runtime |
Values can't reach Module.New() |
| Configure modules from config files / env vars | Glazed middlewares parse config/env but feed into RuntimeInitializerCapability only |
No pre-runtime config patching |
| Pass runtime profile CLI flags to module factory | Not possible — NewRuntime has no values.Values parameter |
No API surface |
| Keep existing post-runtime hooks working | RuntimeInitializerCapability works as-is |
No gap, but must coexist |
The fundamental gap: there is no mechanism to merge parsed Glazed values into ModuleInstance.Config before Module.New() is called.
Issue #52 proposes a new capability interface:
type ModuleConfigCapability interface {
PackageCapability
ModuleConfigFromSections(
ctx context.Context,
vals *values.Values,
descriptor ModuleDescriptor,
) (map[string]any, error)
}Semantics:
ConfigSectionCapabilitystill declares which sections/flags exist.ModuleConfigCapabilitydecodes the parsedvalues.Valuesand returns a config patch (amap[string]any).- The app layer merges the patch into
ModuleInstance.Configbefore callingModule.New(). RuntimeInitializerCapabilityremains for post-runtime lifecycle work.
Proposed new factory method:
func (f *RuntimeFactory) NewRuntimeFromSections(
ctx context.Context,
profile string,
vals *values.Values,
opts ...require.Option,
) (*JSRuntime, error)The existing NewRuntime becomes a thin wrapper:
func (f *RuntimeFactory) NewRuntime(ctx context.Context, profile string, opts ...require.Option) (*JSRuntime, error) {
return f.NewRuntimeFromSections(ctx, profile, nil, opts...)
}My evaluation of the proposal:
The proposal is sound and well-scoped. It correctly identifies the gap, proposes a minimal new interface, and preserves backward compatibility. However, I have several observations and refinements:
-
The
map[string]anyreturn type is a design smell — see Section 7 for a detailed discussion. Returning untyped maps forces every consumer to do type assertions and loses compile-time safety. Ajson.RawMessagereturn or a generic mechanism would be better for the long term. -
The capability is well-named — "ModuleConfig" clearly conveys "this patches module configuration" and sits alongside the existing naming pattern.
-
Package-scoping is correct for now — capabilities are already package-scoped, and the
ModuleConfigFromSectionsmethod receives aModuleDescriptorso the capability can decide whether to patch a specific module instance or all instances from the package. -
The merge-before-construct approach is the right one — patching a copy of
ModuleInstance.Configbefore passing it toModule.Newavoids mutating the shared spec and preserves the existing config precedence model. -
The
NewRuntimeFromSectionsnaming is clear — it signals that parsed section values are involved and that this is a richer version ofNewRuntime.
Capabilities are registered at the package level but applied to every module from that package. This means:
- If
go-go-goja-hostregisters aConfigSectionCapability, it shows up on theModuleDescriptorfor every host module (fs,node:fs,exec,database,db). - The
providerutilhelpers deduplicate by(packageID, capabilityID)key, so the capability'sConfigSections()andInitRuntimeFromSections()are called only once per package, not once per module.
This is correct behavior but not documented in code and can be surprising. A comment in providerapi/capabilities.go or providerutil/sections.go would help.
There are two completely separate config decoding paths:
-
providerapi.ModuleContext.Config— ajson.RawMessagefrom the YAML spec, decoded insideModule.New()by each provider's owndecodeConfig()function (e.g.,host.decodeConfig,provider.decodeConfig). -
values.Values— parsed from Glazed sections, decoded byvals.DecodeSectionInto()withglazed:struct tags.
These two paths use different struct tags (json: vs glazed:), different decoding libraries, and different merge semantics. This means a provider author must maintain two parallel config struct definitions that describe the same conceptual configuration. The ModuleConfigCapability proposal doesn't eliminate this duplication — it adds a third struct (the "patch" struct with glazed: tags that maps to json: keys).
In app/spec.go:
type ModuleInstance struct {
Package string `json:"package"`
Name string `json:"name"`
As string `json:"as,omitempty"`
Config map[string]any `json:"config,omitempty"`
}This config is marshaled to json.RawMessage and then unmarshaled by the provider's decodeConfig. The intermediate map[string]any means:
- Nested objects are
map[string]any(not typed). - Arrays are
[]any(not typed). - There's no schema validation at the app layer; the provider must validate its own config.
The ModuleConfigCapability proposal returns map[string]any too, which compounds the issue — merging two untyped maps requires deep-merge logic that is fragile and error-prone.
There are two "factory" types:
engine.Factory— creates low-level runtimes from explicitRuntimeModuleSpeclists. Lives inengine/factory.go.app.RuntimeFactory— creates runtimes from a spec + provider registry. Delegates toengine.Factoryinternally. Lives inpkg/xgoja/app/factory.go.
The naming overlap is confusing. The app.RuntimeFactory is really a "spec-driven runtime factory" or "xgoja runtime factory." The engine.Factory is the "bare engine factory." The relationship is:
app.RuntimeFactory.NewRuntime()
→ resolves spec to ModuleInstances
→ creates providerRuntimeModuleSpec for each
→ feeds into engine.NewBuilder().WithModules(specs...).Build()
→ calls engine.Factory.NewRuntime()
In pkg/xgoja/app/module_sections.go, there's a private adapter:
type runtimeHandle struct {
rt *JSRuntime
}This implements providerapi.RuntimeHandle by delegating to engine.Runtime. The adapter exists because providerapi intentionally does not depend on the concrete engine.Runtime type. This is good architecture but the thin wrapper is easy to miss — it's defined in a file called module_sections.go rather than a dedicated adapter file.
SectionContext has five fields:
type SectionContext struct {
CommandName string
CommandProviderID string
RuntimeProfile string
PackageID string
ModuleID string
}Built-in commands set CommandName and RuntimeProfile. Command providers set CommandProviderID and optionally RuntimeProfile. The PackageID and ModuleID are set per-descriptor during iteration. This context is passed to ConfigSections() but not to InitRuntimeFromSections() — the runtime initializer only gets (ctx, vals, handle). If a runtime initializer needs to know which command invoked it, it has to look at the values or use some other side channel.
The proposal sketches moduleConfigPatchFromSections() as a free function. This should live in pkg/xgoja/providerutil/ alongside the existing CollectConfigSections() and InitRuntimeFromSections(). The existing file providerutil/sections.go is the natural home.
Currently, providers maintain two config structs:
// YAML config struct (decoded from ModuleContext.Config)
type Config struct {
ProfileRegistries []string `json:"profileRegistries"`
DefaultProfile string `json:"defaultProfile"`
}
// Glazed section struct (decoded from values.Values)
type SectionConfig struct {
ProfileRegistries []string `glazed:"profile-registries"`
DefaultProfile string `glazed:"default-profile"`
}A possible refactoring: introduce a single config struct with both tags, and a helper that decodes from either source:
type Config struct {
ProfileRegistries []string `json:"profileRegistries" glazed:"profile-registries"`
DefaultProfile string `json:"defaultProfile" glazed:"default-profile"`
}
func DecodeConfigFromValues(vals *values.Values, section string, dst any) error { ... }
func DecodeConfigFromJSON(data json.RawMessage, dst any) error { ... }This would reduce the maintenance burden of keeping two structs in sync. However, Glazed's DecodeSectionInto uses glazed: tags for snake_case CLI names, while JSON uses camelCase — the two naming conventions may not always align cleanly.
The proposal returns map[string]any from ModuleConfigFromSections. A better approach:
type ModuleConfigCapability interface {
PackageCapability
ModuleConfigFromSections(
ctx context.Context,
vals *values.Values,
descriptor ModuleDescriptor,
) (json.RawMessage, error)
}Why:
json.RawMessageis opaque bytes — the merger doesn't need to understand the structure.- Deep merge of
json.RawMessagecan be done viajson.Mergeor a simple recursive merge on unmarshaled maps. - The provider's
Module.New()already knows how to decodejson.RawMessage(it does it today fromModuleContext.Config). - No need to expose an untyped map that every consumer must type-assert.
To reduce confusion with engine.Factory, rename to SpecRuntimeFactory or XGojaRuntimeFactory. This is a cosmetic change but would help readability.
The runtimeHandle adapter in module_sections.go should be in a dedicated file like handle.go or runtime_adapter.go.
Capabilities are currently package-scoped. For ModuleConfigCapability, the capability receives a ModuleDescriptor so it can decide per-module, but the registration is still at the package level. If in the future providers need per-module capabilities (e.g., the fs module has a different config capability than exec), the current model would need an extension. This is not needed for the current proposal but is worth noting as a future direction.
Four different packages have nearly identical decodeConfig functions:
pkg/xgoja/providers/host/host.go:222geppetto/pkg/js/modules/geppetto/provider/provider.go- Various test helpers
A shared helper could be:
func DecodeRawConfig(data json.RawMessage, dst any) error {
if len(data) == 0 || string(data) == "null" {
return nil
}
return json.Unmarshal(data, dst)
}This could live in providerapi or providerutil.
The xgoja system uses three different config representations:
| Representation | Type | Used By | Tags |
|---|---|---|---|
| YAML static config | map[string]any (in Spec) → json.RawMessage (in ModuleContext) |
Module.New() |
json: |
| Glazed parsed values | *values.Values |
RuntimeInitializerCapability, command Run() |
glazed: |
| Config patch (proposed) | map[string]any |
ModuleConfigFromSections() |
None (manual map construction) |
Each step involves lossy type conversions: typed struct → map[string]any → json.RawMessage → typed struct.
-
No compile-time validation. A typo in a map key is a runtime error (or silent nil), not a compile error.
-
Nested structures are fragile. Deep merge of
map[string]anymust handle[]anyvsmap[string]anyvs scalars, with no schema to guide it. -
The patch must map
glazed:key names tojson:key names. In the Geppetto example:- CLI:
--geppetto-profile-registries→glazed:"profile-registries" - Config JSON:
{"profileRegistries": [...]}→json:"profileRegistries" - The
ModuleConfigFromSectionsmethod must manually construct thejson:-keyed map from theglazed:-decoded struct.
- CLI:
Instead of returning map[string]any, the capability could return json.RawMessage derived from a typed struct:
type ModuleConfigCapability interface {
PackageCapability
ModuleConfigFromSections(
ctx context.Context,
vals *values.Values,
descriptor ModuleDescriptor,
) (json.RawMessage, error)
}Provider implementation:
func (capability) ModuleConfigFromSections(ctx context.Context, vals *values.Values, desc providerapi.ModuleDescriptor) (json.RawMessage, error) {
var cfg struct {
ProfileRegistries []string `glazed:"profile-registries"`
DefaultProfile string `glazed:"default-profile"`
AllowRegistryLoad bool `glazed:"allow-registry-load"`
AllowNetwork bool `glazed:"allow-network"`
}
if vals == nil {
return nil, nil
}
if err := vals.DecodeSectionInto("geppetto", &cfg); err != nil {
return nil, err
}
// Map to the JSON-keyed struct that Module.New expects:
patch := struct {
ProfileRegistries []string `json:"profileRegistries,omitempty"`
DefaultProfile string `json:"defaultProfile,omitempty"`
AllowRegistryLoad bool `json:"allowRegistryLoad,omitempty"`
AllowNetwork bool `json:"allowNetwork,omitempty"`
}{
ProfileRegistries: cfg.ProfileRegistries,
DefaultProfile: cfg.DefaultProfile,
AllowRegistryLoad: cfg.AllowRegistryLoad,
AllowNetwork: cfg.AllowNetwork,
}
return json.Marshal(patch)
}Advantages:
json.RawMessagemerges cleanly withModuleInstance.Configvia a simple deep-merge on unmarshaled maps.- No untyped
map[string]anyconstruction. - The patch is validated by
json.Marshal— missing fields are simply omitted (omitempty).
Disadvantages:
- Still requires two structs (glazed-tagged and json-tagged) or a single struct with both tag sets.
- Slightly more verbose than a map literal.
If we accept the dual-tag pattern, a helper could automate the "decode from glazed, re-encode as json" dance:
// PatchFromSection decodes a Glazed section into a struct (using glazed: tags),
// then re-marshals it as JSON (using json: tags) for config patching.
func PatchFromSection(vals *values.Values, section string, dst any) (json.RawMessage, error) {
if err := vals.DecodeSectionInto(section, dst); err != nil {
return nil, err
}
return json.Marshal(dst)
}Used with a dual-tagged struct:
type geppettoPatch struct {
ProfileRegistries []string `glazed:"profile-registries" json:"profileRegistries,omitempty"`
DefaultProfile string `glazed:"default-profile" json:"defaultProfile,omitempty"`
AllowRegistryLoad bool `glazed:"allow-registry-load" json:"allowRegistryLoad,omitempty"`
AllowNetwork bool `glazed:"allow-network" json:"allowNetwork,omitempty"`
}One decode + one encode = automatic key mapping with compile-time type safety.
For the initial implementation, I recommend accepting the proposal's map[string]any return type to keep the change small and reviewable, but adding a PatchFromSection helper in providerutil that makes it easy to produce the map from a dual-tagged struct. In a follow-up, the return type can be changed to json.RawMessage if the team agrees.
Step 1.1: Add ModuleConfigCapability to providerapi/capabilities.go
// ModuleConfigCapability lets a provider decode parsed Glazed section values
// and produce a config patch that is merged into ModuleInstance.Config before
// Module.New() is called. This is the pre-runtime counterpart to
// RuntimeInitializerCapability.
type ModuleConfigCapability interface {
PackageCapability
ModuleConfigFromSections(
ctx context.Context,
vals *values.Values,
descriptor ModuleDescriptor,
) (map[string]any, error)
}Step 1.2: Add ModuleConfigPatchFromSections to providerutil/sections.go
// ModuleConfigPatchFromSections collects config patches from all
// ModuleConfigCapability implementations attached to the selected module
// descriptors. Patches are deep-merged into a single map.
func ModuleConfigPatchFromSections(
ctx context.Context,
vals *values.Values,
descriptors []providerapi.ModuleDescriptor,
) (map[string]map[string]any, error) {
// Returns a map from module alias → patch, so each module gets its own patch
patches := map[string]map[string]any{}
applied := map[string]struct{}{}
for _, descriptor := range descriptors {
alias := descriptor.As
patch := map[string]any{}
for _, capability := range descriptor.PackageCapabilities {
configCap, ok := capability.(providerapi.ModuleConfigCapability)
if !ok {
continue
}
id := capability.CapabilityID()
key := packageCapabilityKey(descriptor.PackageID, id)
if _, ok := applied[key]; ok {
continue
}
applied[key] = struct{}{}
partial, err := configCap.ModuleConfigFromSections(ctx, vals, descriptor)
if err != nil {
return nil, fmt.Errorf(
"module config from sections for %s.%s capability %s: %w",
descriptor.PackageID, descriptor.ModuleID, id, err,
)
}
deepMerge(patch, partial)
}
if len(patch) > 0 {
patches[alias] = patch
}
}
return patches, nil
}Note: This returns map[string]map[string]any (alias → patch) rather than a single flat map, because each module instance may receive a different patch.
Step 1.3: Add NewRuntimeFromSections to app.RuntimeFactory
func (f *RuntimeFactory) NewRuntimeFromSections(
ctx context.Context,
profile string,
vals *values.Values,
opts ...require.Option,
) (*JSRuntime, error) {
if f == nil || f.providers == nil || f.spec == nil {
return nil, fmt.Errorf("xgoja runtime factory is not initialized")
}
runtime, ok := f.spec.Runtimes[profile]
if !ok {
return nil, fmt.Errorf("unknown runtime profile %q", profile)
}
// Resolve module descriptors for config patching
descriptors, err := f.selectedModuleDescriptors(profile)
if err != nil {
return nil, err
}
// Collect config patches from ModuleConfigCapability implementations
patches := map[string]map[string]any{}
if vals != nil {
patches, err = providerutil.ModuleConfigPatchFromSections(ctx, vals, descriptors)
if err != nil {
return nil, err
}
}
modules := make([]engine.RuntimeModuleSpec, 0, len(runtime.Modules))
for _, instance := range runtime.Modules {
module, ok := f.providers.ResolveModule(instance.Package, instance.Name)
if !ok {
return nil, fmt.Errorf("runtime %s references unknown provider module %s.%s",
profile, instance.Package, instance.Name)
}
// Clone config and merge patch
config := cloneMap(instance.Config)
if patch, ok := patches[instance.Alias()]; ok {
deepMerge(config, patch)
}
patched := instance
patched.Config = config
modules = append(modules, providerRuntimeModuleSpec{
instance: patched,
module: module,
services: f.services,
})
}
builder := engine.NewBuilder(
engine.WithImplicitDefaultRegistryModules(false),
engine.WithDataOnlyDefaultRegistryModules(false),
).WithModules(modules...)
if len(opts) > 0 {
builder = builder.WithRequireOptions(opts...)
}
runtimeFactory, err := builder.Build()
if err != nil {
return nil, err
}
return runtimeFactory.NewRuntime(
engine.WithStartupContext(ctx),
engine.WithLifetimeContext(ctx),
)
}
func (f *RuntimeFactory) NewRuntime(ctx context.Context, profile string, opts ...require.Option) (*JSRuntime, error) {
return f.NewRuntimeFromSections(ctx, profile, nil, opts...)
}
func cloneMap(m map[string]any) map[string]any {
if m == nil {
return map[string]any{}
}
out := make(map[string]any, len(m))
for k, v := range m {
out[k] = v
}
return out
}
func deepMerge(dst, src map[string]any) {
for k, v := range src {
if existing, ok := dst[k]; ok {
if existingMap, ok := existing.(map[string]any); ok {
if srcMap, ok := v.(map[string]any); ok {
deepMerge(existingMap, srcMap)
continue
}
}
}
dst[k] = v
}
}Step 2.1: Update evalSourceWithInitializers
func evalSourceWithInitializers(ctx context.Context, factory *RuntimeFactory, profile string, source string, vals *values.Values, selectedModules []providerapi.ModuleDescriptor, out io.Writer) error {
// ...
rt, err := factory.NewRuntimeFromSections(ctx, profile, vals) // ← was NewRuntime
// ...
if vals != nil && len(selectedModules) > 0 {
if err := initRuntimeFromSections(ctx, vals, rt, selectedModules); err != nil {
return err
}
}
// ...
}Step 2.2: Update runScriptFileWithInitializers — same pattern.
Step 2.3: Update newXGojaTUIEvaluator — same pattern.
Step 2.4: Update buildVerbCommands invoker — same pattern.
Step 2.5: Update providerapi.RuntimeFactory interface — add NewRuntimeFromSections:
type RuntimeFactory interface {
NewRuntime(ctx context.Context, profile string, opts ...require.Option) (*engine.Runtime, error)
NewRuntimeFromSections(ctx context.Context, profile string, vals *values.Values, opts ...require.Option) (*engine.Runtime, error)
}Step 2.6: Update command provider paths — command providers that create runtimes should also use NewRuntimeFromSections.
type moduleConfigCapability struct{}
func (moduleConfigCapability) CapabilityID() string { return "geppetto.module-config" }
func (moduleConfigCapability) ModuleConfigFromSections(
ctx context.Context,
vals *values.Values,
descriptor providerapi.ModuleDescriptor,
) (map[string]any, error) {
var cfg struct {
ProfileRegistries []string `glazed:"profile-registries"`
DefaultProfile string `glazed:"default-profile"`
AllowRegistryLoad bool `glazed:"allow-registry-load"`
AllowNetwork bool `glazed:"allow-network"`
}
if vals == nil {
return nil, nil
}
if err := vals.DecodeSectionInto("geppetto", &cfg); err != nil {
return nil, err
}
patch := map[string]any{}
if len(cfg.ProfileRegistries) > 0 {
patch["profileRegistries"] = cfg.ProfileRegistries
patch["allowRegistryLoad"] = cfg.AllowRegistryLoad
}
if cfg.DefaultProfile != "" {
patch["defaultProfile"] = cfg.DefaultProfile
}
if cfg.AllowNetwork {
patch["allowNetwork"] = true
}
return patch, nil
}And register it alongside the existing ConfigSectionCapability:
func Register(registry *providerapi.Registry) error {
return registry.Package(PackageID,
providerapi.Module{ /* ... */ },
providerapi.WithPackageCapability(geppettoConfigSectionCapability{}),
providerapi.WithPackageCapability(moduleConfigCapability{}),
)
}Add to providerutil/sections.go:
// PatchFromSection decodes a Glazed section into a struct (using glazed: tags),
// then re-marshals it as JSON (using json: tags) for config patching.
// The struct must have both glazed: and json: tags.
func PatchFromSection(vals *values.Values, section string, dst any) (map[string]any, error) {
if vals == nil {
return nil, nil
}
if err := vals.DecodeSectionInto(section, dst); err != nil {
return nil, err
}
data, err := json.Marshal(dst)
if err != nil {
return nil, err
}
var patch map[string]any
if err := json.Unmarshal(data, &patch); err != nil {
return nil, err
}
// Remove zero-value entries to avoid overriding YAML config with defaults
cleanPatch(patch, dst)
return patch, nil
}- Test with a single descriptor + single
ModuleConfigCapability. - Test with multiple descriptors from the same package (deduplication).
- Test with nil vals → empty patches.
- Test with a capability that returns an error → wrapped error.
-
Factory merges module config patch before Module.New:
- Fixture provider exposes
ConfigSectionCapability+ModuleConfigCapability. - Module factory records
ctx.Config. NewRuntimeFromSections(ctx, profile, vals)should see merged config.
- Fixture provider exposes
-
Existing NewRuntime remains unchanged:
- No vals means only xgoja.yaml config is used.
-
Pre-runtime and post-runtime capabilities both run in order:
- Pre-runtime patch influences module factory config.
- Runtime initializer sees same parsed values after runtime creation.
-
Eval command passes parsed values into NewRuntimeFromSections:
- Command field shows up in module factory config before evaluation.
-
Run command passes parsed values into NewRuntimeFromSections:
- Script can verify that its module received the patched config.
-
TUI command passes parsed values into NewRuntimeFromSections:
- TUI evaluator receives merged config.
-
jsverbs invoker passes parsed values into NewRuntimeFromSections:
- Verb execution sees patched module config.
-
xgoja.yaml default overridden by parsed section values:
- YAML sets
allowNetwork: false, CLI sets--geppetto-allow-network→ patch wins. - YAML sets nested object, patch sets leaf → deep merge preserves non-conflicting keys.
- YAML sets
-
Patch does not mutate spec:
- After creating two runtimes with different patches, the original spec is unchanged.
- All existing
module_sections_test.go,eval_module_sections_test.go,run_module_sections_test.go,jsverbs_module_sections_test.go, andtui_module_sections_test.gomust continue to pass unchanged.
The exact behavior of deepMerge(dst, src) for arrays is undefined. If both YAML config and a patch provide a profileRegistries array, should the patch replace the array or append to it? The issue says "shallow merge may be sufficient for the first implementation" but also notes "deep merge is better for nested config objects."
Recommendation: For v1, use replace semantics for arrays (patch wins entirely) and recursive merge for maps. Document this clearly.
If a Glazed flag has a default value (e.g., fields.WithDefault(false)), and the user doesn't set it, the decoded struct will have the zero value. If we merge this into the YAML config, it could override a YAML-set true with the zero-value false.
Mitigation: The ModuleConfigFromSections implementation should only include fields in the patch that were explicitly set by the user. This requires either:
- Checking if the field was present in the parsed values (not just decoded as zero).
- Using pointer types in the decode struct (e.g.,
*bool) so nil means "not set."
If a package has both a ConfigSectionCapability and a ModuleConfigCapability, they must agree on the section slug. If the section capability declares a section named "geppetto" and the config capability tries to decode section "geppetto", they're coupled by convention. This is fine for a single provider but could be confusing if capabilities are composed from different sources.
Command providers currently receive a RuntimeFactory interface. Adding NewRuntimeFromSections to this interface is a breaking change for existing command providers. We need to decide:
- Option A: Add
NewRuntimeFromSectionsto the interface (breaking change, but there are few implementors). - Option B: Create a new
RuntimeFactoryWithSectionsinterface that extendsRuntimeFactory, and type-assert in command providers that want to use it.
Recommendation: Option A — there are very few command provider implementations, and the benefit of a clean interface outweighs the migration cost.
The providerapi.RuntimeFactory interface (in commands.go) is what command providers see. The app.RuntimeFactory concrete type implements it. When we add NewRuntimeFromSections to the interface, we must update all implementations.
// In pkg/xgoja/providerapi/capabilities.go
type ModuleConfigCapability interface {
PackageCapability
// ModuleConfigFromSections decodes parsed Glazed section values and returns
// a config patch that will be merged into the module's ModuleInstance.Config
// before Module.New() is called.
//
// The returned map uses JSON key names (as expected by the provider's
// decodeConfig function), not Glazed flag names.
//
// Return nil or an empty map if no patching is needed (e.g., vals is nil
// or the relevant section has no values).
ModuleConfigFromSections(
ctx context.Context,
vals *values.Values,
descriptor ModuleDescriptor,
) (map[string]any, error)
}// In pkg/xgoja/app/factory.go
func (f *RuntimeFactory) NewRuntimeFromSections(
ctx context.Context,
profile string,
vals *values.Values,
opts ...require.Option,
) (*JSRuntime, error)
// NewRuntime is unchanged in signature; implementation delegates to NewRuntimeFromSections.
func (f *RuntimeFactory) NewRuntime(
ctx context.Context,
profile string,
opts ...require.Option,
) (*JSRuntime, error)// In pkg/xgoja/providerapi/commands.go
type RuntimeFactory interface {
NewRuntime(ctx context.Context, profile string, opts ...require.Option) (*engine.Runtime, error)
NewRuntimeFromSections(ctx context.Context, profile string, vals *values.Values, opts ...require.Option) (*engine.Runtime, error)
}// In pkg/xgoja/providerutil/sections.go
func ModuleConfigPatchFromSections(
ctx context.Context,
vals *values.Values,
descriptors []providerapi.ModuleDescriptor,
) (map[string]map[string]any, error)
// Returns alias → patch map// In pkg/xgoja/providerutil/sections.go
func PatchFromSection(vals *values.Values, section string, dst any) (map[string]any, error)
// Decodes a Glazed section into dst (glazed: tags), marshals to JSON (json: tags),
// and returns the result as a map[string]any config patch.| File | Purpose |
|---|---|
pkg/xgoja/providerapi/capabilities.go |
PackageCapability, ConfigSectionCapability, RuntimeInitializerCapability, SectionContext, ModuleDescriptor, RuntimeHandle — add ModuleConfigCapability here |
pkg/xgoja/providerapi/module.go |
Module, ModuleFactory, ModuleContext, HostServices — the factory receives Config json.RawMessage |
pkg/xgoja/providerapi/registry.go |
Registry, Package, Entry, WithPackageCapability — package registration and resolution |
pkg/xgoja/providerapi/commands.go |
CommandSetProvider, RuntimeFactory interface, CommandSetContext — extend RuntimeFactory interface here |
pkg/xgoja/providerapi/verbs.go |
VerbSource |
pkg/xgoja/providerapi/help.go |
HelpSource |
| File | Purpose |
|---|---|
pkg/xgoja/app/factory.go |
RuntimeFactory, providerRuntimeModuleSpec, NewRuntime() — add NewRuntimeFromSections(), cloneMap(), deepMerge() here |
pkg/xgoja/app/module_sections.go |
selectedModuleDescriptors(), sectionsForRuntimeProfile(), initRuntimeFromSections(), runtimeHandle adapter |
pkg/xgoja/app/spec.go |
Spec, Runtime, ModuleInstance (has Config map[string]any) |
pkg/xgoja/app/root.go |
evalCommand, newVerbsCommand, buildVerbCommands — update to use NewRuntimeFromSections |
pkg/xgoja/app/run.go |
runCommand, runScriptFileWithInitializers — update to use NewRuntimeFromSections |
pkg/xgoja/app/tui.go |
tuiCommand, newXGojaTUIEvaluator — update to use NewRuntimeFromSections |
pkg/xgoja/app/host.go |
Host, HostServices, AttachDefaultCommands |
pkg/xgoja/app/command_providers.go |
AttachCommandProviders, newCommandSet — update command providers to use NewRuntimeFromSections |
pkg/xgoja/app/glazed.go |
buildGlazedCobraCommand |
pkg/xgoja/app/middlewares.go |
MiddlewaresFromSpec, buildConfigPlan |
pkg/xgoja/app/assets.go |
AssetStore, HostServices |
| File | Purpose |
|---|---|
pkg/xgoja/providerutil/sections.go |
CollectConfigSections(), InitRuntimeFromSections(), AppendUniqueSections() — add ModuleConfigPatchFromSections() here |
| File | Purpose |
|---|---|
engine/factory.go |
Factory, FactoryBuilder, NewRuntime() — unchanged |
engine/module_specs.go |
RuntimeModuleSpec, RuntimeInitializer, RuntimeContext — unchanged |
engine/runtime_modules.go |
RuntimeModuleContext — unchanged |
| File | Purpose |
|---|---|
pkg/xgoja/providers/core/core.go |
Simple provider — modules only, no capabilities |
pkg/xgoja/providers/host/host.go |
Guarded modules with json.RawMessage config schemas and decodeConfig() — future ModuleConfigCapability candidate for CLI guard flags |
pkg/xgoja/providers/http/http.go |
ConfigSectionCapability + RuntimeInitializerCapability — reference implementation for both capabilities |
pkg/xgoja/testprovider/provider.go |
FixtureCapability implementing both ConfigSectionCapability and RuntimeInitializerCapability — reference for tests |
| File | Purpose |
|---|---|
geppetto/pkg/js/modules/geppetto/provider/provider.go |
Config struct with json: tags, decodeConfig(), applyConfigRegistryOptions(), applyConfigStorageOptions() — where ModuleConfigCapability will be added |
| File | Purpose |
|---|---|
pkg/xgoja/app/module_sections_test.go |
Existing tests for section collection and runtime initializer — add ModuleConfigCapability tests here |
pkg/xgoja/app/eval_module_sections_test.go |
Eval command section tests |
pkg/xgoja/app/run_module_sections_test.go |
Run command section tests |
pkg/xgoja/app/jsverbs_module_sections_test.go |
jsverbs section tests |
pkg/xgoja/app/tui_module_sections_test.go |
TUI section tests |
pkg/xgoja/providerutil/sections_test.go |
Provider utility tests |
┌─────────────────────────────────────────────────────────┐
│ Command Construction │
│ │
│ factory.sectionsForRuntimeProfile() │
│ → ConfigSectionCapability.ConfigSections() │
│ → []schema.Section attached to command description │
└──────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Command Execution │
│ │
│ Glazed parses flags → *values.Values │
│ │ │
│ ▼ │
│ factory.NewRuntime(ctx, profile) │
│ → ModuleInstance.Config from xgoja.yaml ONLY │
│ → providerRuntimeModuleSpec.RegisterRuntimeModule() │
│ → json.Marshal(instance.Config) │
│ → Module.New(ModuleContext{Config: marshaled}) │
│ │ │
│ ▼ │
│ initRuntimeFromSections(ctx, vals, rt, modules) │
│ → RuntimeInitializerCapability.InitRuntimeFromSections │
│ │ │
│ ▼ │
│ Execute eval / run / repl / jsverb │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Command Construction │
│ │
│ factory.sectionsForRuntimeProfile() │
│ → ConfigSectionCapability.ConfigSections() │
│ → []schema.Section attached to command description │
└──────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Command Execution │
│ │
│ Glazed parses flags → *values.Values │
│ │ │
│ ▼ │
│ factory.NewRuntimeFromSections(ctx, profile, vals) │
│ → providerutil.ModuleConfigPatchFromSections() │
│ → ModuleConfigCapability.ModuleConfigFromSections() │
│ → map[string]map[string]any (alias → patch) │
│ → cloneMap(instance.Config) │
│ → deepMerge(config, patches[alias]) │
│ → providerRuntimeModuleSpec with patched config │
│ → Module.New(ModuleContext{Config: MERGED}) │
│ │ │
│ ▼ │
│ initRuntimeFromSections(ctx, vals, rt, modules) │
│ → RuntimeInitializerCapability.InitRuntimeFromSections │
│ │ │
│ ▼ │
│ Execute eval / run / repl / jsverb │
└─────────────────────────────────────────────────────────┘
ConfigSectionCapability
┌──────────────────┐
│ ConfigSections() │ → Declares what flags exist
└──────────────────┘
│
▼ (Glazed parses flags into values.Values)
│
ModuleConfigCapability (NEW)
┌────────────────────────────┐
│ ModuleConfigFromSections() │ → Converts parsed values to config patch
└────────────────────────────┘
│
▼ (App merges patch into ModuleInstance.Config)
│
Module.New(ModuleContext{Config: merged})
│
▼ (Runtime now exists)
│
RuntimeInitializerCapability
┌────────────────────────────┐
│ InitRuntimeFromSections() │ → Post-creation runtime setup
└────────────────────────────┘
From lowest to highest precedence:
1. Module.New() code defaults (hardcoded in Go)
2. xgoja.yaml module config (static, from Spec)
3. Config file values (Glazed config middleware)
4. Environment variable values (Glazed env middleware)
5. CLI flag values (Glazed cobra middleware)
The ModuleConfigCapability patch (which encodes config file / env / CLI values) overrides the xgoja.yaml static config. This is the desired behavior: runtime-supplied values should win over build-time defaults.
| Term | Definition |
|---|---|
| xgoja | The code generation and runtime framework that produces standalone JavaScript runtime binaries |
| Spec | The YAML/JSON configuration that defines an xgoja binary's packages, runtimes, commands, and assets |
| Provider | A Go package that registers modules, capabilities, and other entries into the provider registry |
| Package | A named group of entries in the provider registry (e.g., go-go-goja-host, geppetto) |
| Module | A single require()-loadable JavaScript module backed by a Go ModuleFactory |
| Capability | An optional behavior extension on a provider package (sections, init, config) |
| Runtime Profile | A named selection of modules in the spec that defines a specific runtime configuration |
| ModuleInstance | A spec entry that selects a module from a package with optional alias and config |
| Glazed | The CLI framework used by xgoja for command definitions, flag parsing, and output formatting |
| Section | A Glazed concept: a named group of flags/fields that can be attached to a command |
| Values | The parsed result of Glazed flag/argument/config/env parsing, organized by section |