Skip to content

Commit 9e45b63

Browse files
os-zhuangclaude
andauthored
feat(cli): preflight installable provider for required capabilities (#3366) (#3385)
* feat(cli): preflight installable provider for required capabilities (#3366) A capability in `requires: [...]` was only checked at serve time, and a missing provider printed a generic "not installed — add it to your dependencies" even when the provider has no installable version in the current edition (e.g. `ai` -> @objectstack/service-ai, cloud-only since ADR-0025). `os validate` (token vocabulary only) and `os build` (never resolved providers) both passed, so a validate && build && test CI script never caught it — it surfaced only as an opaque `os start` crash. - spec: add `PLATFORM_CAPABILITY_PROVIDERS` (token -> package + edition) and a pure `classifyRequiredCapability()` — one machine-readable source of truth for the provider/edition knowledge the serve resolver encoded informally. A drift test keeps it 1:1 with the vocabulary and in agreement with serve's CAPABILITY_PROVIDERS packages. - cli: `capability-preflight.ts` resolves each declared capability's provider the way serve loads it (host dir, then CLI deps) and renders an edition-aware message. `os build` and `os validate` fail fast on a `requires` entry with no installable provider in the active edition; an absent-but-installable provider is an advisory `pnpm add` hint; a satisfied list passes unchanged. - cli: the `os serve` boot error now renders the same classification, so preflight and boot read identically. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013CQ5vX12KRiZ7mn1U4bSwQ * chore(spec): sync api-surface snapshot for new capability-provider exports `check:api-surface` gates the @objectstack/spec public API. The #3366 additions (PLATFORM_CAPABILITY_PROVIDERS, classifyRequiredCapability, and their types) are additive (0 breaking, 12 added) — regenerate the committed snapshot so the gate passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013CQ5vX12KRiZ7mn1U4bSwQ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 50cfb3d commit 9e45b63

11 files changed

Lines changed: 665 additions & 11 deletions
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/cli": minor
4+
---
5+
6+
feat(cli): preflight that every `requires` capability has an installable provider
7+
in the current edition (#3366)
8+
9+
A capability listed in `requires: [...]` was only checked at `serve`/`start` time,
10+
and a missing provider produced a generic "not installed — add it to your
11+
dependencies" error even when the provider has **no installable version in the
12+
current edition**. `os validate` (token-vocabulary only) and `os build` (never
13+
resolved providers) both passed, so a `validate && build && test` CI script never
14+
caught it — it surfaced only as an opaque boot crash. Seen upgrading an
15+
open-edition app from `14.7` to `16` after `@objectstack/service-ai` went
16+
cloud-only (ADR-0025).
17+
18+
- `@objectstack/spec/kernel` now exports `PLATFORM_CAPABILITY_PROVIDERS`
19+
(token → provider package + edition) and a pure `classifyRequiredCapability()`
20+
one machine-readable source of truth for the provider/edition knowledge the
21+
serve resolver previously encoded informally.
22+
- `os build` and `os validate` gained a provider preflight. A `requires` entry
23+
whose provider has **no installable version in the active edition** (e.g. `ai`
24+
`@objectstack/service-ai`, cloud-only) now fails fast with an edition-aware
25+
message; an absent-but-installable provider is an advisory `pnpm add` hint, not
26+
a hard error; a satisfied `requires` list passes unchanged.
27+
- The `os serve` boot error now renders the same classification, so preflight and
28+
boot read identically.

packages/cli/src/commands/compile.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { lintFlowPatterns } from '../utils/lint-flow-patterns.js';
1717
import { lintAutonumberFormats } from '../utils/lint-autonumber-formats.js';
1818
import { lintLivenessProperties } from '../utils/lint-liveness-properties.js';
1919
import { lintViewRefs } from '../utils/lint-view-refs.js';
20+
import { preflightRequiredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js';
2021
import { collectAndLintDocs } from '../utils/collect-docs.js';
2122
import { buildRuntimeBundle, cleanupOldRuntimeBundles } from '../utils/build-runtime.js';
2223
import {
@@ -170,6 +171,45 @@ export default class Compile extends Command {
170171
}
171172
}
172173

174+
// 3b-ter. [#3366] Installable-provider preflight. Every capability the app
175+
// DECLARES in `requires: [...]` must have a provider resolvable in the
176+
// active edition. `os validate` only checks the token vocabulary and
177+
// `os build` never resolved providers, so a `requires` entry whose
178+
// provider has NO installable version in this edition (e.g. `ai` →
179+
// @objectstack/service-ai, cloud-only since ADR-0025) slipped through
180+
// to a generic `os start` crash. Fail the build with the edition-aware
181+
// message instead; an absent-but-installable provider is a `pnpm add`
182+
// hint (advisory), and a satisfied list passes silently.
183+
if (!flags.json) printStep('Checking capability providers (#3366)...');
184+
const capPreflight = preflightRequiredCapabilities({
185+
requires: Array.isArray((config as { requires?: unknown[] }).requires)
186+
? ((config as { requires?: unknown[] }).requires as unknown[])
187+
: [],
188+
projectDir: path.dirname(absolutePath),
189+
});
190+
if (capPreflight.errors.length > 0) {
191+
if (flags.json) {
192+
console.log(JSON.stringify({
193+
success: false,
194+
error: 'capability provider preflight failed',
195+
issues: capPreflight.errors.map((c) => ({ token: c.token, message: renderCapabilityMessage(c) })),
196+
}));
197+
this.exit(1);
198+
}
199+
console.log('');
200+
printError(`Capability provider check failed (${capPreflight.errors.length} issue${capPreflight.errors.length > 1 ? 's' : ''})`);
201+
for (const c of capPreflight.errors) {
202+
console.log(` • ${renderCapabilityMessage(c)}`);
203+
}
204+
this.exit(1);
205+
}
206+
if (capPreflight.warnings.length > 0 && !flags.json) {
207+
console.log('');
208+
for (const c of capPreflight.warnings) {
209+
printWarning(renderCapabilityMessage(c));
210+
}
211+
}
212+
173213
// 3b-bis. ADR-0089 D3b — deprecated visibility aliases + mis-layered
174214
// binding root. Checked on `normalized` (PRE-parse): the schema folds
175215
// `visibleOn`/`visibility` into `visibleWhen` at parse, so `result.data`

packages/cli/src/commands/serve.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.j
1111
import { resolveDriverType, createStorageDriver, UnsupportedDriverError } from '../utils/storage-driver.js';
1212
import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveAllowDegradedTenancy, isMcpServerEnabled, resolveSearchPinyinEnabled, isModuleNotFoundError } from '@objectstack/types';
1313
import { PLATFORM_CAPABILITY_TOKENS } from '@objectstack/spec/kernel';
14+
import { missingProviderMessage } from '../utils/capability-preflight.js';
1415
import { resolveObjectStackHome } from '@objectstack/runtime';
1516
import { LOG_LEVELS, resolveLogLevel, readLogLevelEnv } from '../utils/log-level.js';
1617
import {
@@ -1840,7 +1841,7 @@ export default class Serve extends Command {
18401841
const loadOptionalServicePlugin = async (
18411842
pkg: string,
18421843
exportName: string,
1843-
opts: { required: boolean; label: string; track: string },
1844+
opts: { required: boolean; label: string; track: string; capability: string },
18441845
): Promise<boolean> => {
18451846
try {
18461847
const mod: any = await importFromHost(pkg);
@@ -1860,9 +1861,12 @@ export default class Serve extends Command {
18601861
if (opts.required) {
18611862
const msg = err instanceof Error ? err.message : String(err);
18621863
throw new Error(
1864+
// #3366 — an absent provider is classified by edition: a cloud-only
1865+
// capability (e.g. `ai` → @objectstack/service-ai) says so instead of
1866+
// the un-followable "add it to your dependencies". Same wording the
1867+
// `os build` preflight prints, so boot and preflight read identically.
18631868
Serve.isModuleNotFoundError(err)
1864-
? `[${opts.label}] required but ${pkg} is not installed. `
1865-
+ `Add it to the app's dependencies, or drop the capability from \`requires\`.`
1869+
? missingProviderMessage(opts.capability)
18661870
: `[${opts.label}] failed to start: ${msg}`,
18671871
);
18681872
}
@@ -1900,7 +1904,7 @@ export default class Serve extends Command {
19001904
const aiLoaded = await loadOptionalServicePlugin(
19011905
'@objectstack/service-ai',
19021906
'AIServicePlugin',
1903-
{ required: aiDecision === 'required', label: 'AI', track: 'AIService' },
1907+
{ required: aiDecision === 'required', label: 'AI', track: 'AIService', capability: 'ai' },
19041908
);
19051909

19061910
// AI Studio (AI-driven metadata authoring / "online development") builds on
@@ -1927,7 +1931,7 @@ export default class Serve extends Command {
19271931
await loadOptionalServicePlugin(
19281932
'@objectstack/service-ai-studio',
19291933
'AIStudioPlugin',
1930-
{ required: studioDecision === 'required', label: 'AI Studio', track: 'AIStudio' },
1934+
{ required: studioDecision === 'required', label: 'AI Studio', track: 'AIStudio', capability: 'ai-studio' },
19311935
);
19321936
}
19331937
}
@@ -2100,9 +2104,12 @@ export default class Serve extends Command {
21002104
// best-effort: absent ⇒ warn, crash ⇒ error, boot continues.
21012105
if (declaredRequires.has(cap)) {
21022106
throw new Error(
2107+
// #3366 — edition-aware message via the shared classifier (same
2108+
// wording the `os build` preflight prints). For these CAPABILITY_
2109+
// PROVIDERS tokens (all open-edition) that's the `pnpm add` hint;
2110+
// the cloud-only tokens surface their edition boundary instead.
21032111
missing
2104-
? `Capability "${cap}" is required but ${spec.pkg} is not installed. `
2105-
+ `Add it to the app's dependencies, or remove "${cap}" from \`requires\`.`
2112+
? missingProviderMessage(cap)
21062113
: `Capability "${cap}" (${spec.pkg}) failed to start: ${msg}`,
21072114
);
21082115
}

packages/cli/src/commands/validate.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { Args, Command, Flags } from '@oclif/core';
44
import { existsSync, readFileSync } from 'node:fs';
55
import { createRequire } from 'node:module';
6-
import { join } from 'node:path';
6+
import { join, dirname } from 'node:path';
77
import chalk from 'chalk';
88
import { ZodError } from 'zod';
99
import { ObjectStackDefinitionSchema, normalizeStackInput, type ConversionNotice } from '@objectstack/spec';
@@ -18,6 +18,7 @@ import { validateCapabilityReferences } from '@objectstack/lint';
1818
import { validateVisibilityPredicates } from '@objectstack/lint';
1919
import { validateSecurityPosture } from '@objectstack/lint';
2020
import { validateFlowTriggerReadiness } from '@objectstack/lint';
21+
import { preflightRequiredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js';
2122
import {
2223
printHeader,
2324
printKV,
@@ -422,6 +423,42 @@ export default class Validate extends Command {
422423
}
423424
}
424425

426+
// 3h. [#3366] Installable-provider preflight — the shift-left of the
427+
// `serve`-time capability check. `os validate` previously only checked
428+
// the `requires` tokens against the vocabulary (ADR-0066), never
429+
// whether each token's provider is resolvable in the active edition. A
430+
// token whose provider has NO installable version here (e.g. `ai` →
431+
// @objectstack/service-ai, cloud-only) fails; absent-but-installable is
432+
// an advisory `pnpm add` hint. Mirrors the `os build` gate exactly.
433+
if (!flags.json) printStep('Checking capability providers (#3366)...');
434+
const capProviderPreflight = preflightRequiredCapabilities({
435+
requires: Array.isArray((config as { requires?: unknown[] }).requires)
436+
? ((config as { requires?: unknown[] }).requires as unknown[])
437+
: [],
438+
projectDir: dirname(absolutePath),
439+
});
440+
const capProviderErrors = capProviderPreflight.errors;
441+
const capProviderWarnings = capProviderPreflight.warnings.map((c) => ({
442+
token: c.token,
443+
message: renderCapabilityMessage(c),
444+
}));
445+
if (capProviderErrors.length > 0) {
446+
if (flags.json) {
447+
console.log(JSON.stringify({
448+
valid: false,
449+
errors: capProviderErrors.map((c) => ({ token: c.token, message: renderCapabilityMessage(c) })),
450+
duration: timer.elapsed(),
451+
}, null, 2));
452+
this.exit(1);
453+
}
454+
console.log('');
455+
printError(`Capability provider check failed (${capProviderErrors.length} issue${capProviderErrors.length > 1 ? 's' : ''})`);
456+
for (const c of capProviderErrors) {
457+
console.log(` • ${renderCapabilityMessage(c)}`);
458+
}
459+
this.exit(1);
460+
}
461+
425462
// 4. Collect and display stats
426463
const stats = collectMetadataStats(config);
427464

@@ -434,7 +471,7 @@ export default class Validate extends Command {
434471
valid: true,
435472
manifest: config.manifest,
436473
stats,
437-
warnings: [...exprWarnings, ...widgetWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings, ...flowReadinessWarnings, ...securityAdvisories],
474+
warnings: [...exprWarnings, ...widgetWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings, ...flowReadinessWarnings, ...securityAdvisories, ...capProviderWarnings],
438475
conversions: conversionNotices,
439476
specVersionGap: specGap,
440477
duration: timer.elapsed(),
@@ -445,6 +482,12 @@ export default class Validate extends Command {
445482
// 5. Warnings (non-blocking)
446483
const warnings: string[] = [];
447484

485+
// [#3366] Installable-provider hints — a declared capability whose provider
486+
// is absent but addable (`pnpm add`), or an unknown token (typo).
487+
for (const w of capProviderWarnings) {
488+
warnings.push(w.message);
489+
}
490+
448491
// ADR-0089 D3b — deprecated visibility aliases + mis-layered binding root.
449492
// Checked on `normalized` (PRE-parse): the schema folds `visibleOn`/
450493
// `visibility` into `visibleWhen` during parse, so `result.data` no longer
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { createRequire } from 'node:module';
4+
import path from 'node:path';
5+
import {
6+
classifyRequiredCapability,
7+
type CapabilityClassification,
8+
} from '@objectstack/spec/kernel';
9+
10+
/**
11+
* Installable-provider preflight (framework#3366).
12+
*
13+
* A capability listed in `requires: [...]` is fail-fast at `serve` time when its
14+
* provider package is missing — but the generic "not installed, add it to your
15+
* dependencies" advice is un-followable when the provider has **no installable
16+
* version in the current edition** (e.g. `ai` → `@objectstack/service-ai`, which
17+
* went cloud-only in 11.3.0 / ADR-0025). Neither `os validate` nor `os build`
18+
* caught it, because neither resolves providers or boots the runtime.
19+
*
20+
* This module resolves each declared capability's provider the same way `serve`
21+
* loads it and classifies the result via the spec-owned
22+
* {@link classifyRequiredCapability}, so a shift-left gate (`os build` /
23+
* `os validate`) and the `serve` boot error read identically.
24+
*/
25+
26+
// ── Provider resolution ─────────────────────────────────────────────────────
27+
28+
/** True when `err` is a "module not exported / not found" resolution failure. */
29+
function isResolveMiss(err: unknown): boolean {
30+
const code = (err as NodeJS.ErrnoException | undefined)?.code;
31+
// A package that IS installed but whose `exports` map doesn't expose the bare
32+
// entry under the `require` condition (import-only) still counts as installed.
33+
return code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED';
34+
}
35+
36+
/**
37+
* Build an `isInstalled(pkg)` predicate that resolves a provider package the way
38+
* `os serve` actually loads it (`importFromHost`): first from the HOST APP's
39+
* dir — a locally-linked service or an enterprise plugin the app installed — then
40+
* from the CLI's own module graph, where the framework `@objectstack/*` providers
41+
* live as dependencies (serve's bare-import fallback). Resolution never
42+
* imports/executes the package, so it is cheap and side-effect-free.
43+
*/
44+
export function makeProviderResolver(projectDir: string): (pkg: string) => boolean {
45+
// Anchoring on `<dir>/package.json` only sets the resolution base — the file
46+
// itself need not exist (Node walks up node_modules from that directory).
47+
const hostRequire = createRequire(path.join(projectDir, 'package.json'));
48+
const cliRequire = createRequire(import.meta.url);
49+
const resolvableFrom = (req: NodeRequire, pkg: string): boolean => {
50+
try {
51+
req.resolve(pkg);
52+
return true;
53+
} catch (err) {
54+
return !isResolveMiss(err);
55+
}
56+
};
57+
return (pkg: string) => resolvableFrom(hostRequire, pkg) || resolvableFrom(cliRequire, pkg);
58+
}
59+
60+
// ── Message rendering ────────────────────────────────────────────────────────
61+
62+
/**
63+
* The one-line, actionable message for a classified capability. Shared by the
64+
* build/validate preflight and the serve boot error so both read identically.
65+
* Only `installable` / `unavailable` / `unknown` produce a message; `ok` is
66+
* satisfied and never surfaced.
67+
*/
68+
export function renderCapabilityMessage(c: CapabilityClassification): string {
69+
const p = c.provider;
70+
switch (c.status) {
71+
case 'installable': {
72+
const pkg = p!.package!;
73+
if (p!.edition === 'enterprise') {
74+
const note = p!.note ? ` (${p!.note})` : '';
75+
return (
76+
`Capability "${c.token}" is provided by ${pkg}${note}. ` +
77+
`Run \`pnpm add ${pkg}\` and add it to \`plugins[]\`, or remove "${c.token}" from \`requires\`.`
78+
);
79+
}
80+
return (
81+
`Capability "${c.token}" requires ${pkg}, which is not installed. ` +
82+
`Run \`pnpm add ${pkg}\`, or remove "${c.token}" from \`requires\`.`
83+
);
84+
}
85+
case 'unavailable': {
86+
const detail = p?.note ? ` (${p.note})` : '';
87+
const lead = p?.package
88+
? `Capability "${c.token}" resolves to ${p.package}, which is not available in the open edition${detail}.`
89+
: `Capability "${c.token}" is provided only by a cloud runtime and has no open-edition provider${detail}.`;
90+
return (
91+
`${lead} ` +
92+
`Remove "${c.token}" from \`requires\`, or run under a cloud runtime that provides the "${c.token}" tier.`
93+
);
94+
}
95+
case 'unknown':
96+
return `requires: "${c.token}" is not a known platform capability — check for a typo.`;
97+
case 'ok':
98+
default:
99+
return `Capability "${c.token}" is satisfied.`;
100+
}
101+
}
102+
103+
// ── Preflight (build / validate) ─────────────────────────────────────────────
104+
105+
export interface CapabilityPreflightResult {
106+
/**
107+
* FATAL findings (`status: 'unavailable'`) — a declared capability whose
108+
* provider has no installable version in the active edition. Fails the build.
109+
*/
110+
readonly errors: CapabilityClassification[];
111+
/**
112+
* Advisory findings — `installable` (absent but addable → `pnpm add` hint) and
113+
* `unknown` (a typo). Never fatal.
114+
*/
115+
readonly warnings: CapabilityClassification[];
116+
}
117+
118+
/**
119+
* Classify every DECLARED `requires` token (deduped) against the resolvable
120+
* providers. Only explicit declarations are checked — the platform's own
121+
* auto-injected convenience defaults (`ALWAYS_ON`, `mcp`, …) carry no "required"
122+
* intent, exactly as `serve` treats them.
123+
*/
124+
export function preflightRequiredCapabilities(opts: {
125+
requires: readonly unknown[];
126+
projectDir: string;
127+
/** Injectable for tests; defaults to on-disk `require.resolve` resolution. */
128+
isInstalled?: (pkg: string) => boolean;
129+
}): CapabilityPreflightResult {
130+
const isInstalled = opts.isInstalled ?? makeProviderResolver(opts.projectDir);
131+
const errors: CapabilityClassification[] = [];
132+
const warnings: CapabilityClassification[] = [];
133+
const seen = new Set<string>();
134+
for (const token of opts.requires) {
135+
if (typeof token !== 'string' || seen.has(token)) continue;
136+
seen.add(token);
137+
const c = classifyRequiredCapability(token, isInstalled);
138+
if (c.status === 'unavailable') errors.push(c);
139+
else if (c.status === 'installable' || c.status === 'unknown') warnings.push(c);
140+
}
141+
return { errors, warnings };
142+
}
143+
144+
/**
145+
* The fatal one-line message `os serve` throws when a DECLARED capability's
146+
* provider import fails as module-not-found. The package is already confirmed
147+
* absent at the throw site, so classification runs against a `false` resolver —
148+
* yielding the same `installable` / `unavailable` wording the build preflight
149+
* prints, so boot and preflight read identically (framework#3366).
150+
*/
151+
export function missingProviderMessage(token: string): string {
152+
return renderCapabilityMessage(classifyRequiredCapability(token, () => false));
153+
}

0 commit comments

Comments
 (0)