Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/capability-provider-preflight-3366.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"@objectstack/spec": minor
"@objectstack/cli": minor
---

feat(cli): preflight that every `requires` capability has an installable provider
in the current edition (#3366)

A capability listed in `requires: [...]` was only checked at `serve`/`start` time,
and a missing provider produced a generic "not installed — add it to your
dependencies" error even when the provider has **no installable version in the
current edition**. `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 boot crash. Seen upgrading an
open-edition app from `14.7` to `16` after `@objectstack/service-ai` went
cloud-only (ADR-0025).

- `@objectstack/spec/kernel` now exports `PLATFORM_CAPABILITY_PROVIDERS`
(token → provider package + edition) and a pure `classifyRequiredCapability()` —
one machine-readable source of truth for the provider/edition knowledge the
serve resolver previously encoded informally.
- `os build` and `os validate` gained a provider preflight. A `requires` entry
whose provider has **no installable version in the active edition** (e.g. `ai` →
`@objectstack/service-ai`, cloud-only) now fails fast with an edition-aware
message; an absent-but-installable provider is an advisory `pnpm add` hint, not
a hard error; a satisfied `requires` list passes unchanged.
- The `os serve` boot error now renders the same classification, so preflight and
boot read identically.
40 changes: 40 additions & 0 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { lintFlowPatterns } from '../utils/lint-flow-patterns.js';
import { lintAutonumberFormats } from '../utils/lint-autonumber-formats.js';
import { lintLivenessProperties } from '../utils/lint-liveness-properties.js';
import { lintViewRefs } from '../utils/lint-view-refs.js';
import { preflightRequiredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js';
import { collectAndLintDocs } from '../utils/collect-docs.js';
import { buildRuntimeBundle, cleanupOldRuntimeBundles } from '../utils/build-runtime.js';
import {
Expand Down Expand Up @@ -170,6 +171,45 @@ export default class Compile extends Command {
}
}

// 3b-ter. [#3366] Installable-provider preflight. Every capability the app
// DECLARES in `requires: [...]` must have a provider resolvable in the
// active edition. `os validate` only checks the token vocabulary and
// `os build` never resolved providers, so a `requires` entry whose
// provider has NO installable version in this edition (e.g. `ai` →
// @objectstack/service-ai, cloud-only since ADR-0025) slipped through
// to a generic `os start` crash. Fail the build with the edition-aware
// message instead; an absent-but-installable provider is a `pnpm add`
// hint (advisory), and a satisfied list passes silently.
if (!flags.json) printStep('Checking capability providers (#3366)...');
const capPreflight = preflightRequiredCapabilities({
requires: Array.isArray((config as { requires?: unknown[] }).requires)
? ((config as { requires?: unknown[] }).requires as unknown[])
: [],
projectDir: path.dirname(absolutePath),
});
if (capPreflight.errors.length > 0) {
if (flags.json) {
console.log(JSON.stringify({
success: false,
error: 'capability provider preflight failed',
issues: capPreflight.errors.map((c) => ({ token: c.token, message: renderCapabilityMessage(c) })),
}));
this.exit(1);
}
console.log('');
printError(`Capability provider check failed (${capPreflight.errors.length} issue${capPreflight.errors.length > 1 ? 's' : ''})`);
for (const c of capPreflight.errors) {
console.log(` • ${renderCapabilityMessage(c)}`);
}
this.exit(1);
}
if (capPreflight.warnings.length > 0 && !flags.json) {
console.log('');
for (const c of capPreflight.warnings) {
printWarning(renderCapabilityMessage(c));
}
}

// 3b-bis. ADR-0089 D3b — deprecated visibility aliases + mis-layered
// binding root. Checked on `normalized` (PRE-parse): the schema folds
// `visibleOn`/`visibility` into `visibleWhen` at parse, so `result.data`
Expand Down
21 changes: 14 additions & 7 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.j
import { resolveDriverType, createStorageDriver, UnsupportedDriverError } from '../utils/storage-driver.js';
import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveAllowDegradedTenancy, isMcpServerEnabled, resolveSearchPinyinEnabled, isModuleNotFoundError } from '@objectstack/types';
import { PLATFORM_CAPABILITY_TOKENS } from '@objectstack/spec/kernel';
import { missingProviderMessage } from '../utils/capability-preflight.js';
import { resolveObjectStackHome } from '@objectstack/runtime';
import { LOG_LEVELS, resolveLogLevel, readLogLevelEnv } from '../utils/log-level.js';
import {
Expand Down Expand Up @@ -1840,7 +1841,7 @@ export default class Serve extends Command {
const loadOptionalServicePlugin = async (
pkg: string,
exportName: string,
opts: { required: boolean; label: string; track: string },
opts: { required: boolean; label: string; track: string; capability: string },
): Promise<boolean> => {
try {
const mod: any = await importFromHost(pkg);
Expand All @@ -1860,9 +1861,12 @@ export default class Serve extends Command {
if (opts.required) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(
// #3366 — an absent provider is classified by edition: a cloud-only
// capability (e.g. `ai` → @objectstack/service-ai) says so instead of
// the un-followable "add it to your dependencies". Same wording the
// `os build` preflight prints, so boot and preflight read identically.
Serve.isModuleNotFoundError(err)
? `[${opts.label}] required but ${pkg} is not installed. `
+ `Add it to the app's dependencies, or drop the capability from \`requires\`.`
? missingProviderMessage(opts.capability)
: `[${opts.label}] failed to start: ${msg}`,
);
}
Expand Down Expand Up @@ -1900,7 +1904,7 @@ export default class Serve extends Command {
const aiLoaded = await loadOptionalServicePlugin(
'@objectstack/service-ai',
'AIServicePlugin',
{ required: aiDecision === 'required', label: 'AI', track: 'AIService' },
{ required: aiDecision === 'required', label: 'AI', track: 'AIService', capability: 'ai' },
);

// AI Studio (AI-driven metadata authoring / "online development") builds on
Expand All @@ -1927,7 +1931,7 @@ export default class Serve extends Command {
await loadOptionalServicePlugin(
'@objectstack/service-ai-studio',
'AIStudioPlugin',
{ required: studioDecision === 'required', label: 'AI Studio', track: 'AIStudio' },
{ required: studioDecision === 'required', label: 'AI Studio', track: 'AIStudio', capability: 'ai-studio' },
);
}
}
Expand Down Expand Up @@ -2100,9 +2104,12 @@ export default class Serve extends Command {
// best-effort: absent ⇒ warn, crash ⇒ error, boot continues.
if (declaredRequires.has(cap)) {
throw new Error(
// #3366 — edition-aware message via the shared classifier (same
// wording the `os build` preflight prints). For these CAPABILITY_
// PROVIDERS tokens (all open-edition) that's the `pnpm add` hint;
// the cloud-only tokens surface their edition boundary instead.
missing
? `Capability "${cap}" is required but ${spec.pkg} is not installed. `
+ `Add it to the app's dependencies, or remove "${cap}" from \`requires\`.`
? missingProviderMessage(cap)
: `Capability "${cap}" (${spec.pkg}) failed to start: ${msg}`,
);
}
Expand Down
47 changes: 45 additions & 2 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { Args, Command, Flags } from '@oclif/core';
import { existsSync, readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { join } from 'node:path';
import { join, dirname } from 'node:path';
import chalk from 'chalk';
import { ZodError } from 'zod';
import { ObjectStackDefinitionSchema, normalizeStackInput, type ConversionNotice } from '@objectstack/spec';
Expand All @@ -18,6 +18,7 @@ import { validateCapabilityReferences } from '@objectstack/lint';
import { validateVisibilityPredicates } from '@objectstack/lint';
import { validateSecurityPosture } from '@objectstack/lint';
import { validateFlowTriggerReadiness } from '@objectstack/lint';
import { preflightRequiredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js';
import {
printHeader,
printKV,
Expand Down Expand Up @@ -422,6 +423,42 @@ export default class Validate extends Command {
}
}

// 3h. [#3366] Installable-provider preflight — the shift-left of the
// `serve`-time capability check. `os validate` previously only checked
// the `requires` tokens against the vocabulary (ADR-0066), never
// whether each token's provider is resolvable in the active edition. A
// token whose provider has NO installable version here (e.g. `ai` →
// @objectstack/service-ai, cloud-only) fails; absent-but-installable is
// an advisory `pnpm add` hint. Mirrors the `os build` gate exactly.
if (!flags.json) printStep('Checking capability providers (#3366)...');
const capProviderPreflight = preflightRequiredCapabilities({
requires: Array.isArray((config as { requires?: unknown[] }).requires)
? ((config as { requires?: unknown[] }).requires as unknown[])
: [],
projectDir: dirname(absolutePath),
});
const capProviderErrors = capProviderPreflight.errors;
const capProviderWarnings = capProviderPreflight.warnings.map((c) => ({
token: c.token,
message: renderCapabilityMessage(c),
}));
if (capProviderErrors.length > 0) {
if (flags.json) {
console.log(JSON.stringify({
valid: false,
errors: capProviderErrors.map((c) => ({ token: c.token, message: renderCapabilityMessage(c) })),
duration: timer.elapsed(),
}, null, 2));
this.exit(1);
}
console.log('');
printError(`Capability provider check failed (${capProviderErrors.length} issue${capProviderErrors.length > 1 ? 's' : ''})`);
for (const c of capProviderErrors) {
console.log(` • ${renderCapabilityMessage(c)}`);
}
this.exit(1);
}

// 4. Collect and display stats
const stats = collectMetadataStats(config);

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

// [#3366] Installable-provider hints — a declared capability whose provider
// is absent but addable (`pnpm add`), or an unknown token (typo).
for (const w of capProviderWarnings) {
warnings.push(w.message);
}

// ADR-0089 D3b — deprecated visibility aliases + mis-layered binding root.
// Checked on `normalized` (PRE-parse): the schema folds `visibleOn`/
// `visibility` into `visibleWhen` during parse, so `result.data` no longer
Expand Down
153 changes: 153 additions & 0 deletions packages/cli/src/utils/capability-preflight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { createRequire } from 'node:module';
import path from 'node:path';
import {
classifyRequiredCapability,
type CapabilityClassification,
} from '@objectstack/spec/kernel';

/**
* Installable-provider preflight (framework#3366).
*
* A capability listed in `requires: [...]` is fail-fast at `serve` time when its
* provider package is missing — but the generic "not installed, add it to your
* dependencies" advice is un-followable when the provider has **no installable
* version in the current edition** (e.g. `ai` → `@objectstack/service-ai`, which
* went cloud-only in 11.3.0 / ADR-0025). Neither `os validate` nor `os build`
* caught it, because neither resolves providers or boots the runtime.
*
* This module resolves each declared capability's provider the same way `serve`
* loads it and classifies the result via the spec-owned
* {@link classifyRequiredCapability}, so a shift-left gate (`os build` /
* `os validate`) and the `serve` boot error read identically.
*/

// ── Provider resolution ─────────────────────────────────────────────────────

/** True when `err` is a "module not exported / not found" resolution failure. */
function isResolveMiss(err: unknown): boolean {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
// A package that IS installed but whose `exports` map doesn't expose the bare
// entry under the `require` condition (import-only) still counts as installed.
return code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED';
}

/**
* Build an `isInstalled(pkg)` predicate that resolves a provider package the way
* `os serve` actually loads it (`importFromHost`): first from the HOST APP's
* dir — a locally-linked service or an enterprise plugin the app installed — then
* from the CLI's own module graph, where the framework `@objectstack/*` providers
* live as dependencies (serve's bare-import fallback). Resolution never
* imports/executes the package, so it is cheap and side-effect-free.
*/
export function makeProviderResolver(projectDir: string): (pkg: string) => boolean {
// Anchoring on `<dir>/package.json` only sets the resolution base — the file
// itself need not exist (Node walks up node_modules from that directory).
const hostRequire = createRequire(path.join(projectDir, 'package.json'));
const cliRequire = createRequire(import.meta.url);
const resolvableFrom = (req: NodeRequire, pkg: string): boolean => {
try {
req.resolve(pkg);
return true;
} catch (err) {
return !isResolveMiss(err);
}
};
return (pkg: string) => resolvableFrom(hostRequire, pkg) || resolvableFrom(cliRequire, pkg);
}

// ── Message rendering ────────────────────────────────────────────────────────

/**
* The one-line, actionable message for a classified capability. Shared by the
* build/validate preflight and the serve boot error so both read identically.
* Only `installable` / `unavailable` / `unknown` produce a message; `ok` is
* satisfied and never surfaced.
*/
export function renderCapabilityMessage(c: CapabilityClassification): string {
const p = c.provider;
switch (c.status) {
case 'installable': {
const pkg = p!.package!;
if (p!.edition === 'enterprise') {
const note = p!.note ? ` (${p!.note})` : '';
return (
`Capability "${c.token}" is provided by ${pkg}${note}. ` +
`Run \`pnpm add ${pkg}\` and add it to \`plugins[]\`, or remove "${c.token}" from \`requires\`.`
);
}
return (
`Capability "${c.token}" requires ${pkg}, which is not installed. ` +
`Run \`pnpm add ${pkg}\`, or remove "${c.token}" from \`requires\`.`
);
}
case 'unavailable': {
const detail = p?.note ? ` (${p.note})` : '';
const lead = p?.package
? `Capability "${c.token}" resolves to ${p.package}, which is not available in the open edition${detail}.`
: `Capability "${c.token}" is provided only by a cloud runtime and has no open-edition provider${detail}.`;
return (
`${lead} ` +
`Remove "${c.token}" from \`requires\`, or run under a cloud runtime that provides the "${c.token}" tier.`
);
}
case 'unknown':
return `requires: "${c.token}" is not a known platform capability — check for a typo.`;
case 'ok':
default:
return `Capability "${c.token}" is satisfied.`;
}
}

// ── Preflight (build / validate) ─────────────────────────────────────────────

export interface CapabilityPreflightResult {
/**
* FATAL findings (`status: 'unavailable'`) — a declared capability whose
* provider has no installable version in the active edition. Fails the build.
*/
readonly errors: CapabilityClassification[];
/**
* Advisory findings — `installable` (absent but addable → `pnpm add` hint) and
* `unknown` (a typo). Never fatal.
*/
readonly warnings: CapabilityClassification[];
}

/**
* Classify every DECLARED `requires` token (deduped) against the resolvable
* providers. Only explicit declarations are checked — the platform's own
* auto-injected convenience defaults (`ALWAYS_ON`, `mcp`, …) carry no "required"
* intent, exactly as `serve` treats them.
*/
export function preflightRequiredCapabilities(opts: {
requires: readonly unknown[];
projectDir: string;
/** Injectable for tests; defaults to on-disk `require.resolve` resolution. */
isInstalled?: (pkg: string) => boolean;
}): CapabilityPreflightResult {
const isInstalled = opts.isInstalled ?? makeProviderResolver(opts.projectDir);
const errors: CapabilityClassification[] = [];
const warnings: CapabilityClassification[] = [];
const seen = new Set<string>();
for (const token of opts.requires) {
if (typeof token !== 'string' || seen.has(token)) continue;
seen.add(token);
const c = classifyRequiredCapability(token, isInstalled);
if (c.status === 'unavailable') errors.push(c);
else if (c.status === 'installable' || c.status === 'unknown') warnings.push(c);
}
return { errors, warnings };
}

/**
* The fatal one-line message `os serve` throws when a DECLARED capability's
* provider import fails as module-not-found. The package is already confirmed
* absent at the throw site, so classification runs against a `false` resolver —
* yielding the same `installable` / `unavailable` wording the build preflight
* prints, so boot and preflight read identically (framework#3366).
*/
export function missingProviderMessage(token: string): string {
return renderCapabilityMessage(classifyRequiredCapability(token, () => false));
}
Loading