From eee348dfe5e05cb6d32df892c16eb5faf72aef7d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 13:54:34 +0000 Subject: [PATCH 1/2] feat(cli): preflight installable provider for required capabilities (#3366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_013CQ5vX12KRiZ7mn1U4bSwQ --- .../capability-provider-preflight-3366.md | 28 ++++ packages/cli/src/commands/compile.ts | 40 +++++ packages/cli/src/commands/serve.ts | 21 ++- packages/cli/src/commands/validate.ts | 47 +++++- .../cli/src/utils/capability-preflight.ts | 153 ++++++++++++++++++ .../cli/test/capability-preflight.test.ts | 115 +++++++++++++ .../test/serve-capability-vocabulary.test.ts | 37 ++++- packages/spec/src/index.ts | 10 +- .../src/kernel/platform-capabilities.test.ts | 68 ++++++++ .../spec/src/kernel/platform-capabilities.ts | 145 +++++++++++++++++ 10 files changed, 653 insertions(+), 11 deletions(-) create mode 100644 .changeset/capability-provider-preflight-3366.md create mode 100644 packages/cli/src/utils/capability-preflight.ts create mode 100644 packages/cli/test/capability-preflight.test.ts diff --git a/.changeset/capability-provider-preflight-3366.md b/.changeset/capability-provider-preflight-3366.md new file mode 100644 index 0000000000..fa876e1db9 --- /dev/null +++ b/.changeset/capability-provider-preflight-3366.md @@ -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. diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 2888043c84..a6a91f999b 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -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 { @@ -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` diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index f87ebd96c4..0eb806e854 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -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 { @@ -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 => { try { const mod: any = await importFromHost(pkg); @@ -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}`, ); } @@ -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 @@ -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' }, ); } } @@ -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}`, ); } diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 18516ec443..20dc64597f 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -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'; @@ -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, @@ -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); @@ -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(), @@ -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 diff --git a/packages/cli/src/utils/capability-preflight.ts b/packages/cli/src/utils/capability-preflight.ts new file mode 100644 index 0000000000..04d9f599ec --- /dev/null +++ b/packages/cli/src/utils/capability-preflight.ts @@ -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 `/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(); + 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)); +} diff --git a/packages/cli/test/capability-preflight.test.ts b/packages/cli/test/capability-preflight.test.ts new file mode 100644 index 0000000000..fcd95ecc8b --- /dev/null +++ b/packages/cli/test/capability-preflight.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import { + preflightRequiredCapabilities, + renderCapabilityMessage, + missingProviderMessage, + makeProviderResolver, +} from '../src/utils/capability-preflight.js'; +import { classifyRequiredCapability } from '@objectstack/spec/kernel'; + +// framework#3366 — the CLI-side resolution + message layer over the spec-owned +// classifier. Resolution is injected so the classification is deterministic. + +describe('preflightRequiredCapabilities (#3366)', () => { + const call = (requires: unknown[], isInstalled: (p: string) => boolean) => + preflightRequiredCapabilities({ requires, projectDir: '/tmp/nowhere', isInstalled }); + + it('a fully-satisfied requires list yields no errors and no warnings', () => { + const r = call(['automation', 'ui', 'auth'], () => true); + expect(r.errors).toHaveLength(0); + expect(r.warnings).toHaveLength(0); + }); + + it('a cloud-only provider is a FATAL error (no installable version here)', () => { + const r = call(['ai'], () => false); + expect(r.errors.map((c) => c.token)).toEqual(['ai']); + expect(r.warnings).toHaveLength(0); + }); + + it('an absent open-edition provider is an advisory warning, not an error', () => { + const r = call(['automation'], () => false); + expect(r.errors).toHaveLength(0); + expect(r.warnings.map((c) => c.token)).toEqual(['automation']); + }); + + it('an unknown token is an advisory warning (typo), never fatal', () => { + const r = call(['automations'], () => true); + expect(r.errors).toHaveLength(0); + expect(r.warnings.map((c) => c.status)).toEqual(['unknown']); + }); + + it('dedupes and ignores non-string entries', () => { + const r = call(['ai', 'ai', 42, null, 'ai'], () => false); + expect(r.errors).toHaveLength(1); + }); + + it('only classifies what is declared — an empty list is always clean', () => { + expect(call([], () => false)).toEqual({ errors: [], warnings: [] }); + }); +}); + +describe('renderCapabilityMessage (#3366)', () => { + const msgFor = (token: string, isInstalled: (p: string) => boolean) => + renderCapabilityMessage(classifyRequiredCapability(token, isInstalled)); + + it('cloud-only message names the package, the edition, and the cloud escape', () => { + const m = msgFor('ai', () => false); + expect(m).toContain('@objectstack/service-ai'); + expect(m).toContain('not available in the open edition'); + expect(m).toContain('cloud runtime'); + }); + + it('a packageless cloud tier states it has no open-edition provider', () => { + const m = msgFor('ai-seat', () => false); + expect(m).toContain('no open-edition provider'); + }); + + it('open-edition absent provider gives a `pnpm add` hint', () => { + const m = msgFor('automation', () => false); + expect(m).toContain('pnpm add @objectstack/service-automation'); + }); + + it('enterprise provider hint points at plugins[] wiring', () => { + const m = msgFor('hierarchy-security', () => false); + expect(m).toContain('pnpm add @objectstack/security-enterprise'); + expect(m).toContain('plugins[]'); + }); + + it('unknown token reads as a typo hint', () => { + expect(msgFor('automations', () => true)).toContain('not a known platform capability'); + }); +}); + +describe('missingProviderMessage — serve reads identically to the preflight (#3366)', () => { + it('matches what the build preflight renders for the same absent token', () => { + for (const token of ['ai', 'ai-studio', 'automation', 'hierarchy-security']) { + // At the serve throw site the package is confirmed absent, so the preflight + // classifies it against a `false` resolver — the two MUST produce the same + // string, which is the whole point of the shared classifier. + const fromServe = missingProviderMessage(token); + const fromBuild = renderCapabilityMessage(classifyRequiredCapability(token, () => false)); + expect(fromServe).toBe(fromBuild); + } + }); + + it('the `ai` boot message is edition-aware, not the old "add it to your dependencies"', () => { + const m = missingProviderMessage('ai'); + expect(m).toContain('not available in the open edition'); + expect(m).not.toContain("add it to the app's dependencies"); + }); +}); + +describe('makeProviderResolver — real on-disk resolution', () => { + const isInstalled = makeProviderResolver('/tmp/nowhere'); + + it('resolves a package the CLI actually depends on', () => { + // `@objectstack/spec` is a hard CLI dependency, so it resolves from the CLI + // module graph even though the host dir is bogus. + expect(isInstalled('@objectstack/spec')).toBe(true); + }); + + it('reports a genuinely-absent package as not installed', () => { + expect(isInstalled('@objectstack/service-ai')).toBe(false); + expect(isInstalled('@objectstack/this-package-does-not-exist')).toBe(false); + }); +}); diff --git a/packages/cli/test/serve-capability-vocabulary.test.ts b/packages/cli/test/serve-capability-vocabulary.test.ts index 3de565debe..57aa2b5006 100644 --- a/packages/cli/test/serve-capability-vocabulary.test.ts +++ b/packages/cli/test/serve-capability-vocabulary.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { PLATFORM_CAPABILITY_TOKENS } from '@objectstack/spec/kernel'; +import { PLATFORM_CAPABILITY_TOKENS, PLATFORM_CAPABILITY_PROVIDERS } from '@objectstack/spec/kernel'; import Serve from '../src/commands/serve.js'; // framework#3265 — drift guard: the serve path's provider registries must stay @@ -39,3 +39,38 @@ describe('serve capability registries vs spec vocabulary (#3265)', () => { } }); }); + +// framework#3366 — the installable-provider registry must classify EVERY +// vocabulary token, and its `open`-edition entries must name the SAME package +// the serve resolver actually loads. Otherwise the preflight and boot could +// disagree about what provides a capability (or what edition it ships in). +describe('PLATFORM_CAPABILITY_PROVIDERS vs vocabulary + serve resolver (#3366)', () => { + it('classifies every vocabulary token, and adds none outside it (1:1)', () => { + const providerTokens = Object.keys(PLATFORM_CAPABILITY_PROVIDERS); + for (const token of PLATFORM_CAPABILITY_TOKENS) { + expect(providerTokens, `vocabulary token '${token}' has no provider entry`).toContain(token); + } + for (const token of providerTokens) { + expect(PLATFORM_CAPABILITY_TOKENS, `provider token '${token}' missing from vocabulary`).toContain(token); + } + }); + + it('open-edition service tokens name the SAME package as serve CAPABILITY_PROVIDERS', () => { + for (const [token, spec] of Object.entries(Serve.CAPABILITY_PROVIDERS)) { + const provider = PLATFORM_CAPABILITY_PROVIDERS[token]; + expect(provider, `serve provider '${token}' has no registry entry`).toBeTruthy(); + expect(provider.package, `package mismatch for '${token}'`).toBe(spec.pkg); + expect(provider.edition, `serve-provided '${token}' must be an open-edition provider`).toBe('open'); + } + }); + + it('tier-gated tokens carry a provider entry; ai/ai-studio are cloud-only', () => { + for (const token of Object.keys(Serve.CAPABILITY_TO_TIER)) { + expect(PLATFORM_CAPABILITY_PROVIDERS[token], `tier token '${token}' has no provider entry`).toBeTruthy(); + } + // The bug the issue targets: AI runtime went cloud-only, so under the open + // edition there is no version to install. + expect(PLATFORM_CAPABILITY_PROVIDERS.ai.edition).toBe('cloud'); + expect(PLATFORM_CAPABILITY_PROVIDERS['ai-studio'].edition).toBe('cloud'); + }); +}); diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index 687a31c831..1ec352e6fa 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -120,10 +120,18 @@ export * from './migrations/index.js'; export { type PluginContext } from './kernel/plugin.zod'; -// Platform SERVICE capability vocabulary for `requires: [...]` (framework#3265). +// Platform SERVICE capability vocabulary for `requires: [...]` (framework#3265), +// plus the provider/edition registry + classifier behind the installable-provider +// preflight (framework#3366). export { PLATFORM_CAPABILITY_TOKENS, isKnownPlatformCapability, + PLATFORM_CAPABILITY_PROVIDERS, + classifyRequiredCapability, + type CapabilityEdition, + type PlatformCapabilityProvider, + type CapabilityProviderStatus, + type CapabilityClassification, } from './kernel/platform-capabilities'; // Expression Protocol (M9 — canonical wire format for formulas / predicates / conditions) diff --git a/packages/spec/src/kernel/platform-capabilities.test.ts b/packages/spec/src/kernel/platform-capabilities.test.ts index c4f708214c..a247e17306 100644 --- a/packages/spec/src/kernel/platform-capabilities.test.ts +++ b/packages/spec/src/kernel/platform-capabilities.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'; import { PLATFORM_CAPABILITY_TOKENS, isKnownPlatformCapability, + PLATFORM_CAPABILITY_PROVIDERS, + classifyRequiredCapability, } from './platform-capabilities'; // framework#3265 — one capability vocabulary across the standalone serve path @@ -46,3 +48,69 @@ describe('isKnownPlatformCapability', () => { expect(isKnownPlatformCapability('automations')).toBe(false); }); }); + +// framework#3366 — the provider/edition registry + classifier behind the +// installable-provider preflight. +describe('PLATFORM_CAPABILITY_PROVIDERS', () => { + it('is frozen and maps exactly the vocabulary tokens', () => { + expect(Object.isFrozen(PLATFORM_CAPABILITY_PROVIDERS)).toBe(true); + expect(new Set(Object.keys(PLATFORM_CAPABILITY_PROVIDERS))).toEqual( + new Set(PLATFORM_CAPABILITY_TOKENS), + ); + }); + + it('every entry has a valid edition; cloud tiers may carry a null package', () => { + for (const [token, p] of Object.entries(PLATFORM_CAPABILITY_PROVIDERS)) { + expect(['open', 'enterprise', 'cloud'], `bad edition for '${token}'`).toContain(p.edition); + if (p.package !== null) expect(p.package.startsWith('@')).toBe(true); + // A packageless provider only makes sense for a cloud-runtime tier. + if (p.package === null) expect(p.edition).toBe('cloud'); + } + }); +}); + +describe('classifyRequiredCapability (#3366)', () => { + const allInstalled = () => true; + const noneInstalled = () => false; + + it('installed provider ⇒ ok', () => { + expect(classifyRequiredCapability('automation', allInstalled).status).toBe('ok'); + expect(classifyRequiredCapability('ai', allInstalled).status).toBe('ok'); + }); + + it('absent open-edition provider ⇒ installable (add the dep)', () => { + const c = classifyRequiredCapability('automation', noneInstalled); + expect(c.status).toBe('installable'); + expect(c.provider?.package).toBe('@objectstack/service-automation'); + }); + + it('absent cloud-only provider ⇒ unavailable (no version to install here)', () => { + // The headline case: `ai` → @objectstack/service-ai, cloud-only. + const c = classifyRequiredCapability('ai', noneInstalled); + expect(c.status).toBe('unavailable'); + expect(c.provider?.edition).toBe('cloud'); + }); + + it('a cloud tier with no package is unavailable even when "everything" resolves', () => { + // `ai-seat`/`governance` have package:null — there is nothing to resolve, so + // they can never be satisfied under the open edition. + expect(classifyRequiredCapability('ai-seat', allInstalled).status).toBe('unavailable'); + expect(classifyRequiredCapability('governance', allInstalled).status).toBe('unavailable'); + }); + + it('absent enterprise provider ⇒ installable (a licensed package, still addable)', () => { + const c = classifyRequiredCapability('hierarchy-security', noneInstalled); + expect(c.status).toBe('installable'); + expect(c.provider?.edition).toBe('enterprise'); + }); + + it('an unknown token ⇒ unknown (typo), never a provider miss', () => { + expect(classifyRequiredCapability('automations', noneInstalled).status).toBe('unknown'); + }); + + it('resolution is injected — the classifier itself performs no I/O', () => { + const seen: string[] = []; + classifyRequiredCapability('automation', (pkg) => { seen.push(pkg); return true; }); + expect(seen).toEqual(['@objectstack/service-automation']); + }); +}); diff --git a/packages/spec/src/kernel/platform-capabilities.ts b/packages/spec/src/kernel/platform-capabilities.ts index 4ebfe55b52..9787579609 100644 --- a/packages/spec/src/kernel/platform-capabilities.ts +++ b/packages/spec/src/kernel/platform-capabilities.ts @@ -73,3 +73,148 @@ export const PLATFORM_CAPABILITY_TOKENS: readonly string[] = Object.freeze([ export function isKnownPlatformCapability(token: string): boolean { return PLATFORM_CAPABILITY_TOKENS.includes(token); } + +/** + * Distribution edition that ships an *installable* provider for a capability. + * + * - `open` — a framework `@objectstack/*` package on the public registry + * (bundled as a dependency of `@objectstack/cli`, so a + * `requires` token backed by one resolves out of the box). + * - `enterprise` — a separately-licensed enterprise package the app installs + * and wires in via `plugins[]` (e.g. `hierarchy-security`). + * - `cloud` — realized only by a cloud runtime tier; there is **no + * installable version in the open edition**. This is the + * boundary framework#3366 makes legible: "add it to your + * dependencies" is un-followable, so the error must say so. + */ +export type CapabilityEdition = 'open' | 'enterprise' | 'cloud'; + +/** How a platform SERVICE capability's runtime is provided (framework#3366). */ +export interface PlatformCapabilityProvider { + /** + * npm package whose plugin the runtime loads to satisfy this capability, or + * `null` when the capability is a cloud-runtime tier with no standalone + * package to install (`ai-seat` / `governance`). + */ + readonly package: string | null; + /** Which edition ships an installable version of {@link package}. */ + readonly edition: CapabilityEdition; + /** + * Short human note on the edition boundary, surfaced verbatim inside the + * preflight / boot error so the message carries its own context. + */ + readonly note?: string; +} + +/** + * Every {@link PLATFORM_CAPABILITY_TOKENS} entry → the package + edition that + * provides its runtime (framework#3366). This is the single machine-readable + * source of truth for the knowledge `serve`'s CAPABILITY_PROVIDERS map + tier + * gating already encode informally, lifted so a preflight can read it *before* + * boot and report instead of aborting — and so cloud's objectos-runtime and the + * framework CLI classify a `requires` token identically. + * + * A drift test (`serve-capability-vocabulary.test.ts`) asserts this map and the + * vocabulary stay in 1:1 sync, and that every `open`-edition entry agrees with + * the package the serve resolver actually loads — so the two can't diverge. + */ +export const PLATFORM_CAPABILITY_PROVIDERS: Readonly> = + Object.freeze({ + // ── Tier-gated capabilities (serve.ts CAPABILITY_TO_TIER) ────────────── + // `ai` / `ai-studio` were removed from the open edition (ADR-0025): the AI + // runtime is cloud-only, so under the open edition there is NO version to + // install — the boundary framework#3366 exists to surface. + ai: { + package: '@objectstack/service-ai', + edition: 'cloud', + note: 'cloud-only since 11.3.0 / ADR-0025', + }, + 'ai-studio': { + package: '@objectstack/service-ai-studio', + edition: 'cloud', + note: 'cloud-only AI authoring; not part of the open framework', + }, + i18n: { package: '@objectstack/service-i18n', edition: 'open' }, + ui: { package: '@objectstack/console', edition: 'open' }, + auth: { package: '@objectstack/plugin-auth', edition: 'open' }, + // ── Service capabilities (serve.ts CAPABILITY_PROVIDERS) ─────────────── + automation: { package: '@objectstack/service-automation', edition: 'open' }, + analytics: { package: '@objectstack/service-analytics', edition: 'open' }, + audit: { package: '@objectstack/plugin-audit', edition: 'open' }, + cache: { package: '@objectstack/service-cache', edition: 'open' }, + storage: { package: '@objectstack/service-storage', edition: 'open' }, + queue: { package: '@objectstack/service-queue', edition: 'open' }, + job: { package: '@objectstack/service-job', edition: 'open' }, + messaging: { package: '@objectstack/service-messaging', edition: 'open' }, + triggers: { package: '@objectstack/trigger-record-change', edition: 'open' }, + realtime: { package: '@objectstack/service-realtime', edition: 'open' }, + mcp: { package: '@objectstack/mcp', edition: 'open' }, + marketplace: { package: '@objectstack/service-package', edition: 'open' }, + email: { package: '@objectstack/plugin-email', edition: 'open' }, + sms: { package: '@objectstack/service-sms', edition: 'open' }, + sharing: { package: '@objectstack/plugin-sharing', edition: 'open' }, + 'pinyin-search': { package: '@objectstack/plugin-pinyin-search', edition: 'open' }, + reports: { package: '@objectstack/plugin-reports', edition: 'open' }, + approvals: { package: '@objectstack/plugin-approvals', edition: 'open' }, + settings: { package: '@objectstack/service-settings', edition: 'open' }, + webhooks: { package: '@objectstack/plugin-webhooks', edition: 'open' }, + // ── Enterprise / cloud-runtime capabilities ──────────────────────────── + 'hierarchy-security': { + package: '@objectstack/security-enterprise', + edition: 'enterprise', + note: 'ADR-0057 hierarchy scopes ship in the enterprise edition', + }, + 'ai-seat': { package: null, edition: 'cloud', note: 'cloud AI-seat tier' }, + governance: { package: null, edition: 'cloud', note: 'cloud governance tier' }, + }); + +/** + * Outcome of classifying one `requires` token against the installed providers: + * - `ok` — provider resolvable (installed); nothing to do. + * - `installable` — absent, but an installable version exists in this edition + * (`open`/`enterprise` package) → actionable `pnpm add` hint. + * - `unavailable` — absent AND no installable version in this edition + * (`cloud`-only, or a tier with no package) → edition error. + * - `unknown` — not a platform capability token at all (a typo). + */ +export type CapabilityProviderStatus = 'ok' | 'installable' | 'unavailable' | 'unknown'; + +/** Structured classification of a single `requires` token (framework#3366). */ +export interface CapabilityClassification { + readonly token: string; + readonly status: CapabilityProviderStatus; + /** Present for every known token (absent only when `status` is `unknown`). */ + readonly provider?: PlatformCapabilityProvider; +} + +/** + * Classify one `requires` capability token by whether its provider is installed + * and, if not, whether it *could* be in the active (open) edition — the pure + * derivation behind both the `os build` preflight and the `os serve` boot error + * (framework#3366). Package resolution is injected via `isInstalled` so this + * stays side-effect-free (spec holds no I/O): callers wire it to + * `require.resolve`. Message rendering is the caller's job — this returns only + * the status + provider facts, so every runtime can word it in its own voice. + */ +export function classifyRequiredCapability( + token: string, + isInstalled: (pkg: string) => boolean, +): CapabilityClassification { + const provider = PLATFORM_CAPABILITY_PROVIDERS[token]; + if (!provider) { + // The drift test keeps the registry complete, so a token with no provider + // entry is outside the vocabulary — i.e. a typo. (The `isKnown` guard is + // defensive: a known-but-unmapped token classifies as satisfied, never as a + // false failure.) + return { token, status: isKnownPlatformCapability(token) ? 'ok' : 'unknown' }; + } + if (provider.package && isInstalled(provider.package)) { + return { token, status: 'ok', provider }; + } + // Absent. A `cloud`-only tier (or one with no installable package) has no + // version to add in the open edition; `open`/`enterprise` do → actionable add. + if (provider.edition === 'cloud' || provider.package === null) { + return { token, status: 'unavailable', provider }; + } + return { token, status: 'installable', provider }; +} From 95a4357e35464f663bdeaf827dc07ddbc0ddf638 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 14:02:06 +0000 Subject: [PATCH 2/2] chore(spec): sync api-surface snapshot for new capability-provider exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_013CQ5vX12KRiZ7mn1U4bSwQ --- packages/spec/api-surface.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 13e087e283..fc62fe7695 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -15,6 +15,9 @@ "CONVERSIONS_BY_MAJOR (const)", "CONVERSION_CONFLICT_CODE (const)", "CONVERSION_NOTICE_CODE (const)", + "CapabilityClassification (interface)", + "CapabilityEdition (type)", + "CapabilityProviderStatus (type)", "ComposeStacksOptions (type)", "ComposeStacksOptionsSchema (const)", "ConflictStrategy (type)", @@ -76,7 +79,9 @@ "ObjectUICapabilities (type)", "ObjectUICapabilitiesSchema (const)", "P (const)", + "PLATFORM_CAPABILITY_PROVIDERS (const)", "PLATFORM_CAPABILITY_TOKENS (const)", + "PlatformCapabilityProvider (interface)", "PluginContext (type)", "Predicate (type)", "PredicateInput (type)", @@ -102,6 +107,7 @@ "applyConversionsToFlow (function)", "applyMetaMigrations (function)", "cel (function)", + "classifyRequiredCapability (function)", "collectConversionNotices (function)", "composeMigrationChain (function)", "composeSpecChanges (function)", @@ -1278,8 +1284,11 @@ "CLICommandContributionSchema (const)", "CONSUMER_INSTALLABLE_TYPES (const)", "CORE_PLUGIN_TYPES (const)", + "CapabilityClassification (interface)", "CapabilityConformanceLevel (type)", "CapabilityConformanceLevelSchema (const)", + "CapabilityEdition (type)", + "CapabilityProviderStatus (type)", "ClusterCapabilityConfig (type)", "ClusterCapabilityConfigInput (type)", "ClusterCapabilityConfigSchema (const)", @@ -1517,6 +1526,7 @@ "OpsFilePathSchema (const)", "OpsPluginStructure (type)", "OpsPluginStructureSchema (const)", + "PLATFORM_CAPABILITY_PROVIDERS (const)", "PLATFORM_CAPABILITY_TOKENS (const)", "PROTOCOL_MAJOR (const)", "PROTOCOL_VERSION (const)", @@ -1538,6 +1548,7 @@ "PermissionActionSchema (const)", "PermissionScope (type)", "PermissionScopeSchema (const)", + "PlatformCapabilityProvider (interface)", "PluginCaching (type)", "PluginCachingSchema (const)", "PluginCapability (type)", @@ -1751,6 +1762,7 @@ "VersionConstraint (type)", "VersionConstraintSchema (const)", "VulnerabilitySeverity (type)", + "classifyRequiredCapability (function)", "deriveNamespaceFromPackageId (function)", "evaluateLockForDelete (function)", "evaluateLockForWrite (function)",