@@ -212,34 +212,63 @@ export default class Serve extends Command {
212212 // `@objectstack/runtime` (no cloud dependencies). runtime/cloud
213213 // modes go through `@objectstack/service-cloud`.
214214 if ( shouldBootWithLibrary ( config ) ) {
215+ // The boot stack returns only `{plugins, api}` — preserve the
216+ // original stack metadata (notably `requires`, `analyticsCubes`,
217+ // `tiers`) so the capability resolver further down can read it.
218+ const originalConfig = config ;
215219 const resolvedMode = config . bootMode ?? process . env . OS_MODE ?? 'standalone' ;
216220 if ( resolvedMode === 'standalone' ) {
217221 const { createStandaloneStack } = await import ( '@objectstack/runtime' ) ;
218222 const bootResult = await createStandaloneStack ( config . standalone ) ;
219- config = bootResult as any ;
223+ config = { ... originalConfig , ... bootResult } as any ;
220224 } else {
221225 const { createBootStack } = await import ( '@objectstack/service-cloud' ) ;
222226 const bootResult = await createBootStack ( {
223227 mode : config . bootMode ,
224228 runtime : config . runtime ?? config . project ,
225229 cloud : config . cloud ,
226230 } ) ;
227- config = bootResult as any ;
231+ config = { ... originalConfig , ... bootResult } as any ;
228232 }
229233 }
230234
231235 // ── Resolve plugin tiers ──────────────────────────────────────
232- // Precedence: config.tiers > --preset > built-in default.
233- // Tiers gate the OPTIONAL auto-registration blocks (AIService,
234- // I18n, Studio UI). Explicitly-listed config.plugins always load.
236+ // Precedence: config.requires (capability declarations) >
237+ // config.tiers > --preset > built-in default.
238+ //
239+ // `requires: ['ai', 'automation', ...]` is the recommended
240+ // app-level way to declare platform dependencies. The CLI
241+ // expands each capability name into the matching tier so the
242+ // optional auto-registration blocks below light up without
243+ // extra flags. Explicitly-listed `config.plugins` always load
244+ // and shadow any capability resolution (i.e. an explicit
245+ // instance wins over the auto-loader).
235246 const presetName = flags . preset ?? ( isDev ? 'default' : 'default' ) ;
236247 const presetTiers = Serve . TIER_PRESETS [ presetName ] ?? Serve . TIER_PRESETS . default ;
237- const tiers : Set < string > = new Set (
248+ const requires : string [ ] = Array . isArray ( ( config as any ) . requires )
249+ ? ( config as any ) . requires . filter ( ( c : unknown ) => typeof c === 'string' )
250+ : [ ] ;
251+ // Capability → tier: any capability that is gated by a tier
252+ // here automatically opens that tier when listed in `requires`.
253+ // Capabilities NOT in this map (e.g. `automation`, `analytics`,
254+ // `audit`) bypass tier gating and are loaded directly by the
255+ // capability-resolver block further down.
256+ const CAPABILITY_TO_TIER : Record < string , string > = {
257+ ai : 'ai' ,
258+ i18n : 'i18n' ,
259+ ui : 'ui' ,
260+ auth : 'auth' ,
261+ } ;
262+ const requiredTiers = requires
263+ . map ( ( c ) => CAPABILITY_TO_TIER [ c ] )
264+ . filter ( ( t ) : t is string => typeof t === 'string' ) ;
265+ const baseTiers =
238266 Array . isArray ( ( config as any ) . tiers ) && ( config as any ) . tiers . length > 0
239267 ? ( config as any ) . tiers
240- : presetTiers
241- ) ;
268+ : presetTiers ;
269+ const tiers : Set < string > = new Set ( [ ... baseTiers , ... requiredTiers ] ) ;
242270 const tierEnabled = ( t : string ) => tiers . has ( t ) ;
271+ const requiresCapability = ( c : string ) => requires . includes ( c ) ;
243272
244273 // Import ObjectStack runtime
245274 const { Runtime } = await import ( '@objectstack/runtime' ) ;
@@ -658,6 +687,141 @@ export default class Serve extends Command {
658687 }
659688 }
660689
690+ // 5. Capability resolver — auto-load service plugins declared in
691+ // `requires: [...]` that are NOT tier-gated. Each entry maps to a
692+ // package + factory; if the user already provided an explicit
693+ // instance via `plugins: [...]` we skip (explicit wins).
694+ //
695+ // Adding a new built-in capability is a one-line change here.
696+ type CapabilitySpec = {
697+ pkg : string ;
698+ export : string ; // named export to import
699+ nameMatch : string [ ] ; // plugin.name / constructor.name fragments to detect dupes
700+ configKey ?: string ; // optional config field passed as constructor arg
701+ extras ?: Array < { pkg : string ; export : string ; nameMatch : string [ ] } > ;
702+ } ;
703+ const CAPABILITY_PROVIDERS : Record < string , CapabilitySpec > = {
704+ automation : {
705+ pkg : '@objectstack/service-automation' ,
706+ export : 'AutomationServicePlugin' ,
707+ nameMatch : [ 'service-automation' , 'AutomationServicePlugin' ] ,
708+ // The default node packs ship from the same package; auto-register them
709+ // so flows actually have executors. Users can opt out by listing
710+ // their own subset explicitly in `plugins: []` (which sets
711+ // `nameMatch` to skip these auto-loads).
712+ extras : [
713+ { pkg : '@objectstack/service-automation' , export : 'CrudNodesPlugin' , nameMatch : [ 'crud-nodes' , 'CrudNodesPlugin' ] } ,
714+ { pkg : '@objectstack/service-automation' , export : 'LogicNodesPlugin' , nameMatch : [ 'logic-nodes' , 'LogicNodesPlugin' ] } ,
715+ { pkg : '@objectstack/service-automation' , export : 'HttpConnectorPlugin' , nameMatch : [ 'http-connector' , 'HttpConnectorPlugin' ] } ,
716+ { pkg : '@objectstack/service-automation' , export : 'ScreenNodesPlugin' , nameMatch : [ 'screen-nodes' , 'ScreenNodesPlugin' ] } ,
717+ ] ,
718+ } ,
719+ analytics : {
720+ pkg : '@objectstack/service-analytics' ,
721+ export : 'AnalyticsServicePlugin' ,
722+ nameMatch : [ 'service-analytics' , 'AnalyticsServicePlugin' ] ,
723+ configKey : 'analyticsCubes' ,
724+ } ,
725+ audit : {
726+ pkg : '@objectstack/plugin-audit' ,
727+ export : 'AuditPlugin' ,
728+ nameMatch : [ 'audit' , 'AuditPlugin' ] ,
729+ } ,
730+ cache : {
731+ pkg : '@objectstack/service-cache' ,
732+ export : 'CacheServicePlugin' ,
733+ nameMatch : [ 'service-cache' , 'CacheServicePlugin' ] ,
734+ } ,
735+ storage : {
736+ pkg : '@objectstack/service-storage' ,
737+ export : 'StorageServicePlugin' ,
738+ nameMatch : [ 'service-storage' , 'StorageServicePlugin' ] ,
739+ } ,
740+ queue : {
741+ pkg : '@objectstack/service-queue' ,
742+ export : 'QueueServicePlugin' ,
743+ nameMatch : [ 'service-queue' , 'QueueServicePlugin' ] ,
744+ } ,
745+ job : {
746+ pkg : '@objectstack/service-job' ,
747+ export : 'JobServicePlugin' ,
748+ nameMatch : [ 'service-job' , 'JobServicePlugin' ] ,
749+ } ,
750+ realtime : {
751+ pkg : '@objectstack/service-realtime' ,
752+ export : 'RealtimeServicePlugin' ,
753+ nameMatch : [ 'service-realtime' , 'RealtimeServicePlugin' ] ,
754+ } ,
755+ feed : {
756+ pkg : '@objectstack/service-feed' ,
757+ export : 'FeedServicePlugin' ,
758+ nameMatch : [ 'service-feed' , 'FeedServicePlugin' ] ,
759+ } ,
760+ mcp : {
761+ pkg : '@objectstack/plugin-mcp-server' ,
762+ export : 'MCPServerPlugin' ,
763+ nameMatch : [ 'mcp-server' , 'MCPServerPlugin' ] ,
764+ } ,
765+ marketplace : {
766+ pkg : '@objectstack/service-package' ,
767+ export : 'PackageServicePlugin' ,
768+ nameMatch : [ 'service-package' , 'PackageServicePlugin' ] ,
769+ } ,
770+ } ;
771+
772+ const hasPluginMatching = ( fragments : string [ ] ) =>
773+ plugins . some ( ( p : any ) => {
774+ const n = String ( p ?. name ?? '' ) ;
775+ const c = String ( p ?. constructor ?. name ?? '' ) ;
776+ return fragments . some ( ( f ) => n . includes ( f ) || c . includes ( f ) ) ;
777+ } ) ;
778+
779+ for ( const cap of requires ) {
780+ const spec = CAPABILITY_PROVIDERS [ cap ] ;
781+ if ( ! spec ) continue ; // tier-gated capabilities (ai/i18n/ui/auth) handled above
782+ if ( hasPluginMatching ( spec . nameMatch ) ) continue ;
783+
784+ try {
785+ const mod : any = await import ( /* webpackIgnore: true */ spec . pkg ) ;
786+ const Ctor = mod [ spec . export ] ;
787+ if ( ! Ctor ) {
788+ console . warn ( chalk . yellow ( ` ⚠ Capability "${ cap } ": ${ spec . pkg } did not export ${ spec . export } ` ) ) ;
789+ continue ;
790+ }
791+ // analytics needs cubes from config, others take no args
792+ let arg : any ;
793+ if ( spec . configKey === 'analyticsCubes' ) {
794+ const cubes = ( config as any ) . analyticsCubes ?? ( config as any ) . cubes ?? [ ] ;
795+ arg = { cubes } ;
796+ }
797+ await kernel . use ( arg !== undefined ? new Ctor ( arg ) : new Ctor ( ) ) ;
798+ trackPlugin ( spec . export ) ;
799+
800+ if ( spec . extras ) {
801+ for ( const ex of spec . extras ) {
802+ if ( hasPluginMatching ( ex . nameMatch ) ) continue ;
803+ try {
804+ const exMod : any = await import ( /* webpackIgnore: true */ ex . pkg ) ;
805+ const ExCtor = exMod [ ex . export ] ;
806+ if ( ExCtor ) {
807+ await kernel . use ( new ExCtor ( ) ) ;
808+ trackPlugin ( ex . export ) ;
809+ }
810+ } catch {
811+ // optional extra — silently skip
812+ }
813+ }
814+ }
815+ } catch ( err : unknown ) {
816+ const msg = err instanceof Error ? err . message : String ( err ) ;
817+ if ( ! msg . includes ( 'Cannot find module' ) && ! msg . includes ( 'ERR_MODULE_NOT_FOUND' ) ) {
818+ console . error ( `[Capability:${ cap } ] failed to load ${ spec . pkg } : ${ msg } ` ) ;
819+ } else {
820+ console . warn ( chalk . yellow ( ` ⚠ Capability "${ cap } " required but ${ spec . pkg } is not installed` ) ) ;
821+ }
822+ }
823+ }
824+
661825 // ── Studio UI ─────────────────────────────────────────────────
662826 // In dev mode, Studio UI is enabled by default (use --no-ui to disable).
663827 // Always serves the pre-built dist/ — no Vite dev server, no extra port.
0 commit comments