33import { Args , Command , Flags } from '@oclif/core' ;
44import { existsSync , readFileSync } from 'node:fs' ;
55import { createRequire } from 'node:module' ;
6- import { join } from 'node:path' ;
6+ import { join , dirname } from 'node:path' ;
77import chalk from 'chalk' ;
88import { ZodError } from 'zod' ;
99import { ObjectStackDefinitionSchema , normalizeStackInput , type ConversionNotice } from '@objectstack/spec' ;
@@ -19,6 +19,7 @@ import { validateCapabilityReferences } from '@objectstack/lint';
1919import { validateVisibilityPredicates } from '@objectstack/lint' ;
2020import { validateSecurityPosture } from '@objectstack/lint' ;
2121import { validateFlowTriggerReadiness } from '@objectstack/lint' ;
22+ import { preflightRequiredCapabilities , renderCapabilityMessage } from '../utils/capability-preflight.js' ;
2223import {
2324 printHeader ,
2425 printKV ,
@@ -460,6 +461,42 @@ export default class Validate extends Command {
460461 }
461462 }
462463
464+ // 3h. [#3366] Installable-provider preflight — the shift-left of the
465+ // `serve`-time capability check. `os validate` previously only checked
466+ // the `requires` tokens against the vocabulary (ADR-0066), never
467+ // whether each token's provider is resolvable in the active edition. A
468+ // token whose provider has NO installable version here (e.g. `ai` →
469+ // @objectstack /service-ai, cloud-only) fails; absent-but-installable is
470+ // an advisory `pnpm add` hint. Mirrors the `os build` gate exactly.
471+ if ( ! flags . json ) printStep ( 'Checking capability providers (#3366)...' ) ;
472+ const capProviderPreflight = preflightRequiredCapabilities ( {
473+ requires : Array . isArray ( ( config as { requires ?: unknown [ ] } ) . requires )
474+ ? ( ( config as { requires ?: unknown [ ] } ) . requires as unknown [ ] )
475+ : [ ] ,
476+ projectDir : dirname ( absolutePath ) ,
477+ } ) ;
478+ const capProviderErrors = capProviderPreflight . errors ;
479+ const capProviderWarnings = capProviderPreflight . warnings . map ( ( c ) => ( {
480+ token : c . token ,
481+ message : renderCapabilityMessage ( c ) ,
482+ } ) ) ;
483+ if ( capProviderErrors . length > 0 ) {
484+ if ( flags . json ) {
485+ console . log ( JSON . stringify ( {
486+ valid : false ,
487+ errors : capProviderErrors . map ( ( c ) => ( { token : c . token , message : renderCapabilityMessage ( c ) } ) ) ,
488+ duration : timer . elapsed ( ) ,
489+ } , null , 2 ) ) ;
490+ this . exit ( 1 ) ;
491+ }
492+ console . log ( '' ) ;
493+ printError ( `Capability provider check failed (${ capProviderErrors . length } issue${ capProviderErrors . length > 1 ? 's' : '' } )` ) ;
494+ for ( const c of capProviderErrors ) {
495+ console . log ( ` • ${ renderCapabilityMessage ( c ) } ` ) ;
496+ }
497+ this . exit ( 1 ) ;
498+ }
499+
463500 // 4. Collect and display stats
464501 const stats = collectMetadataStats ( config ) ;
465502
@@ -472,7 +509,7 @@ export default class Validate extends Command {
472509 valid : true ,
473510 manifest : config . manifest ,
474511 stats,
475- warnings : [ ...exprWarnings , ...widgetWarnings , ...actionRefWarnings , ...styleWarnings , ...jsxWarnings , ...capWarnings , ...flowReadinessWarnings , ...securityAdvisories ] ,
512+ warnings : [ ...exprWarnings , ...widgetWarnings , ...actionRefWarnings , ...styleWarnings , ...jsxWarnings , ...capWarnings , ...flowReadinessWarnings , ...securityAdvisories , ... capProviderWarnings ] ,
476513 conversions : conversionNotices ,
477514 specVersionGap : specGap ,
478515 duration : timer . elapsed ( ) ,
@@ -483,6 +520,12 @@ export default class Validate extends Command {
483520 // 5. Warnings (non-blocking)
484521 const warnings : string [ ] = [ ] ;
485522
523+ // [#3366] Installable-provider hints — a declared capability whose provider
524+ // is absent but addable (`pnpm add`), or an unknown token (typo).
525+ for ( const w of capProviderWarnings ) {
526+ warnings . push ( w . message ) ;
527+ }
528+
486529 // ADR-0089 D3b — deprecated visibility aliases + mis-layered binding root.
487530 // Checked on `normalized` (PRE-parse): the schema folds `visibleOn`/
488531 // `visibility` into `visibleWhen` during parse, so `result.data` no longer
0 commit comments