@@ -209,6 +209,53 @@ export default class Serve extends Command {
209209 full : [ 'core' , 'i18n' , 'ui' , 'ai' , 'auth' ] ,
210210 } ;
211211
212+ /**
213+ * True when a dynamic `import()` / `require.resolve()` failed because the
214+ * module is simply NOT INSTALLED — as opposed to the module being present but
215+ * throwing while it loads (a real crash). Checking `err.code` FIRST is the
216+ * #1595 fix: ESM reports a missing package as `err.code ===
217+ * 'ERR_MODULE_NOT_FOUND'` with the human message `Cannot find package '...'`;
218+ * matching only the older `Cannot find module` string mis-classified that as a
219+ * crash. One owner for this test so the optional-plugin guards and the
220+ * capability resolver can't drift apart and re-introduce that bug (#1597).
221+ */
222+ static isModuleNotFoundError ( err : unknown ) : boolean {
223+ const code = ( err as { code ?: string } | null | undefined ) ?. code ;
224+ if ( code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND' ) return true ;
225+ const msg = err instanceof Error ? err . message : String ( err ) ;
226+ return msg . includes ( 'Cannot find module' ) || msg . includes ( 'Cannot find package' ) ;
227+ }
228+
229+ /**
230+ * Resolve HOW an optional, separately-published service plugin (e.g.
231+ * `@objectstack/service-ai`, `@objectstack/service-ai-studio`) should load,
232+ * from explicit INTENT rather than mere package presence (#1597).
233+ *
234+ * Two orthogonal axes decide the outcome — the app's declared intent and the
235+ * license TIER (an orthogonal DENY):
236+ *
237+ * tierAllowed=false → 'off' never load (CE / `tiers` sans the feature)
238+ * required=true (+ tierAllowed) → 'required' MUST load; a missing package is fail-fast
239+ * declared=true (+ tierAllowed) → 'auto' opt-in convenience; best-effort load
240+ * neither declared nor required → 'off' skip, with NO speculative import
241+ *
242+ * `required` is an explicit capability declaration (`requires: [...]`), NOT
243+ * "the package happens to be installed". `declared` means the host app listed
244+ * the package in its OWN package.json — a deliberate authoring act, the opt-in
245+ * path — resolved WITHOUT importing anything. The caller maps the result:
246+ * 'required'/'auto' → attempt load (fail-fast only when 'required'); 'off' → skip.
247+ */
248+ static resolveOptionalPluginLoad ( opts : {
249+ tierAllowed : boolean ;
250+ required : boolean ;
251+ declared : boolean ;
252+ } ) : 'required' | 'auto' | 'off' {
253+ if ( ! opts . tierAllowed ) return 'off' ;
254+ if ( opts . required ) return 'required' ;
255+ if ( opts . declared ) return 'auto' ;
256+ return 'off' ;
257+ }
258+
212259 async run ( ) : Promise < void > {
213260 const { args, flags } = await this . parse ( Serve ) ;
214261
@@ -471,6 +518,13 @@ export default class Serve extends Command {
471518 const requires : string [ ] = Array . isArray ( ( config as any ) . requires )
472519 ? ( config as any ) . requires . filter ( ( c : unknown ) => typeof c === 'string' )
473520 : [ ] ;
521+ // Snapshot the app's EXPLICIT capability declarations BEFORE the platform
522+ // appends its own convenience defaults (auth→email, mcp, pinyin-search,
523+ // ALWAYS_ON_CAPABILITIES, queue/job). Only these explicit declarations carry
524+ // "required" INTENT (#1597): a declared capability whose provider package is
525+ // absent is a hard boot error, whereas an auto-injected default that happens
526+ // to be absent stays best-effort (warn + continue).
527+ const declaredRequires = new Set < string > ( requires ) ;
474528 // Auth callbacks (password-reset, email-verification, magic-link,
475529 // invitation) depend on the email service. Auto-pull `email` when
476530 // `auth` is required so transactional mail works out of the box
@@ -535,6 +589,10 @@ export default class Serve extends Command {
535589 // capability-resolver block further down.
536590 const CAPABILITY_TO_TIER : Record < string , string > = {
537591 ai : 'ai' ,
592+ // `ai-studio` (AI-driven authoring) rides on the base AI service, so
593+ // requiring it opens the same `ai` tier (#1597). The dedicated AI block
594+ // below resolves its load; it has no CAPABILITY_PROVIDERS entry.
595+ 'ai-studio' : 'ai' ,
538596 i18n : 'i18n' ,
539597 ui : 'ui' ,
540598 auth : 'auth' ,
@@ -1497,7 +1555,7 @@ export default class Serve extends Command {
14971555 }
14981556 } catch ( err : unknown ) {
14991557 const msg = err instanceof Error ? err . message : String ( err ) ;
1500- if ( ! msg . includes ( 'Cannot find module' ) && ! msg . includes ( 'ERR_MODULE_NOT_FOUND' ) ) {
1558+ if ( ! Serve . isModuleNotFoundError ( err ) ) {
15011559 console . warn ( chalk . yellow ( ` ⚠ AuthPlugin failed to load: ${ msg } ` ) ) ;
15021560 }
15031561 // @objectstack /plugin-auth not installed — login/register endpoints unavailable
@@ -1650,61 +1708,120 @@ export default class Serve extends Command {
16501708 return false ;
16511709 }
16521710 } ;
1653- // AI Studio (`@objectstack/service-ai-studio`) attaches its personas via the
1654- // `ai:ready` hook the base service fires, so declaring Studio implies the base
1655- // service — load it even when only Studio is in the deps (the base is a
1656- // transitive dep of Studio, so it stays resolvable).
1711+ // `wantsAiService` is the AUTO (opt-in) signal: the host app listed the base
1712+ // AI service — or the Studio that builds on it — in its OWN package.json. This
1713+ // is a package.json READ (a deliberate authoring act), not a speculative
1714+ // import: gating on a *declared* dep, not mere resolvability, is reliable in a
1715+ // workspace/monorepo where a package stays hoist-resolvable when undeclared.
1716+ // Studio implies the base service (it attaches via the `ai:ready` hook the base
1717+ // fires; the base is a transitive dep of Studio, so it stays resolvable).
16571718 const wantsAiService =
16581719 hostDeclaresDependency ( '@objectstack/service-ai' )
16591720 || hostDeclaresDependency ( '@objectstack/service-ai-studio' ) ;
1660- if ( ! hasAIPlugin && tierEnabled ( 'ai' ) && wantsAiService ) {
1721+
1722+ // Load an optional, separately-published service plugin by INTENT (#1597).
1723+ // `required` (from an explicit `requires: [...]` capability) makes a missing
1724+ // OR crashing package a HARD boot error — a declared-but-broken capability
1725+ // must fail-fast, never boot silently degraded (the outer boot catch prints
1726+ // the message and exits 1). `auto` (the package is merely DECLARED as a dep)
1727+ // is best-effort: a genuine crash is surfaced loudly, an absent package is the
1728+ // expected quiet skip. Presence is never inferred by importing-and-catching —
1729+ // the caller already decided to attempt this from intent, so the module-not-
1730+ // found test only WORDS the failure, it never decides whether to load.
1731+ // Returns true when the plugin was registered.
1732+ const loadOptionalServicePlugin = async (
1733+ pkg : string ,
1734+ exportName : string ,
1735+ opts : { required : boolean ; label : string ; track : string } ,
1736+ ) : Promise < boolean > => {
16611737 try {
1662- const aiPkg = '@objectstack/service-ai' ;
1663- const { AIServicePlugin } = await importFromHost ( aiPkg ) ;
1664-
1665- // AIServicePlugin will auto-detect LLM provider from environment variables
1666- // (AI_GATEWAY_MODEL, OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY)
1667- // No need to manually construct the adapter here.
1668- await kernel . use ( new AIServicePlugin ( ) ) ;
1669- trackPlugin ( 'AIService' ) ;
1738+ const mod : any = await importFromHost ( pkg ) ;
1739+ const Ctor = mod [ exportName ] ;
1740+ if ( typeof Ctor !== 'function' ) {
1741+ const detail = `${ pkg } did not export ${ exportName } ` ;
1742+ if ( opts . required ) {
1743+ throw new Error ( `[${ opts . label } ] required but ${ detail } .` ) ;
1744+ }
1745+ console . warn ( chalk . yellow ( ` ⚠ ${ opts . label } : ${ detail } — skipping` ) ) ;
1746+ return false ;
1747+ }
1748+ await kernel . use ( new Ctor ( ) ) ;
1749+ trackPlugin ( opts . track ) ;
1750+ return true ;
16701751 } catch ( err : unknown ) {
1671- const msg = err instanceof Error ? err . message : String ( err ) ;
1672- const code = ( err as { code ?: string } ) ?. code ;
1673- const missing = code === 'ERR_MODULE_NOT_FOUND'
1674- || msg . includes ( 'Cannot find module' )
1675- || msg . includes ( 'Cannot find package' ) ;
1676- if ( ! missing ) {
1677- console . error ( '[AI] AIServicePlugin failed to start:' , msg ) ;
1752+ if ( opts . required ) {
1753+ const msg = err instanceof Error ? err . message : String ( err ) ;
1754+ throw new Error (
1755+ Serve . isModuleNotFoundError ( err )
1756+ ? `[${ opts . label } ] required but ${ pkg } is not installed. `
1757+ + `Add it to the app's dependencies, or drop the capability from \`requires\`.`
1758+ : `[${ opts . label } ] failed to start: ${ msg } ` ,
1759+ ) ;
1760+ }
1761+ // auto (opt-in): non-fatal. A real crash is surfaced; a missing package is
1762+ // the expected "not installed" path and stays quiet.
1763+ if ( ! Serve . isModuleNotFoundError ( err ) ) {
1764+ const msg = err instanceof Error ? err . message : String ( err ) ;
1765+ console . error ( `[${ opts . label } ] failed to start: ${ msg } ` ) ;
16781766 }
1679- // @objectstack /service-ai not installed — AI features unavailable
1767+ return false ;
16801768 }
1769+ } ;
16811770
1682- // 4b. Auto-register AI Studio (AI-driven metadata authoring / "online
1683- // development") when the private @objectstack/service-ai-studio package
1684- // is installed. It is NOT part of the open-source framework: the dynamic
1685- // import below silently skips when absent, so open-source installs get
1686- // the generic AI runtime only. Enterprise installs that ship the package
1687- // get full AI authoring. AIStudioPlugin attaches via the `ai:ready` hook.
1688- const hasAIStudio = plugins . some (
1689- ( p : any ) => p . name === 'com.objectstack.service-ai-studio'
1690- || p . constructor ?. name === 'AIStudioPlugin'
1771+ // Base AI service — enable from declared INTENT, not package presence (#1597):
1772+ // • `requires: ['ai']` (or `['ai-studio']`, which implies the base) ⇒ required
1773+ // → a missing/broken package aborts startup (fail-fast).
1774+ // • package declared in the app's package.json (but not required) ⇒ auto
1775+ // → load best-effort.
1776+ // • otherwise ⇒ skip, with NO speculative import.
1777+ // The `ai` tier is the orthogonal DENY: a Community-Edition deployment whose
1778+ // `tiers` omit `ai` never loads it, whatever the intent — so a CE app that
1779+ // omits AI gets no AI service, no agents, and no `services.ai` in discovery
1780+ // (the console hides its AI surface), while every other capability is unaffected.
1781+ const aiRequired =
1782+ declaredRequires . has ( 'ai' ) || declaredRequires . has ( 'ai-studio' ) ;
1783+ const aiDecision = Serve . resolveOptionalPluginLoad ( {
1784+ tierAllowed : tierEnabled ( 'ai' ) ,
1785+ required : aiRequired ,
1786+ declared : wantsAiService ,
1787+ } ) ;
1788+ if ( ! hasAIPlugin && aiDecision !== 'off' ) {
1789+ // AIServicePlugin auto-detects its LLM provider from environment
1790+ // (AI_GATEWAY_MODEL, OPENAI_API_KEY, ANTHROPIC_API_KEY,
1791+ // GOOGLE_GENERATIVE_AI_API_KEY) — no adapter to construct here.
1792+ const aiLoaded = await loadOptionalServicePlugin (
1793+ '@objectstack/service-ai' ,
1794+ 'AIServicePlugin' ,
1795+ { required : aiDecision === 'required' , label : 'AI' , track : 'AIService' } ,
16911796 ) ;
1692- if ( ! hasAIStudio ) {
1693- try {
1694- const studioPkg = '@objectstack/service-ai-studio' ;
1695- const { AIStudioPlugin } = await importFromHost ( studioPkg ) ;
1696- await kernel . use ( new AIStudioPlugin ( ) ) ;
1697- trackPlugin ( 'AIStudio' ) ;
1698- } catch ( err : unknown ) {
1699- const msg = err instanceof Error ? err . message : String ( err ) ;
1700- const code = ( err as { code ?: string } ) ?. code ;
1701- const missing = code === 'ERR_MODULE_NOT_FOUND'
1702- || msg . includes ( 'Cannot find module' )
1703- || msg . includes ( 'Cannot find package' ) ;
1704- if ( ! missing ) {
1705- console . error ( '[AI Studio] AIStudioPlugin failed to start:' , msg ) ;
1797+
1798+ // AI Studio (AI-driven metadata authoring / "online development") builds on
1799+ // the base service and attaches via its `ai:ready` hook, so only attempt it
1800+ // once the base actually loaded. It is NOT part of the open-source framework:
1801+ // • `requires: ['ai-studio']` ⇒ required → fail-fast if the private package
1802+ // is absent (an app that advertises Studio must ship it).
1803+ // • package declared but not required ⇒ auto (best-effort).
1804+ // • otherwise ⇒ skip — this is the control-plane host path (apps/cloud ships
1805+ // no Studio and MUST boot clean, cloud#107): not declared + not required
1806+ // ⇒ no import, no error.
1807+ if ( aiLoaded ) {
1808+ const hasAIStudio = plugins . some (
1809+ ( p : any ) => p . name === 'com.objectstack.service-ai-studio'
1810+ || p . constructor ?. name === 'AIStudioPlugin'
1811+ ) ;
1812+ if ( ! hasAIStudio ) {
1813+ const studioDecision = Serve . resolveOptionalPluginLoad ( {
1814+ tierAllowed : tierEnabled ( 'ai' ) ,
1815+ required : declaredRequires . has ( 'ai-studio' ) ,
1816+ declared : hostDeclaresDependency ( '@objectstack/service-ai-studio' ) ,
1817+ } ) ;
1818+ if ( studioDecision !== 'off' ) {
1819+ await loadOptionalServicePlugin (
1820+ '@objectstack/service-ai-studio' ,
1821+ 'AIStudioPlugin' ,
1822+ { required : studioDecision === 'required' , label : 'AI Studio' , track : 'AIStudio' } ,
1823+ ) ;
17061824 }
1707- // @objectstack /service-ai-studio not installed — AI authoring unavailable
17081825 }
17091826 }
17101827 }
@@ -1994,10 +2111,26 @@ export default class Serve extends Command {
19942111 }
19952112 } catch ( err : unknown ) {
19962113 const msg = err instanceof Error ? err . message : String ( err ) ;
1997- if ( ! msg . includes ( 'Cannot find module' ) && ! msg . includes ( 'ERR_MODULE_NOT_FOUND' ) ) {
2114+ const missing = Serve . isModuleNotFoundError ( err ) ;
2115+ // Fail-fast (#1597) for capabilities the app EXPLICITLY declared in
2116+ // `requires`: one that can't be provided — its provider package absent, or
2117+ // its plugin throwing while it starts — is a hard boot error, not a warning
2118+ // to scroll past (declared ≠ enforced, Prime Directive #10). The outer boot
2119+ // catch prints the message and exits 1. Platform-injected convenience
2120+ // defaults (ALWAYS_ON, mcp, pinyin-search, auth→email, queue/job) stay
2121+ // best-effort: absent ⇒ warn, crash ⇒ error, boot continues.
2122+ if ( declaredRequires . has ( cap ) ) {
2123+ throw new Error (
2124+ missing
2125+ ? `Capability "${ cap } " is required but ${ spec . pkg } is not installed. `
2126+ + `Add it to the app's dependencies, or remove "${ cap } " from \`requires\`.`
2127+ : `Capability "${ cap } " (${ spec . pkg } ) failed to start: ${ msg } ` ,
2128+ ) ;
2129+ }
2130+ if ( ! missing ) {
19982131 console . error ( `[Capability:${ cap } ] failed to load ${ spec . pkg } : ${ msg } ` ) ;
19992132 } else {
2000- console . warn ( chalk . yellow ( ` ⚠ Capability "${ cap } " required but ${ spec . pkg } is not installed ` ) ) ;
2133+ console . warn ( chalk . yellow ( ` ⚠ Capability "${ cap } " (auto-enabled default) not installed — skipping ${ spec . pkg } ` ) ) ;
20012134 }
20022135 }
20032136 }
@@ -2037,7 +2170,7 @@ export default class Serve extends Command {
20372170 }
20382171 } catch ( err : unknown ) {
20392172 const msg = err instanceof Error ? err . message : String ( err ) ;
2040- if ( ! msg . includes ( 'Cannot find module' ) && ! msg . includes ( 'ERR_MODULE_NOT_FOUND' ) ) {
2173+ if ( ! Serve . isModuleNotFoundError ( err ) ) {
20412174 console . error ( `[Datasource] federation wiring failed: ${ msg } ` ) ;
20422175 }
20432176 }
@@ -2153,7 +2286,7 @@ export default class Serve extends Command {
21532286 }
21542287 } catch ( err : unknown ) {
21552288 const msg = err instanceof Error ? err . message : String ( err ) ;
2156- if ( ! msg . includes ( 'Cannot find module' ) && ! msg . includes ( 'ERR_MODULE_NOT_FOUND' ) ) {
2289+ if ( ! Serve . isModuleNotFoundError ( err ) ) {
21572290 console . error ( `[Datasource] runtime-UI admin wiring failed: ${ msg } ` ) ;
21582291 }
21592292 }
0 commit comments