feat(FN-2189): include gh CLI auth in setup readiness

- Extend auth status API typing to include optional ghCli availability/authentication metadata.
- Update /api/auth/status to return ghCli readiness using isGhAvailable() and isGhAuthenticated().
- Treat setup GitHub readiness as satisfied when either GitHub OAuth or authenticated gh CLI is present.
- Add hook tests covering gh CLI authenticated, unauthenticated, combined OAuth, and missing-ghCli fallback cases.
This commit is contained in:
Fusion
2026-04-20 10:16:13 -07:00
committed by gsxdsm
parent 595b5cab5f
commit 1015365ca5
4 changed files with 86 additions and 9 deletions

View File

@@ -903,8 +903,14 @@ export interface AuthProvider {
}
/** Fetch authentication status for all OAuth providers */
export function fetchAuthStatus(): Promise<{ providers: AuthProvider[] }> {
return api<{ providers: AuthProvider[] }>("/auth/status");
export function fetchAuthStatus(): Promise<{
providers: AuthProvider[];
ghCli?: { available: boolean; authenticated: boolean };
}> {
return api<{
providers: AuthProvider[];
ghCli?: { available: boolean; authenticated: boolean };
}>("/auth/status");
}
/** Initiate OAuth login for a provider. Returns the auth URL to open in a new tab. */

View File

@@ -77,6 +77,54 @@ describe("useSetupReadiness", () => {
expect(result.current.hasGithub).toBe(true);
});
it("returns hasGithub=true when gh CLI is authenticated but GitHub OAuth is not", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [makeProvider("anthropic", true)],
ghCli: { available: true, authenticated: true },
});
const { result } = renderHook(() => useSetupReadiness());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.hasGithub).toBe(true);
expect(result.current.hasWarnings).toBe(false);
});
it("returns hasGithub=true when both gh CLI and GitHub OAuth are authenticated", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [makeProvider("anthropic", true), makeProvider("github", true)],
ghCli: { available: true, authenticated: true },
});
const { result } = renderHook(() => useSetupReadiness());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.hasGithub).toBe(true);
});
it("returns hasGithub=false when gh CLI is available but not authenticated and GitHub OAuth is not connected", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [makeProvider("anthropic", true)],
ghCli: { available: true, authenticated: false },
});
const { result } = renderHook(() => useSetupReadiness());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.hasGithub).toBe(false);
});
it("returns hasGithub=false when ghCli is absent from response", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [makeProvider("anthropic", true)],
});
const { result } = renderHook(() => useSetupReadiness());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.hasGithub).toBe(false);
});
it("returns hasGithub=false when GitHub is missing or not authenticated", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [makeProvider("anthropic", true)],

View File

@@ -13,9 +13,15 @@ export interface SetupReadiness {
hasWarnings: boolean;
}
interface GhCliStatus {
available: boolean;
authenticated: boolean;
}
interface SetupReadinessSnapshot {
hasAiProvider: boolean;
hasGithub: boolean;
ghCli?: GhCliStatus;
expiresAt: number;
}
@@ -41,9 +47,14 @@ function getFreshSnapshot(cacheKey: string): SetupReadinessSnapshot | null {
return cached;
}
function evaluateProviders(providers: AuthProvider[]): Pick<SetupReadinessSnapshot, "hasAiProvider" | "hasGithub"> {
function evaluateProviders(
providers: AuthProvider[],
ghCli?: GhCliStatus,
): Pick<SetupReadinessSnapshot, "hasAiProvider" | "hasGithub"> {
const hasAiProvider = providers.some((provider) => provider.id !== "github" && provider.authenticated);
const hasGithub = providers.some((provider) => provider.id === "github" && provider.authenticated);
const hasGithub =
providers.some((provider) => provider.id === "github" && provider.authenticated) ||
(ghCli?.authenticated ?? false);
return {
hasAiProvider,
@@ -58,10 +69,11 @@ async function fetchAndCacheSetupReadiness(cacheKey: string): Promise<SetupReadi
}
const request = fetchAuthStatus()
.then(({ providers }) => {
const computed = evaluateProviders(providers);
.then(({ providers, ghCli }) => {
const computed = evaluateProviders(providers, ghCli);
const snapshot: SetupReadinessSnapshot = {
...computed,
ghCli,
expiresAt: Date.now() + CACHE_TTL_MS,
};
setupReadinessCache.set(cacheKey, snapshot);
@@ -90,6 +102,7 @@ export function useSetupReadiness(projectId?: string): SetupReadiness {
const [hasAiProvider, setHasAiProvider] = useState(initialSnapshot?.hasAiProvider ?? false);
const [hasGithub, setHasGithub] = useState(initialSnapshot?.hasGithub ?? false);
const [, setGhCli] = useState<GhCliStatus | undefined>(initialSnapshot?.ghCli);
const [loading, setLoading] = useState(initialSnapshot == null);
const initialLoadCompleteRef = useRef(Boolean(initialSnapshot));
@@ -102,6 +115,7 @@ export function useSetupReadiness(projectId?: string): SetupReadiness {
if (cached) {
setHasAiProvider(cached.hasAiProvider);
setHasGithub(cached.hasGithub);
setGhCli(cached.ghCli);
setLoading(false);
initialLoadCompleteRef.current = true;
return () => {
@@ -124,6 +138,7 @@ export function useSetupReadiness(projectId?: string): SetupReadiness {
}
setHasAiProvider(snapshot.hasAiProvider);
setHasGithub(snapshot.hasGithub);
setGhCli(snapshot.ghCli);
} catch {
// Best effort only: keep warnings visible when status cannot be fetched.
} finally {

View File

@@ -18,7 +18,7 @@ import * as nodeFs from "node:fs";
import { promisify } from "node:util";
import type { TaskStore, Column, ScheduleType, ActivityEventType, ModelPreset, MessageType, ParticipantType, RoutineTriggerType, ProjectSettings, EnrichedChatSession, PlanningSummary } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, type PiExtensionEntry, type PiExtensionSettings, getCurrentRepo, isGhAuthenticated, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupRoutine, exportSettings, importSettings, validateImportData, MessageStore, RoutineStore, isWebhookTrigger, resolveMemoryBackend, getMemoryBackendCapabilities, listMemoryBackendTypes, listProjectMemoryFiles, readProjectMemoryFile, readProjectMemoryFileContent, writeProjectMemoryFile, listAgentMemoryFiles, readAgentMemoryFile, writeAgentMemoryFile, readMemory, writeMemory, searchProjectMemory, isQmdAvailable, installQmd, refreshQmdProjectMemoryIndex, QMD_INSTALL_COMMAND, MemoryBackendError, scheduleQmdProjectMemoryRefresh, discoverPiExtensions, updatePiExtensionDisabledIds, getFusionAgentDir, getLegacyPiAgentDir, ensureMemoryFileWithBackend, readInsightsMemory, writeInsightsMemory, generateMemoryAudit, buildInsightExtractionPrompt, parseInsightExtractionResponse, processAndAuditInsightExtraction } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, type PiExtensionEntry, type PiExtensionSettings, getCurrentRepo, isGhAvailable, isGhAuthenticated, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupRoutine, exportSettings, importSettings, validateImportData, MessageStore, RoutineStore, isWebhookTrigger, resolveMemoryBackend, getMemoryBackendCapabilities, listMemoryBackendTypes, listProjectMemoryFiles, readProjectMemoryFile, readProjectMemoryFileContent, writeProjectMemoryFile, listAgentMemoryFiles, readAgentMemoryFile, writeAgentMemoryFile, readMemory, writeMemory, searchProjectMemory, isQmdAvailable, installQmd, refreshQmdProjectMemoryIndex, QMD_INSTALL_COMMAND, MemoryBackendError, scheduleQmdProjectMemoryRefresh, discoverPiExtensions, updatePiExtensionDisabledIds, getFusionAgentDir, getLegacyPiAgentDir, ensureMemoryFileWithBackend, readInsightsMemory, writeInsightsMemory, generateMemoryAudit, buildInsightExtractionPrompt, parseInsightExtractionResponse, processAndAuditInsightExtraction } from "@fusion/core";
import type { ServerOptions } from "./server.js";
import { GitHubClient, parseBadgeUrl } from "./github.js";
import { githubRateLimiter } from "./github-poll.js";
@@ -18562,7 +18562,10 @@ function registerAuthRoutes(router: Router, authStorage?: AuthStorageLike): void
* GET /api/auth/status
* Returns list of all providers with their authentication status and type.
* Includes both OAuth-backed and API-key-backed providers.
* Response: { providers: [{ id, name, authenticated, type, keyHint? }] }
* Response: {
* providers: [{ id, name, authenticated, type, keyHint? }],
* ghCli: { available: boolean, authenticated: boolean }
* }
*/
router.get("/auth/status", (_req, res) => {
try {
@@ -18599,7 +18602,12 @@ function registerAuthRoutes(router: Router, authStorage?: AuthStorageLike): void
}
}
res.json({ providers });
const ghCli = {
available: isGhAvailable(),
authenticated: isGhAuthenticated(),
};
res.json({ providers, ghCli });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;