pluginsdk/plugin/wasmguest/caps/provisioner.go
Alex Dunmow b6d40ed8ac feat: bootstrap block/pluginsdk — the proto-first plugin SDK (P1)
Bootstrap the plugin-facing SDK module so the fleet migration (P2) becomes a
mechanical block/core/X -> block/pluginsdk/X import rewrite.

- abi/proto/v1: the single source-of-truth ABI proto tree, copied from the cms
  authoring source (cms/backend/abi/proto/v1) with go_package retargeted to
  git.dev.alexdunmow.com/block/pluginsdk/abi/v1;abiv1. Kills the hand-kept
  cms/core proto duplication (audit gap 4).
- abi/v1: generated Go bindings (buf generate); byte-identical to core's abi/v1
  save the embedded go_package path.
- plugin/ (registration + DI surface), plugin/wasmguest/** (transport shim,
  caps stubs, bnwasm db driver, testdata fixtures + golden .pb), and the
  guest-facing type packages: blocks (+builtin/shared/tags), templates
  (+pongo/bn), auth, settings, content, gating, crypto, rbac, video, ai,
  subscriptions, menus, datasources. Internal imports rewritten core ->
  pluginsdk; zero block/core references remain.
- README/AGENTS(+CLAUDE symlink)/Makefile: proto is the contract, Go is one
  binding; no replace directives; templates/bn is a synced copy authored in cms.

Spec: cms docs/superpowers/specs/2026-07-07-proto-first-plugin-sdk-design.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:51:00 +08:00

190 lines
7.5 KiB
Go

package caps
import (
"context"
"encoding/json"
"fmt"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
"github.com/google/uuid"
)
// provisionerStub implements plugin.Provisioner over provisioner.* capability
// calls (WO-WZ-019). This is a LOAD-TIME capability: call it from the Load
// hook. RegisterWithProvisioner still receives a no-op at Register/DESCRIBE
// time (host functions are stubbed to fail there), so seed logic must move to
// Load — see core/docs/wasm-abi.md.
//
// Most Provisioner methods carry no context (the Go interface predates the
// ABI); calls run under the ambient invoke deadline the host enforces.
type provisionerStub struct{ base }
var _ plugin.Provisioner = (*provisionerStub)(nil)
func (s *provisionerStub) EnsureDataTable(config plugin.DataTableConfig) error {
req := &abiv1.ProvisionerEnsureDataTableRequest{
Key: config.Key,
Name: config.Name,
Description: config.Description,
SchemaJson: config.Schema,
PrimaryKey: config.PrimaryKey,
}
return s.invoke(context.Background(), "ensure_data_table", req, &abiv1.ProvisionerEnsureDataTableResponse{})
}
func (s *provisionerStub) MergeSiteSettings(defaults map[string]any) error {
defaultsJSON, err := json.Marshal(defaults)
if err != nil {
return fmt.Errorf("provisioner.merge_site_settings: marshal defaults: %w", err)
}
req := &abiv1.ProvisionerMergeSiteSettingsRequest{DefaultsJson: defaultsJSON}
return s.invoke(context.Background(), "merge_site_settings", req, &abiv1.ProvisionerMergeSiteSettingsResponse{})
}
func (s *provisionerStub) EnsureSetting(key string, defaultValue any) error {
valueJSON, err := json.Marshal(defaultValue)
if err != nil {
return fmt.Errorf("provisioner.ensure_setting: marshal value: %w", err)
}
req := &abiv1.ProvisionerEnsureSettingRequest{Key: key, DefaultValueJson: valueJSON}
return s.invoke(context.Background(), "ensure_setting", req, &abiv1.ProvisionerEnsureSettingResponse{})
}
func (s *provisionerStub) EnsurePage(config plugin.PageConfig) error {
seed := &abiv1.PageSeed{
Slug: config.Slug,
ParentSlug: config.ParentSlug,
Title: config.Title,
TemplateKey: config.TemplateKey,
DetailSourceType: config.DetailSourceType,
DetailSourceKey: config.DetailSourceKey,
DetailSlugField: config.DetailSlugField,
ReconcileBlocks: config.ReconcileBlocks,
ReconcileTemplate: config.ReconcileTemplate,
}
for _, b := range config.Blocks {
contentJSON, err := json.Marshal(b.Content)
if err != nil {
return fmt.Errorf("provisioner.ensure_page: block %q: marshal content: %w", b.BlockKey, err)
}
seed.Blocks = append(seed.Blocks, &abiv1.PageBlock{
BlockKey: b.BlockKey,
Title: b.Title,
ContentJson: contentJSON,
HtmlContent: b.HtmlContent,
Slot: b.Slot,
SortOrder: b.SortOrder,
})
}
req := &abiv1.ProvisionerEnsurePageRequest{Page: seed}
return s.invoke(context.Background(), "ensure_page", req, &abiv1.ProvisionerEnsurePageResponse{})
}
func (s *provisionerStub) OverrideSiteSettings(overrides map[string]any) error {
overridesJSON, err := json.Marshal(overrides)
if err != nil {
return fmt.Errorf("provisioner.override_site_settings: marshal overrides: %w", err)
}
req := &abiv1.ProvisionerOverrideSiteSettingsRequest{OverridesJson: overridesJSON}
return s.invoke(context.Background(), "override_site_settings", req, &abiv1.ProvisionerOverrideSiteSettingsResponse{})
}
func (s *provisionerStub) EnsureMenuItem(menuName string, config plugin.MenuItemConfig) error {
req := &abiv1.ProvisionerEnsureMenuItemRequest{
MenuName: menuName,
Label: config.Label,
Url: config.URL,
PageSlug: config.PageSlug,
SortOrder: config.SortOrder,
}
return s.invoke(context.Background(), "ensure_menu_item", req, &abiv1.ProvisionerEnsureMenuItemResponse{})
}
func (s *provisionerStub) RegisterEmbeddingConfig(config plugin.EmbeddingConfigDef) error {
req := &abiv1.ProvisionerRegisterEmbeddingConfigRequest{
TableKey: config.TableKey,
TextTemplate: config.TextTemplate,
Enabled: config.Enabled,
}
return s.invoke(context.Background(), "register_embedding_config", req, &abiv1.ProvisionerRegisterEmbeddingConfigResponse{})
}
// EnsureEmbed crosses the template-rendered surface only: EmbedConfig.
// RenderFunc is a function value that cannot serialize, so an ABI-provisioned
// embed must carry a Template (a RenderFunc-only embed returns an error
// rather than silently dropping the renderer).
func (s *provisionerStub) EnsureEmbed(config plugin.EmbedConfig) error {
if config.RenderFunc != nil && config.Template == "" {
return fmt.Errorf("provisioner.ensure_embed: embed %q uses RenderFunc, which cannot cross the wasm ABI — provide a Template instead", config.Key)
}
req := &abiv1.ProvisionerEnsureEmbedRequest{
Key: config.Key,
Title: config.Title,
Description: config.Description,
Icon: config.Icon,
LabelField: config.LabelField,
Template: config.Template,
DataSourceType: config.DataSource.Type,
DataSourceTableKey: config.DataSource.TableKey,
}
return s.invoke(context.Background(), "ensure_embed", req, &abiv1.ProvisionerEnsureEmbedResponse{})
}
func (s *provisionerStub) EnsureJobSchedule(config plugin.JobScheduleConfig) error {
req := &abiv1.ProvisionerEnsureJobScheduleRequest{
JobType: config.JobType,
CronExpression: config.CronExpression,
ConfigJson: config.Config,
}
return s.invoke(context.Background(), "ensure_job_schedule", req, &abiv1.ProvisionerEnsureJobScheduleResponse{})
}
func (s *provisionerStub) UpdateDataTableRowField(ctx context.Context, rowID uuid.UUID, fieldKey string, value any) error {
valueJSON, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("provisioner.update_data_table_row_field: marshal value: %w", err)
}
req := &abiv1.ProvisionerUpdateDataTableRowFieldRequest{
RowId: rowID.String(),
FieldKey: fieldKey,
ValueJson: valueJSON,
}
return s.invoke(ctx, "update_data_table_row_field", req, &abiv1.ProvisionerUpdateDataTableRowFieldResponse{})
}
func (s *provisionerStub) DisableOrphanedJobSchedules(registeredTypes []string) error {
req := &abiv1.ProvisionerDisableOrphanedJobSchedulesRequest{RegisteredTypes: registeredTypes}
return s.invoke(context.Background(), "disable_orphaned_job_schedules", req, &abiv1.ProvisionerDisableOrphanedJobSchedulesResponse{})
}
func (s *provisionerStub) EnsurePlugin(name string) error {
req := &abiv1.ProvisionerEnsurePluginRequest{Name: name}
return s.invoke(context.Background(), "ensure_plugin", req, &abiv1.ProvisionerEnsurePluginResponse{})
}
func (s *provisionerStub) EnsureCustomColor(config plugin.CustomColorConfig) error {
req := &abiv1.ProvisionerEnsureCustomColorRequest{
Name: config.Name,
LightValue: config.LightValue,
DarkValue: config.DarkValue,
Source: config.Source,
}
return s.invoke(context.Background(), "ensure_custom_color", req, &abiv1.ProvisionerEnsureCustomColorResponse{})
}
func (s *provisionerStub) EnsureMedia(deposit plugin.MediaDeposit) error {
if deposit.ID == uuid.Nil {
return fmt.Errorf("provisioner.ensure_media: MediaDeposit.ID is required (the deterministic, template-referable key)")
}
req := &abiv1.ProvisionerEnsureMediaRequest{
Id: deposit.ID.String(),
Filename: deposit.Filename,
Data: deposit.Data,
AltText: deposit.AltText,
Folder: deposit.Folder,
Source: deposit.Source,
}
return s.invoke(context.Background(), "ensure_media", req, &abiv1.ProvisionerEnsureMediaResponse{})
}