11import type { Command } from 'commander' ;
22import { platformFetch } from '../../lib/api/platform.js' ;
3+ import { ossFetch } from '../../lib/api/oss.js' ;
34import { requireAuth } from '../../lib/credentials.js' ;
4- import { handleError , getRootOpts , CLIError , ProjectNotLinkedError } from '../../lib/errors.js' ;
5+ import { CLIError , handleError , getRootOpts , ProjectNotLinkedError } from '../../lib/errors.js' ;
56import { getProjectConfig , FAKE_PROJECT_ID } from '../../lib/config.js' ;
67import { outputJson , outputTable } from '../../lib/output.js' ;
78import { reportCliUsage } from '../../lib/skills.js' ;
89import { trackDiagnose , shutdownAnalytics } from '../../lib/analytics.js' ;
910
10-
1111interface AdvisorScanSummary {
1212 scanId : string ;
1313 status : string ;
1414 scanType : string ;
1515 scannedAt : string ;
16+ errorMessage ?: string ;
1617 summary : { total : number ; critical : number ; warning : number ; info : number } ;
17- collectorErrors : { collector : string ; error : string ; timestamp : string } [ ] ;
18+ collectorErrors ? : { collector : string ; error : string ; timestamp : string } [ ] ;
1819}
1920
2021interface AdvisorIssue {
@@ -26,22 +27,96 @@ interface AdvisorIssue {
2627 description : string ;
2728 affectedObject : string ;
2829 recommendation : string ;
29- isResolved : boolean ;
30+ isResolved ? : boolean ;
3031}
3132
3233interface AdvisorIssuesResponse {
3334 issues : AdvisorIssue [ ] ;
3435 total : number ;
3536}
3637
37- export async function fetchAdvisorSummary (
38+ /**
39+ * True when an error from `ossFetch` is a route-level 404 — i.e. this backend
40+ * is too old to expose the advisor endpoints at all. `ossFetch` throws a
41+ * `CLIError` carrying the HTTP status for these. A backend that HAS the route
42+ * but no scan yet returns 200 with a null/empty body, never a 404, so a 404
43+ * here unambiguously means "route absent → fall back to cloud-backend".
44+ */
45+ function isOssAdvisorRouteMissing ( err : unknown ) : boolean {
46+ return err instanceof CLIError && err . statusCode === 404 ;
47+ }
48+
49+ async function fetchOssAdvisorLatest ( ) : Promise < AdvisorScanSummary | null > {
50+ const res = await ossFetch ( '/api/advisor/latest' ) ;
51+ // Guard the parse: no-scan returns null and an empty body would make
52+ // res.json() throw, matching the .catch pattern used across the codebase.
53+ return ( await res . json ( ) . catch ( ( ) => null ) ) as AdvisorScanSummary | null ;
54+ }
55+
56+ async function fetchOssAdvisorIssues ( params : URLSearchParams ) : Promise < AdvisorIssuesResponse > {
57+ const res = await ossFetch ( `/api/advisor/issues?${ params . toString ( ) } ` ) ;
58+ // The OSS backend returns { issues: [], total: 0 } for the no-scan state, but
59+ // normalize defensively so a null/empty body can never crash the caller
60+ // (res.json() throws on an empty body, so guard the parse itself too).
61+ const data = ( await res . json ( ) . catch ( ( ) => null ) ) as AdvisorIssuesResponse | null ;
62+ return data ?? { issues : [ ] , total : 0 } ;
63+ }
64+
65+ async function fetchPlatformAdvisorLatest (
3866 projectId : string ,
3967 apiUrl ?: string ,
40- ) : Promise < AdvisorScanSummary > {
41- const res = await platformFetch ( `/projects/v1/${ projectId } /advisor/latest` , { } , apiUrl ) ;
68+ ) : Promise < AdvisorScanSummary | null > {
69+ // 404 here means the legacy cloud-backend has no scan for this project — a
70+ // normal "no scan yet" state, not a failure. Pass it through and return null.
71+ const res = await platformFetch (
72+ `/projects/v1/${ projectId } /advisor/latest` ,
73+ { passThroughStatuses : [ 404 ] } ,
74+ apiUrl ,
75+ ) ;
76+ if ( res . status === 404 ) return null ;
4277 return ( await res . json ( ) ) as AdvisorScanSummary ;
4378}
4479
80+ async function fetchPlatformAdvisorIssues (
81+ projectId : string ,
82+ params : URLSearchParams ,
83+ apiUrl ?: string ,
84+ ) : Promise < AdvisorIssuesResponse > {
85+ const res = await platformFetch (
86+ `/projects/v1/${ projectId } /advisor/latest/issues?${ params . toString ( ) } ` ,
87+ { passThroughStatuses : [ 404 ] } ,
88+ apiUrl ,
89+ ) ;
90+ if ( res . status === 404 ) return { issues : [ ] , total : 0 } ;
91+ return ( await res . json ( ) ) as AdvisorIssuesResponse ;
92+ }
93+
94+ /**
95+ * Scan summary only, used by `diagnose` (no subcommand) to build the
96+ * aggregate health report.
97+ *
98+ * The project's own OSS backend is authoritative — it holds the data and its
99+ * running version is the only thing that decides whether the advisor route
100+ * exists. So we always try the OSS advisor first and only fall back to the
101+ * legacy cloud-backend when the OSS route is absent (backend older than the
102+ * advisor feature). This avoids trusting possibly-stale Platform metadata.
103+ * OSS `--api-key` mode has no cloud-backend to fall back to.
104+ */
105+ export async function fetchAdvisorSummary (
106+ projectId : string ,
107+ apiUrl ?: string ,
108+ ) : Promise < AdvisorScanSummary | null > {
109+ if ( projectId === FAKE_PROJECT_ID ) {
110+ return await fetchOssAdvisorLatest ( ) ;
111+ }
112+ try {
113+ return await fetchOssAdvisorLatest ( ) ;
114+ } catch ( err ) {
115+ if ( ! isOssAdvisorRouteMissing ( err ) ) throw err ;
116+ return await fetchPlatformAdvisorLatest ( projectId , apiUrl ) ;
117+ }
118+ }
119+
45120export function registerDiagnoseAdvisorCommand ( diagnoseCmd : Command ) : void {
46121 diagnoseCmd
47122 . command ( 'advisor' )
@@ -55,45 +130,47 @@ export function registerDiagnoseAdvisorCommand(diagnoseCmd: Command): void {
55130 await requireAuth ( apiUrl ) ;
56131 const config = getProjectConfig ( ) ;
57132 if ( ! config ) throw new ProjectNotLinkedError ( ) ;
58- if ( config . project_id === FAKE_PROJECT_ID ) {
59- throw new CLIError (
60- 'Advisor requires InsForge Platform login. Not available when linked via --api-key.' ,
61- ) ;
62- }
63133 trackDiagnose ( 'advisor' , config ) ;
64134
65135 const projectId = config . project_id ;
136+ const ossMode = projectId === FAKE_PROJECT_ID ;
66137
67- // Fetch scan summary
68- const scanRes = await platformFetch (
69- `/projects/v1/${ projectId } /advisor/latest` ,
70- { } ,
71- apiUrl ,
72- ) ;
73- const scan = ( await scanRes . json ( ) ) as AdvisorScanSummary ;
74-
75- // Fetch issues
76138 const issueParams = new URLSearchParams ( ) ;
77139 if ( opts . severity ) issueParams . set ( 'severity' , opts . severity ) ;
78140 if ( opts . category ) issueParams . set ( 'category' , opts . category ) ;
79141 issueParams . set ( 'limit' , opts . limit ) ;
80142
81- const issuesRes = await platformFetch (
82- `/projects/v1/${ projectId } /advisor/latest/issues?${ issueParams . toString ( ) } ` ,
83- { } ,
84- apiUrl ,
85- ) ;
86- const issuesData = ( await issuesRes . json ( ) ) as AdvisorIssuesResponse ;
143+ let scan : AdvisorScanSummary | null ;
144+ let issuesData : AdvisorIssuesResponse ;
145+
146+ // Try the project's own OSS advisor first (it holds the data and its
147+ // running version is what decides whether the route exists). In
148+ // Platform mode, fall back to the legacy cloud-backend only when the
149+ // OSS route is absent (backend older than the advisor feature). OSS
150+ // `--api-key` mode has no fallback — the oss.ts route-level-404 message
151+ // guards backends too old to have the route.
152+ try {
153+ scan = await fetchOssAdvisorLatest ( ) ;
154+ issuesData = await fetchOssAdvisorIssues ( issueParams ) ;
155+ } catch ( err ) {
156+ if ( ossMode || ! isOssAdvisorRouteMissing ( err ) ) throw err ;
157+ scan = await fetchPlatformAdvisorLatest ( projectId , apiUrl ) ;
158+ issuesData = await fetchPlatformAdvisorIssues ( projectId , issueParams , apiUrl ) ;
159+ }
87160
88161 if ( json ) {
89162 outputJson ( { scan, issues : issuesData . issues } ) ;
90163 } else {
91- // Scan summary line
92- const date = new Date ( scan . scannedAt ) . toLocaleDateString ( ) ;
93- const s = scan . summary ;
94- console . log (
95- `Scan: ${ date } (${ scan . status } ) — ${ s . critical } critical, ${ s . warning } warning, ${ s . info } info\n` ,
96- ) ;
164+ if ( ! scan ) {
165+ console . log ( 'No scan yet.\n' ) ;
166+ } else {
167+ // Scan summary line
168+ const date = new Date ( scan . scannedAt ) . toLocaleDateString ( ) ;
169+ const s = scan . summary ;
170+ console . log (
171+ `Scan: ${ date } (${ scan . status } ) — ${ s . critical } critical, ${ s . warning } warning, ${ s . info } info\n` ,
172+ ) ;
173+ }
97174
98175 if ( ! issuesData . issues || issuesData . issues . length === 0 ) {
99176 console . log ( 'No issues found.' ) ;
0 commit comments