Skip to content

Commit 8c61848

Browse files
committed
feat: add requires field for declarative capability loading
1 parent fa7fba4 commit 8c61848

7 files changed

Lines changed: 255 additions & 36 deletions

File tree

examples/app-crm/objectstack.config.ts

Lines changed: 8 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,8 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { defineStack } from '@objectstack/spec';
4-
import {
5-
AutomationServicePlugin,
6-
ScreenNodesPlugin,
7-
CrudNodesPlugin,
8-
LogicNodesPlugin,
9-
HttpConnectorPlugin,
10-
} from '@objectstack/service-automation';
11-
import { AnalyticsServicePlugin } from '@objectstack/service-analytics';
124
import * as cubes from './src/cubes';
135

14-
// ─── Barrel Imports (one per metadata type) ─────────────────────────
156
import * as objects from './src/objects';
167
import * as actions from './src/actions';
178
import * as dashboards from './src/dashboards';
@@ -26,7 +17,6 @@ import * as views from './src/views';
2617
import * as translations from './src/translations';
2718
import { CrmSeedData } from './src/data';
2819

29-
// ─── Sharing & Security (special: mixed single/array values) ───────
3020
import {
3121
AccountTeamSharingRule, TerritorySharingRules,
3222
OpportunitySalesSharingRule,
@@ -46,19 +36,14 @@ export default defineStack({
4636
description: 'Comprehensive enterprise CRM demonstrating all ObjectStack Protocol features including AI, security, and automation',
4737
},
4838

49-
// Runtime plugins — register the AutomationEngine and its node executors so
50-
// server-side flows (e.g. `lead_conversion`) can run end-to-end. CLI serve
51-
// does NOT auto-register automation; it must be opted-in here.
52-
plugins: [
53-
new AutomationServicePlugin(),
54-
new CrudNodesPlugin(),
55-
new LogicNodesPlugin(),
56-
new HttpConnectorPlugin(),
57-
new ScreenNodesPlugin(),
58-
new AnalyticsServicePlugin({ cubes: Object.values(cubes) }),
59-
],
39+
// ─── Platform capabilities this app needs ─────────────────────────
40+
// The runtime resolves each capability name to a built-in service plugin
41+
// and auto-loads it (with extras like Automation's node packs). No need
42+
// to hand-instantiate plugins or pass `--preset` flags. See
43+
// packages/cli/src/commands/serve.ts CAPABILITY_PROVIDERS for the
44+
// complete map; explicit `plugins: [...]` always shadows the resolver.
45+
requires: ['ai', 'automation', 'analytics'],
6046

61-
// Auto-collected from barrel index files via Object.values()
6247
objects: Object.values(objects),
6348
actions: Object.values(actions),
6449
dashboards: Object.values(dashboards),
@@ -70,25 +55,21 @@ export default defineStack({
7055
permissions: Object.values(profiles),
7156
apps: Object.values(apps),
7257
views: Object.values(views),
58+
analyticsCubes: Object.values(cubes),
7359

74-
// Lifecycle hooks declared as metadata; AppPlugin auto-binds them.
7560
hooks: allHooks,
7661

77-
// Seed Data (top-level, registered as metadata)
7862
data: CrmSeedData,
7963

80-
// I18n Configuration — per-locale file organization
8164
i18n: {
8265
defaultLocale: 'en',
8366
supportedLocales: ['en', 'zh-CN', 'ja-JP', 'es-ES'],
8467
fallbackLocale: 'en',
8568
fileOrganization: 'per_locale',
8669
},
8770

88-
// I18n Translation Bundles (en, zh-CN, ja-JP, es-ES)
8971
translations: Object.values(translations),
9072

91-
// Sharing & security
9273
sharingRules: [
9374
AccountTeamSharingRule,
9475
OpportunitySalesSharingRule,

examples/app-crm/src/cubes/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export const opportunityCube: Cube = {
2121
dimensions: {
2222
stage: { name: 'stage', label: 'Stage', type: 'string', sql: 'stage' },
2323
close_date: { name: 'close_date', label: 'Close Date', type: 'time', sql: 'close_date', granularities: ['day', 'week', 'month', 'quarter', 'year'] },
24-
'account.industry': { name: 'account.industry', label: 'Industry', type: 'string', sql: 'account.industry' },
24+
'account_industry': { name: 'account_industry', label: 'Industry', type: 'string', sql: 'account.industry' },
2525
owner: { name: 'owner', label: 'Owner', type: 'string', sql: 'owner' },
2626
},
2727
};

packages/cli/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,21 @@
5050
"@objectstack/plugin-audit": "workspace:*",
5151
"@objectstack/plugin-auth": "workspace:*",
5252
"@objectstack/plugin-hono-server": "workspace:*",
53+
"@objectstack/plugin-mcp-server": "workspace:*",
5354
"@objectstack/plugin-security": "workspace:*",
5455
"@objectstack/rest": "workspace:*",
5556
"@objectstack/runtime": "workspace:^",
5657
"@objectstack/service-ai": "workspace:*",
58+
"@objectstack/service-analytics": "workspace:*",
59+
"@objectstack/service-automation": "workspace:*",
60+
"@objectstack/service-cache": "workspace:*",
5761
"@objectstack/service-cloud": "workspace:*",
62+
"@objectstack/service-feed": "workspace:*",
63+
"@objectstack/service-job": "workspace:*",
64+
"@objectstack/service-package": "workspace:*",
65+
"@objectstack/service-queue": "workspace:*",
66+
"@objectstack/service-realtime": "workspace:*",
67+
"@objectstack/service-storage": "workspace:*",
5868
"@objectstack/spec": "workspace:*",
5969
"@oclif/core": "^4.11.0",
6070
"bundle-require": "^5.1.0",

packages/cli/src/commands/dev.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ export default class Dev extends Command {
1919
ui: Flags.boolean({ description: 'Enable Studio UI at /_studio/' }),
2020
verbose: Flags.boolean({ char: 'v', description: 'Verbose output' }),
2121
port: Flags.string({ char: 'p', description: 'Server port (overrides $PORT)' }),
22+
preset: Flags.string({
23+
description: 'Plugin tier preset forwarded to `serve`: minimal | default | full',
24+
}),
2225
compile: Flags.boolean({
2326
description: 'Compile objectstack.config.ts to dist/objectstack.json before starting (auto if artifact missing)',
2427
default: false,
@@ -79,6 +82,7 @@ export default class Dev extends Command {
7982
...(port ? ['--port', port] : []),
8083
...(flags.ui ? ['--ui'] : []),
8184
...(flags.verbose ? ['--verbose'] : []),
85+
...(flags.preset ? ['--preset', flags.preset] : []),
8286
],
8387
{ stdio: 'inherit', env: localEnv },
8488
);

packages/cli/src/commands/serve.ts

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

packages/spec/src/stack.zod.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,35 @@ export const ObjectStackDefinitionSchema = lazySchema(() => z.object({
314314
*/
315315
plugins: z.array(z.unknown()).optional().describe('Plugins to load'),
316316

317+
/**
318+
* Required Capabilities
319+
*
320+
* Declarative dependency on platform-provided capabilities. The
321+
* runtime resolves each name to a built-in service plugin and
322+
* loads it automatically — no need to construct the plugin in
323+
* `plugins[]` or pass `--preset` flags at the CLI level.
324+
*
325+
* Built-in capability names (mapped in `@objectstack/cli`):
326+
* `ai` → AIServicePlugin
327+
* `automation` → AutomationServicePlugin (+ default node packs)
328+
* `analytics` → AnalyticsServicePlugin
329+
* `audit` → AuditPlugin
330+
* `i18n` → I18nPlugin
331+
*
332+
* If a capability is also provided explicitly via `plugins[]`, the
333+
* explicit instance wins (and the resolver does not double-register).
334+
*
335+
* @example
336+
* ```ts
337+
* defineStack({
338+
* manifest: { ... },
339+
* requires: ['ai', 'automation', 'analytics'],
340+
* objects: [...],
341+
* });
342+
* ```
343+
*/
344+
requires: z.array(z.string()).optional().describe('Capability names this stack requires from the platform'),
345+
317346
/**
318347
* DevPlugins: Development Capabilities
319348
* List of plugins to load ONLY in development environment.
@@ -782,6 +811,7 @@ const CONCAT_ARRAY_FIELDS = [
782811
'data',
783812
'plugins',
784813
'devPlugins',
814+
'requires',
785815
] as const satisfies readonly (keyof ObjectStackDefinition)[];
786816

787817
/**

0 commit comments

Comments
 (0)