Skip to content

Commit 1b733db

Browse files
os-zhuangclaude
andauthored
feat(cli): make optional-plugin loading intent-driven, fail-fast on declared-but-missing (#1597) (#3228)
Drive optional AI plugin loading from declared intent (requires: ['ai'|'ai-studio']) instead of package presence: required-but-missing fails fast, undeclared skips with no speculative import, tier gating stays an orthogonal deny. Consolidate missing-vs-crashed detection into one Serve.isModuleNotFoundError (the #1595 fix). Preserves apps/cloud clean boot (cloud#107). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TjHfkKmEvgk8v7N8nTe5sH
1 parent 21bd3dd commit 1b733db

4 files changed

Lines changed: 283 additions & 53 deletions

File tree

content/docs/getting-started/cli.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ os start
315315

316316
**What it boots:**
317317
- Reads the artifact's `manifest`, `objects`, `views`, `flows`, …
318-
- Auto-registers the platform services declared in `requires: [...]` (e.g. `ai`, `automation`, `analytics`, `auth`, `ui`)
318+
- Auto-registers the platform services declared in `requires: [...]` (e.g. `ai`, `automation`, `analytics`, `auth`, `ui`). Declaring a **service** capability (`automation`, `analytics`, `ai`, `audit`, …) is a *requirement*: if its provider package isn't installed, boot **fails fast** with a clear error instead of silently starting without a capability you asked for. (`auth` and `ui` are tier-gated with their own opt-in rules — `auth`'s secret-gated skip is described below.)
319319
- Auto-detects the driver from the database URL scheme (`memory://` → in-memory, `libsql://`/`https://` → Turso, `postgres[ql]://`/`pg://` → pg, `mongodb[+srv]://` → MongoDB, otherwise sqlite)
320320
- Runs standalone boot mode with one active environment.
321321

packages/cli/src/commands/serve.ts

Lines changed: 183 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)