Skip to content

Commit ece8a74

Browse files
authored
Merge pull request #192 from InsForge/feat/diagnose-advisor-oss-routing
feat(diagnose): advisor to project OSS backend
2 parents bafcb30 + a6efe05 commit ece8a74

3 files changed

Lines changed: 126 additions & 36 deletions

File tree

src/commands/diagnose/advisor.ts

Lines changed: 110 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
11
import type { Command } from 'commander';
22
import { platformFetch } from '../../lib/api/platform.js';
3+
import { ossFetch } from '../../lib/api/oss.js';
34
import { 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';
56
import { getProjectConfig, FAKE_PROJECT_ID } from '../../lib/config.js';
67
import { outputJson, outputTable } from '../../lib/output.js';
78
import { reportCliUsage } from '../../lib/skills.js';
89
import { trackDiagnose, shutdownAnalytics } from '../../lib/analytics.js';
910

10-
1111
interface 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

2021
interface AdvisorIssue {
@@ -26,22 +27,96 @@ interface AdvisorIssue {
2627
description: string;
2728
affectedObject: string;
2829
recommendation: string;
29-
isResolved: boolean;
30+
isResolved?: boolean;
3031
}
3132

3233
interface 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+
45120
export 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.');

src/commands/diagnose/index.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,10 @@ async function collectDiagnosticData(
3535
const metricsPromise = ossMode
3636
? Promise.reject(new Error('Platform login required (linked via --api-key)'))
3737
: fetchMetricsSummary(projectId, apiUrl);
38-
const advisorPromise = ossMode
39-
? Promise.reject(new Error('Platform login required (linked via --api-key)'))
40-
: fetchAdvisorSummary(projectId, apiUrl);
38+
// Tries the project's own OSS advisor first, falling back to the
39+
// cloud-backend Platform advisor for older project backends — works in
40+
// both OSS and Platform link modes.
41+
const advisorPromise = fetchAdvisorSummary(projectId, apiUrl);
4142

4243
const [metricsResult, advisorResult, dbResult, logsResult] = await Promise.allSettled([
4344
metricsPromise,
@@ -75,6 +76,9 @@ async function collectDiagnosticData(
7576
}
7677

7778
if (advisorResult.status === 'fulfilled') {
79+
// A null summary is a healthy "no scan yet" state, not a failure — leave
80+
// `advisor` null without recording an error so JSON consumers don't treat
81+
// an unscanned project as a failed diagnostic run.
7882
advisor = advisorResult.value;
7983
} else {
8084
errors.push(advisorResult.reason?.message ?? 'Advisor unavailable');

src/lib/api/oss.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,15 @@ export async function ossFetch(
213213
message = 'The web scraper is not available on this backend.\nThe Apify web scraper is cloud-only. Self-hosted: this feature is not supported. Cloud: contact your InsForge admin to enable it.';
214214
}
215215

216+
// Safe to treat any 404 on /api/advisor/* as a route-level miss: the OSS
217+
// advisor endpoints return 200 with a null/empty body when no scan exists
218+
// (a resource-level "no data" state), so they never emit a 404 for a
219+
// present-but-empty resource. A 404 here therefore means the route itself
220+
// is absent (backend older than the advisor feature).
221+
if (res.status === 404 && isRouteLevel404 && path.startsWith('/api/advisor')) {
222+
message = 'Backend Advisor is not available on this backend.\nSelf-hosted: upgrade your InsForge instance. Cloud: update the project to a newer version.';
223+
}
224+
216225
throw new CLIError(message, 1, err.error, res.status);
217226
}
218227

0 commit comments

Comments
 (0)