From 72a6f8be48f9bb9d899ffca14ae2a1d519fc4c19 Mon Sep 17 00:00:00 2001 From: hhhjin Date: Thu, 19 Mar 2026 10:54:39 +0900 Subject: [PATCH 1/3] refactor: extract tui config controllers --- packages/tui/src/app.ts | 1665 ++--------------- packages/tui/src/config-auth-controller.ts | 727 +++++++ packages/tui/src/config-route-actions.ts | 493 +++++ packages/tui/src/config-runtime-controller.ts | 445 +++++ 4 files changed, 1850 insertions(+), 1480 deletions(-) create mode 100644 packages/tui/src/config-auth-controller.ts create mode 100644 packages/tui/src/config-route-actions.ts create mode 100644 packages/tui/src/config-runtime-controller.ts diff --git a/packages/tui/src/app.ts b/packages/tui/src/app.ts index dfcf938..0fdc6cb 100644 --- a/packages/tui/src/app.ts +++ b/packages/tui/src/app.ts @@ -1,58 +1,27 @@ import * as OpenTui from "@opentui/core"; +import type { AppPaths, SyncRuntimeEvent, UpdateStatus } from "@syncdown/core"; +import { EXIT_CODES, validateManagedOutputDirectory } from "@syncdown/core"; import type { - AppPaths, - GmailSyncFilter, - SyncIntervalPreset, - SyncRuntimeEvent, - SyncRuntimeSnapshot, - UpdateStatus, -} from "@syncdown/core"; -import { - collectGoogleProviderScopes, - DEFAULT_GOOGLE_CONNECTION_ID, - DEFAULT_GOOGLE_OAUTH_APP_ID, - EXIT_CODES, - getGoogleConnectionSecretNames, - getGoogleOAuthAppSecretNames, - validateManagedOutputDirectory, -} from "@syncdown/core"; -import type { - BrowserOpenResult, - GoogleAuthCredentials, GoogleAuthSession, NotionOAuthSession, TuiAuthService, } from "./auth.js"; import { createTuiAuthService } from "./auth.js"; +import { createConfigAuthController } from "./config-auth-controller.js"; +import { createConfigRouteActions } from "./config-route-actions.js"; +import { createConfigRuntimeController } from "./config-runtime-controller.js"; import type { ConfigTuiRequest } from "./index.js"; -import type { DraftState, OutputPresetAction } from "./state.js"; +import type { DraftState } from "./state.js"; import { - buildOutputPresetPaths, cloneDraftState, - collectDiagnostics, - getDraftIntegration, - getDraftSelectedGoogleCalendarIds, - hasAnyStoredCredentials, - isDraftConnectorEnabled, normalizeOutputPath, saveDraft, - setConnectorEnabled, - setGmailSyncFilter, setOutputDirectory, - setSelectedGoogleCalendarIds, - setSyncInterval, - stageConnectorDisconnect, - stageGoogleConnection, - stageNotionConnection, - stageNotionOAuthConnection, - stageProviderDisconnect, - stageStoredCredentialDisconnect, syncDraftState, } from "./state.js"; import type { ConfigUiState, ConnectorAuthRoute, - GoogleCalendarSelectionRoute, HomeRoute, SyncDashboardRoute, UpdateRoute, @@ -60,20 +29,7 @@ import type { import { clampRouteSelection, createConfigUiState, - createConfirmDisconnectRoute, - createConfirmResetRoute, - createConnectorAuthRoute, - createConnectorDetailsRoute, - createDiagnosticsRoute, - createGmailFilterRoute, - createGoogleCalendarSelectionRoute, - createIntervalRoute, - createOutputCustomRoute, - createSyncDashboardRoute, - createUpdateRoute, getBreadcrumb, - getConnectorAuthDocsUrl, - getCurrentAuthField, getCurrentRoute, getInputProps, getKeyHint, @@ -81,12 +37,10 @@ import { getRouteOptions, isInputRoute, popRoute, - pushRoute, setNotice, } from "./view-state.js"; const SECRET_MASK = "•"; -const GOOGLE_AUTH_TIMEOUT_MS = 5 * 60 * 1_000; const MAX_VISIBLE_SELECT_ITEMS = 5; const NOTICE_HEIGHT = 3; const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1_000; @@ -219,15 +173,6 @@ function createNoopUpdater(): NonNullable { }; } -function isSyncSnapshotBusy(snapshot: SyncRuntimeSnapshot): boolean { - return ( - snapshot.watch.active || - snapshot.integrations.some( - (integration) => integration.running || integration.queuedImmediateRun, - ) - ); -} - export class ConfigTuiApp { private readonly renderer: CliRenderer; private readonly request: ConfigTuiRequest; @@ -236,6 +181,13 @@ export class ConfigTuiApp { private readonly draft: DraftState; private readonly authService: TuiAuthService; private readonly ui: ConfigUiState; + private readonly authController: ReturnType< + typeof createConfigAuthController + >; + private readonly routeActions: ReturnType; + private readonly runtimeController: ReturnType< + typeof createConfigRuntimeController + >; private readonly headerBreadcrumb: TextRenderableLike; private readonly headerDivider: TextRenderableLike; private readonly bodyText: TextRenderableLike; @@ -271,6 +223,49 @@ export class ConfigTuiApp { supportsSelfUpdate ? null : SOURCE_UPDATE_REASON, options.request.docsBaseUrl ?? null, ); + this.authController = createConfigAuthController({ + ui: this.ui, + draft: this.draft, + paths: this.paths, + authService: this.authService, + refreshView: () => this.refreshView(), + persistDraftMutation: (mutate, failureFallback) => + this.persistDraftMutation(mutate, failureFallback), + inspectApp: () => this.request.app.inspect(), + getSecret: (name) => this.request.secrets.getSecret(name, this.paths), + getActiveAuthRun: () => this.activeAuthRun, + incrementActiveAuthRun: () => ++this.activeAuthRun, + getActiveBrowserAuthSession: () => this.activeBrowserAuthSession, + setActiveBrowserAuthSession: (session) => { + this.activeBrowserAuthSession = session; + }, + }); + this.runtimeController = createConfigRuntimeController({ + ui: this.ui, + draft: this.draft, + paths: this.paths, + request: this.request, + updater: this.updater, + refreshView: () => this.refreshView(), + runUpdateCheck: (showNoticeOnFailure) => + this.runUpdateCheck(showNoticeOnFailure), + finish: (code) => this.finish(code), + cancelAuthFlow: () => this.authController.cancelAuthFlow(), + }); + this.routeActions = createConfigRouteActions({ + ui: this.ui, + draft: this.draft, + getSyncSnapshot: () => this.request.session.getSnapshot(), + refreshView: () => this.refreshView(), + ensureGoogleScopesForConnector: (connector) => + this.authController.ensureGoogleScopesForConnector(connector), + persistDraftMutation: (mutate, failureFallback) => + this.persistDraftMutation(mutate, failureFallback), + persistOutputDirectory: (outputDir) => + this.persistOutputDirectory(outputDir), + refreshGoogleCalendarSelection: (route) => + this.authController.refreshGoogleCalendarSelection(route), + }); this.renderer.root.flexDirection = "column"; this.renderer.root.padding = 1; @@ -682,1477 +677,187 @@ export class ConfigTuiApp { ]; const selection = option?.value; - if (route.id === "home") { - if (selection === "sync") { - pushRoute( - this.ui, - createSyncDashboardRoute(this.request.session.getSnapshot()), - ); - } else if (selection === "connectors") { - pushRoute(this.ui, { id: "connectors", selectedIndex: 0 }); - } else if (selection === "output") { - pushRoute(this.ui, { id: "output", selectedIndex: 0 }); - } else if (selection === "schedule") { - pushRoute(this.ui, { id: "schedule", selectedIndex: 0 }); - } else if (selection === "advanced") { - pushRoute(this.ui, { id: "advanced", selectedIndex: 0 }); - } else if (selection === "update") { - pushRoute(this.ui, createUpdateRoute(this.getHomeRoute())); - } - this.refreshView(); - return; - } - - if (route.id === "syncDashboard") { - await this.activateSyncDashboardSelection(route, selection); - return; - } - - if (route.id === "connectors") { - if ( - selection === "notion" || - selection === "gmail" || - selection === "google-calendar" || - selection === "apple-notes" - ) { - pushRoute(this.ui, createConnectorDetailsRoute(selection)); - } - this.refreshView(); - return; - } - - if (route.id === "connectorDetails") { - if (selection === "connectToken" && route.connector === "notion") { - pushRoute( - this.ui, - createConnectorAuthRoute(route.connector, "notion-token"), - ); - } else if (selection === "connectOAuth" && route.connector === "notion") { - pushRoute( - this.ui, - createConnectorAuthRoute(route.connector, "notion-oauth"), - ); - } else if (selection === "connect") { - pushRoute( - this.ui, - createConnectorAuthRoute(route.connector, "google-oauth"), - ); - } else if (selection === "gmailFilter" && route.connector === "gmail") { - pushRoute(this.ui, createGmailFilterRoute()); - } else if ( - selection === "googleCalendarSelection" && - route.connector === "google-calendar" - ) { - const calendarRoute = createGoogleCalendarSelectionRoute( - getDraftSelectedGoogleCalendarIds(this.draft), - ); - pushRoute(this.ui, calendarRoute); - this.refreshView(); - await this.refreshGoogleCalendarSelection(calendarRoute); - } else if (selection === "enable" && route.connector === "gmail") { - const hasScopes = await this.ensureGoogleScopesForConnector("gmail"); - if (!hasScopes) { - return; - } - - const saved = await this.persistDraftMutation( - (draft) => setConnectorEnabled(draft, "gmail", true), - "Failed to enable Gmail.", - ); - if (saved) { - setNotice(this.ui, { - kind: "success", - text: "Gmail enabled.", - }); - } - } else if (selection === "enable" && route.connector === "apple-notes") { - const saved = await this.persistDraftMutation( - (draft) => setConnectorEnabled(draft, "apple-notes", true), - "Failed to enable Apple Notes.", - ); - if (saved) { - setNotice(this.ui, { - kind: "success", - text: "Apple Notes enabled.", - }); - } - } else if ( - selection === "enable" && - route.connector === "google-calendar" - ) { - const hasScopes = - await this.ensureGoogleScopesForConnector("google-calendar"); - if (!hasScopes) { - return; - } - - if (getDraftSelectedGoogleCalendarIds(this.draft).length === 0) { - const calendarRoute = createGoogleCalendarSelectionRoute([]); - pushRoute(this.ui, calendarRoute); - this.refreshView(); - await this.refreshGoogleCalendarSelection(calendarRoute); - return; - } - - const saved = await this.persistDraftMutation( - (draft) => setConnectorEnabled(draft, "google-calendar", true), - "Failed to enable Google Calendar.", - ); - if (saved) { - setNotice(this.ui, { - kind: "success", - text: "Google Calendar enabled.", - }); - } - } else if (selection === "disable") { - pushRoute( - this.ui, - createConfirmDisconnectRoute(route.connector, "connector"), - ); - } else if ( - selection === "disconnectProvider" && - (route.connector === "gmail" || route.connector === "google-calendar") - ) { - pushRoute( - this.ui, - createConfirmDisconnectRoute(route.connector, "provider", "google"), + switch (route.id) { + case "home": + this.routeActions.handleHomeSelection(route, selection); + return; + case "syncDashboard": + await this.activateSyncDashboardSelection(route, selection); + return; + case "connectors": + this.routeActions.handleConnectorsSelection(route, selection); + return; + case "connectorDetails": + await this.routeActions.handleConnectorDetailsSelection( + route, + selection, ); - } else if (selection === "disconnect") { - if ( - !hasAnyStoredCredentials(this.draft, route.connector) && - !isDraftConnectorEnabled(this.draft, route.connector) - ) { - setNotice(this.ui, { - kind: "error", - text: "Connector is already disconnected.", - }); - this.refreshView(); - return; - } - pushRoute( - this.ui, - createConfirmDisconnectRoute(route.connector, "connector"), + return; + case "connectorAuth": + await this.activateAuthSelection(route, selection); + return; + case "confirmDisconnect": + await this.routeActions.handleConfirmDisconnectSelection( + route, + selection, ); - } - this.refreshView(); - return; - } - - if (route.id === "connectorAuth") { - await this.activateAuthSelection(route, selection); - return; - } - - if (route.id === "confirmDisconnect") { - if (selection === "cancel") { - popRoute(this.ui); - this.refreshView(); return; - } - - const connector = route.connector; - const disconnectLabel = - route.mode === "provider" - ? route.provider === "notion" - ? "Notion OAuth account" - : "Google account" - : connector === "notion" - ? "Notion" - : connector === "gmail" - ? "Gmail" - : connector === "google-calendar" - ? "Google Calendar" - : "Apple Notes"; - const saved = await this.persistDraftMutation((draft) => { - if (route.mode === "provider") { - stageProviderDisconnect(draft, route.provider ?? "google"); - return; - } - - if (connector === "notion") { - stageStoredCredentialDisconnect(draft, connector); - return; - } - - stageConnectorDisconnect(draft, connector); - }, `Failed to disconnect ${disconnectLabel}.`); - if (saved) { - popRoute(this.ui); - setNotice(this.ui, { - kind: "success", - text: - route.mode === "provider" - ? route.provider === "notion" - ? "Notion OAuth account disconnected." - : "Google account disconnected." - : connector === "notion" - ? "Notion disconnected." - : connector === "gmail" - ? "Gmail disabled." - : connector === "google-calendar" - ? "Google Calendar disabled." - : "Apple Notes disabled.", - }); - this.refreshView(); - } - return; - } - - if (route.id === "output") { - if (selection === "custom") { - pushRoute(this.ui, createOutputCustomRoute(this.draft)); - this.refreshView(); + case "output": + await this.routeActions.handleOutputSelection(route, selection); return; - } - - const preset = selection as - | Exclude - | undefined; - if (!preset) { + case "outputCustom": return; + case "schedule": + this.routeActions.handleScheduleSelection(route, selection); + return; + case "interval": + await this.routeActions.handleIntervalSelection(route, selection); + return; + case "gmailFilter": + await this.routeActions.handleGmailFilterSelection(route, selection); + return; + case "googleCalendarSelection": + await this.routeActions.handleGoogleCalendarSelection(route, selection); + return; + case "advanced": + await this.runtimeController.handleAdvancedSelection(selection); + return; + case "confirmReset": + await this.runtimeController.handleConfirmResetSelection(selection); + return; + case "update": + await this.runtimeController.activateUpdateSelection(route, selection); + return; + case "diagnostics": + await this.runtimeController.handleDiagnosticsSelection(selection); + return; + default: { + const exhaustiveRoute: never = route; + return exhaustiveRoute; } - - const presetPaths = buildOutputPresetPaths(); - const saved = await this.persistOutputDirectory(presetPaths[preset]); - if (saved) { - setNotice(this.ui, { - kind: "success", - text: "Output directory saved.", - }); - this.refreshView(); - } - return; } + } - if (route.id === "schedule") { - if ( - selection === "notion" || - selection === "gmail" || - selection === "google-calendar" || - selection === "apple-notes" - ) { - pushRoute(this.ui, createIntervalRoute(selection)); - } - this.refreshView(); - return; - } + private async activateSyncDashboardSelection( + route: SyncDashboardRoute, + selection: unknown, + ): Promise { + await this.runtimeController.activateSyncDashboardSelection( + route, + selection, + ); + } - if (route.id === "interval") { - const interval = selection as SyncIntervalPreset | undefined; - if (!interval) { - return; - } + private async activateAuthSelection( + route: ConnectorAuthRoute, + selection: unknown, + ): Promise { + await this.authController.activateAuthSelection(route, selection); + } - const connector = route.connector; - const saved = await this.persistDraftMutation( - (draft) => setSyncInterval(draft, connector, interval), - `Failed to save the ${connector === "notion" ? "Notion" : connector === "gmail" ? "Gmail" : connector === "google-calendar" ? "Google Calendar" : "Apple Notes"} interval.`, - ); - if (saved) { - popRoute(this.ui); + private async submitInput(): Promise { + const route = getCurrentRoute(this.ui); + + if (route.id === "outputCustom") { + const value = route.value.trim(); + if (!value) { + route.error = "Output directory is required."; setNotice(this.ui, { - kind: "success", - text: `${connector === "notion" ? "Notion" : connector === "gmail" ? "Gmail" : connector === "google-calendar" ? "Google Calendar" : "Apple Notes"} interval saved.`, + kind: "error", + text: route.error, }); this.refreshView(); - } - return; - } - - if (route.id === "gmailFilter") { - const syncFilter = selection as GmailSyncFilter | undefined; - if (!syncFilter) { return; } - const saved = await this.persistDraftMutation( - (draft) => setGmailSyncFilter(draft, syncFilter), - "Failed to save the Gmail inbox filter.", - ); + const saved = await this.persistOutputDirectory(value, route); if (saved) { popRoute(this.ui); setNotice(this.ui, { kind: "success", - text: "Gmail inbox filter saved. Run Gmail again to apply the new scope.", + text: "Output directory saved.", }); this.refreshView(); } return; } - if (route.id === "googleCalendarSelection") { - if (selection === "refresh") { - await this.refreshGoogleCalendarSelection(route); - return; - } - - if (selection === "save") { - const selectedCalendarIds = [...route.selectedCalendarIds]; - const saved = await this.persistDraftMutation( - (draft) => setSelectedGoogleCalendarIds(draft, selectedCalendarIds), - "Failed to save selected Google calendars.", - ); - if (saved) { - popRoute(this.ui); - setNotice(this.ui, { - kind: "success", - text: "Google Calendar selection saved.", - }); - this.refreshView(); - } - return; - } - - if ( - selection && - typeof selection === "object" && - "kind" in selection && - (selection as { kind?: string }).kind === "toggleCalendar" - ) { - const calendarId = (selection as unknown as { calendarId: string }) - .calendarId; - route.selectedCalendarIds = route.selectedCalendarIds.includes( - calendarId, - ) - ? route.selectedCalendarIds.filter((id) => id !== calendarId) - : [...route.selectedCalendarIds, calendarId]; - this.refreshView(); - } + if (route.id !== "connectorAuth" || route.stage !== "collect-input") { return; } + await this.authController.submitConnectorAuthInput(route); + } - if (route.id === "advanced") { - if (selection === "diagnostics") { - pushRoute(this.ui, createDiagnosticsRoute(this.paths, this.draft)); - this.refreshView(); - await this.refreshDiagnostics(); - } else if (selection === "resetAppData") { - if (isSyncSnapshotBusy(this.request.session.getSnapshot())) { - setNotice(this.ui, { - kind: "error", - text: "Stop the current sync before resetting app data.", - }); - this.refreshView(); - return; - } - - pushRoute(this.ui, createConfirmResetRoute()); - this.refreshView(); - } - return; - } + private async retryAuthFlow(route: ConnectorAuthRoute): Promise { + await this.authController.retryAuthFlow(route); + } - if (route.id === "confirmReset") { - if (selection === "cancel") { - popRoute(this.ui); - this.refreshView(); - return; - } + private async cancelAuthFlow(): Promise { + await this.authController.cancelAuthFlow(); + } - if (selection !== "reset") { - return; - } + private async refreshDiagnostics(): Promise { + await this.runtimeController.refreshDiagnostics(); + } - if (isSyncSnapshotBusy(this.request.session.getSnapshot())) { - setNotice(this.ui, { - kind: "error", - text: "Stop the current sync before resetting app data.", - }); - popRoute(this.ui); - this.refreshView(); - return; + private async persistOutputDirectory( + outputDir: string, + route?: { error: string | null }, + ): Promise { + const normalizedOutputDir = normalizeOutputPath(outputDir); + const validationError = + await this.validateOutputDirectory(normalizedOutputDir); + if (validationError) { + if (route) { + route.error = validationError; } - - const writes: string[] = []; - const errors: string[] = []; - await this.request.session.dispose(); - const exitCode = await this.request.app.reset({ - write(line) { - writes.push(line); - }, - error(line) { - errors.push(line); - }, + setNotice(this.ui, { + kind: "error", + text: validationError, }); - - if (exitCode !== EXIT_CODES.OK) { - this.finish(exitCode); - for (const line of errors.length > 0 - ? errors - : ["Failed to reset app data."]) { - this.request.io.error(line); - } - return; - } - - this.finish(EXIT_CODES.OK); - for (const line of writes) { - this.request.io.write(line); - } - return; + this.refreshView(); + return false; } - if (route.id === "update") { - await this.activateUpdateSelection(route, selection); - return; + if (route) { + route.error = null; } - if (route.id === "diagnostics") { - if (selection === "refresh") { - await this.refreshDiagnostics(); - } - } + return this.persistDraftMutation( + (draft) => setOutputDirectory(draft, normalizedOutputDir), + "Failed to save output directory.", + ); } - private async activateUpdateSelection( - route: UpdateRoute, - selection: unknown, - ): Promise { - if (route.installBusy) { - return; - } - - if (selection === "checkNow") { - await this.runUpdateCheck(true); - return; - } - - if (selection !== "installUpdate") { - return; - } + private async validateOutputDirectory( + outputDir: string, + ): Promise { + return validateManagedOutputDirectory(outputDir); + } - route.installBusy = true; - setNotice(this.ui, null); - this.refreshView(); + private async persistDraftMutation( + mutate: (draft: DraftState) => void, + failureFallback: string, + ): Promise { + const next = cloneDraftState(this.draft); + mutate(next); try { - const result = await this.updater.applyUpdate(); - setNotice(this.ui, { - kind: "success", - text: result.message, - }); + await saveDraft(this.paths, this.request.secrets, next); + syncDraftState(this.draft, next); + return true; } catch (error) { setNotice(this.ui, { kind: "error", - text: - error instanceof Error ? error.message : "Unknown update failure.", + text: error instanceof Error ? error.message : failureFallback, }); - } finally { - route.installBusy = false; this.refreshView(); - } - } - - private async activateSyncDashboardSelection( - route: SyncDashboardRoute, - selection: unknown, - ): Promise { - if (route.busy) { - if (selection === "cancelActiveRun" && !route.cancelPending) { - route.cancelPending = true; - setNotice(this.ui, { - kind: "success", - text: "Cancelling sync...", - }); - this.refreshView(); - void this.request.session.cancelActiveRun().catch((error) => { - route.cancelPending = false; - setNotice(this.ui, { - kind: "error", - text: - error instanceof Error - ? error.message - : "Unknown sync action failure.", - }); - this.refreshView(); - }); - } - return; - } - - const hasActiveSync = - route.snapshot.watch.active || - route.snapshot.integrations.some( - (integration) => integration.running || integration.queuedImmediateRun, - ); - if (hasActiveSync && selection !== "cancelActiveRun") { - setNotice(this.ui, { - kind: "error", - text: "Stop the current sync before using other actions.", - }); - this.refreshView(); - return; - } - - if (selection === "clearLog") { - route.clearedAfter = - route.snapshot.logs.at(-1)?.timestamp ?? route.clearedAfter; - this.refreshView(); - return; - } - - if (selection === "toggleDetailedLogs") { - route.showDetailedLogs = !route.showDetailedLogs; - this.refreshView(); - return; - } - - route.busy = true; - route.cancelPending = false; - setNotice(this.ui, null); - this.refreshView(); - - try { - if (selection === "cancelActiveRun") { - if (route.snapshot.watch.active) { - await this.request.session.cancelActiveRun(); - await this.request.session.stopWatch(); - setNotice(this.ui, { - kind: "success", - text: "Sync stopped.", - }); - } else { - await this.request.session.cancelActiveRun(); - setNotice(this.ui, { - kind: "success", - text: "Sync cancelled.", - }); - } - } else if (selection === "startWatch") { - await this.request.session.startWatch({ kind: "per-integration" }); - setNotice(this.ui, { - kind: "success", - text: "Watch started.", - }); - } else if (selection === "stopWatch") { - await this.request.session.stopWatch(); - setNotice(this.ui, { - kind: "success", - text: "Watch stopped.", - }); - } else if (selection === "runAll") { - await this.request.session.runNow({ kind: "all" }); - this.setSyncRunNotice("Run completed."); - } else if (selection === "runAllReset") { - await this.request.session.runNow( - { kind: "all" }, - { resetState: true }, - ); - this.setSyncRunNotice("Full resync completed."); - } else if (selection === "runNotion") { - await this.request.session.runNow({ - kind: "integration", - integrationId: getDraftIntegration(this.draft, "notion").id, - }); - this.setSyncRunNotice("Notion run completed."); - } else if (selection === "runNotionReset") { - await this.request.session.runNow( - { - kind: "integration", - integrationId: getDraftIntegration(this.draft, "notion").id, - }, - { resetState: true }, - ); - this.setSyncRunNotice("Notion full resync completed."); - } else if (selection === "runGmail") { - await this.request.session.runNow({ - kind: "integration", - integrationId: getDraftIntegration(this.draft, "gmail").id, - }); - this.setSyncRunNotice("Gmail run completed."); - } else if (selection === "runGmailReset") { - await this.request.session.runNow( - { - kind: "integration", - integrationId: getDraftIntegration(this.draft, "gmail").id, - }, - { resetState: true }, - ); - this.setSyncRunNotice("Gmail full resync completed."); - } else if (selection === "runGoogleCalendar") { - await this.request.session.runNow({ - kind: "integration", - integrationId: getDraftIntegration(this.draft, "google-calendar").id, - }); - this.setSyncRunNotice("Google Calendar run completed."); - } else if (selection === "runGoogleCalendarReset") { - await this.request.session.runNow( - { - kind: "integration", - integrationId: getDraftIntegration(this.draft, "google-calendar") - .id, - }, - { resetState: true }, - ); - this.setSyncRunNotice("Google Calendar full resync completed."); - } else if (selection === "runAppleNotes") { - await this.request.session.runNow({ - kind: "integration", - integrationId: getDraftIntegration(this.draft, "apple-notes").id, - }); - this.setSyncRunNotice("Apple Notes run completed."); - } else if (selection === "runAppleNotesReset") { - await this.request.session.runNow( - { - kind: "integration", - integrationId: getDraftIntegration(this.draft, "apple-notes").id, - }, - { resetState: true }, - ); - this.setSyncRunNotice("Apple Notes full resync completed."); - } - } catch (error) { - setNotice(this.ui, { - kind: "error", - text: - error instanceof Error - ? error.message - : "Unknown sync action failure.", - }); - } finally { - route.busy = false; - route.cancelPending = false; - route.snapshot = this.request.session.getSnapshot(); - this.refreshView(); - } - } - - private setSyncRunNotice(successText: string): void { - const snapshot = this.request.session.getSnapshot(); - if (snapshot.lastRunError === "Sync cancelled by user.") { - setNotice(this.ui, { - kind: "success", - text: "Sync cancelled.", - }); - return; - } - - if (snapshot.lastRunExitCode === EXIT_CODES.OK) { - setNotice(this.ui, { - kind: "success", - text: successText, - }); - return; - } - - setNotice(this.ui, { - kind: "error", - text: - snapshot.lastRunError ?? - `Sync failed with exit code ${snapshot.lastRunExitCode ?? "unknown"}.`, - }); - } - - private async activateAuthSelection( - route: ConnectorAuthRoute, - selection: unknown, - ): Promise { - if (route.stage === "intro") { - if (selection === "cancel") { - popRoute(this.ui); - this.refreshView(); - return; - } - - if (selection === "openDocs") { - const docsUrl = getConnectorAuthDocsUrl(route, this.ui.docsBaseUrl); - if (!docsUrl) { - setNotice(this.ui, { - kind: "error", - text: "Docs link is unavailable for this auth flow.", - }); - this.refreshView(); - return; - } - - const browserResult = await this.authService.openUrl(docsUrl); - setNotice( - this.ui, - browserResult.opened - ? { - kind: "success", - text: "Connector docs opened in your browser.", - } - : { - kind: "error", - text: - browserResult.error ?? - "Failed to open the connector docs in your browser.", - }, - ); - this.refreshView(); - return; - } - - if (route.authMethod === "notion-token") { - await this.startNotionSetup(route); - } else if (route.authMethod === "notion-oauth") { - await this.openOAuthSetupPage(route, () => - this.authService.openNotionOAuthSetup(), - ); - } else { - await this.openOAuthSetupPage(route, () => - this.authService.openGoogleOAuthSetup(), - ); - } - return; - } - - if (route.stage === "success") { - const message = - this.ui.notice?.kind === "success" - ? this.ui.notice.text - : `${route.connector} connected.`; - popRoute(this.ui); - setNotice(this.ui, { - kind: "success", - text: message, - }); - this.refreshView(); - return; - } - - if (selection === "cancel") { - await this.cancelAuthFlow(); - return; - } - - if (route.stage === "error" && selection === "retry") { - await this.retryAuthFlow(route); - } - } - - private async submitInput(): Promise { - const route = getCurrentRoute(this.ui); - - if (route.id === "outputCustom") { - const value = route.value.trim(); - if (!value) { - route.error = "Output directory is required."; - setNotice(this.ui, { - kind: "error", - text: route.error, - }); - this.refreshView(); - return; - } - - const saved = await this.persistOutputDirectory(value, route); - if (saved) { - popRoute(this.ui); - setNotice(this.ui, { - kind: "success", - text: "Output directory saved.", - }); - this.refreshView(); - } - return; - } - - if (route.id !== "connectorAuth" || route.stage !== "collect-input") { - return; - } - - const field = getCurrentAuthField(route); - const value = route.inputValue.trim(); - if (!value) { - route.error = `${field.label} is required.`; - setNotice(this.ui, { - kind: "error", - text: route.error, - }); - this.refreshView(); - return; - } - - route.values[field.key] = value; - route.error = null; - setNotice(this.ui, null); - - if ( - (route.authMethod === "google-oauth" || - route.authMethod === "notion-oauth") && - route.fieldIndex === 0 - ) { - route.fieldIndex = 1; - route.inputValue = - route.authMethod === "notion-oauth" - ? (route.values.notionOauthClientSecret ?? "") - : (route.values.googleClientSecret ?? ""); - this.refreshView(); - return; - } - - if (route.authMethod === "notion-token") { - await this.validateNotionToken(route, value); - return; - } - - if (route.authMethod === "notion-oauth") { - const clientId = route.values.notionOauthClientId ?? ""; - const clientSecret = value; - await this.runNotionOAuthConnectFlow(route, clientId, clientSecret); - return; - } - - const clientId = route.values.googleClientId ?? ""; - const clientSecret = value; - await this.runGoogleConnectFlow(route, clientId, clientSecret); - } - - private async startNotionSetup(route: ConnectorAuthRoute): Promise { - const runId = ++this.activeAuthRun; - route.stage = "opening-browser"; - route.error = null; - route.selectedIndex = 0; - this.refreshView(); - - const browserResult = await this.authService.openNotionSetup(); - if (!this.isAuthRouteActive(route, runId)) { - return; - } - - route.browserOpened = browserResult.opened; - route.browserError = browserResult.error ?? null; - route.stage = "collect-input"; - route.fieldIndex = 0; - route.inputValue = route.values.notionToken ?? ""; - route.error = null; - this.refreshView(); - } - - private async openOAuthSetupPage( - route: ConnectorAuthRoute, - openSetupPage: () => Promise, - ): Promise { - route.stage = "opening-browser"; - route.error = null; - route.selectedIndex = 0; - this.refreshView(); - - const browserResult = await openSetupPage(); - if (getCurrentRoute(this.ui) !== route) { - return; - } - - route.browserOpened = browserResult.opened; - route.browserError = browserResult.error ?? null; - route.stage = "collect-input"; - route.fieldIndex = 0; - route.inputValue = - route.authMethod === "notion-oauth" - ? (route.values.notionOauthClientId ?? "") - : (route.values.googleClientId ?? ""); - route.error = null; - route.selectedIndex = 0; - this.refreshView(); - } - - private async validateNotionToken( - route: ConnectorAuthRoute, - token: string, - ): Promise { - const runId = ++this.activeAuthRun; - route.stage = "validating"; - route.error = null; - route.selectedIndex = 0; - this.refreshView(); - - try { - await this.authService.validateNotionToken(this.paths, token); - if (!this.isAuthRouteActive(route, runId)) { - return; - } - - const saved = await this.persistDraftMutation( - (draft) => stageNotionConnection(draft, token), - "Failed to save Notion credentials.", - ); - if (!saved || !this.isAuthRouteActive(route, runId)) { - route.stage = "collect-input"; - route.error = - this.ui.notice?.kind === "error" - ? this.ui.notice.text - : "Failed to save Notion credentials."; - this.refreshView(); - return; - } - - route.stage = "success"; - route.error = null; - route.selectedIndex = 0; - setNotice(this.ui, { - kind: "success", - text: "Notion connected.", - }); - this.refreshView(); - } catch (error) { - if (!this.isAuthRouteActive(route, runId)) { - return; - } - route.stage = "collect-input"; - route.error = - error instanceof Error - ? error.message - : "Unknown Notion validation failure."; - setNotice(this.ui, { - kind: "error", - text: route.error, - }); - this.refreshView(); - } - } - - private async getRequiredGoogleScopes( - connectorId: "gmail" | "google-calendar", - ): Promise { - const snapshot = await this.request.app.inspect(); - const integrations = snapshot.integrations.map((integration) => ({ - ...integration, - enabled: - integration.connectorId === "notion" - ? isDraftConnectorEnabled(this.draft, "notion") - : integration.connectorId === "gmail" - ? isDraftConnectorEnabled(this.draft, "gmail") - : integration.connectorId === "google-calendar" - ? isDraftConnectorEnabled(this.draft, "google-calendar") - : integration.enabled, - })); - return collectGoogleProviderScopes(integrations, { - includeIds: [ - snapshot.integrations.find( - (integration) => integration.connectorId === connectorId, - )?.id ?? getDraftIntegration(this.draft, connectorId).id, - ], - }); - } - - private async getCurrentGoogleOAuthAppCredentials(): Promise<{ - clientId: string; - clientSecret: string; - } | null> { - const clientId = - this.draft.googleClientId.action === "set" - ? this.draft.googleClientId.value - : this.draft.googleClientId.action === "delete" - ? null - : await this.request.secrets.getSecret( - getGoogleOAuthAppSecretNames(DEFAULT_GOOGLE_OAUTH_APP_ID) - .clientId, - this.paths, - ); - const clientSecret = - this.draft.googleClientSecret.action === "set" - ? this.draft.googleClientSecret.value - : this.draft.googleClientSecret.action === "delete" - ? null - : await this.request.secrets.getSecret( - getGoogleOAuthAppSecretNames(DEFAULT_GOOGLE_OAUTH_APP_ID) - .clientSecret, - this.paths, - ); - if (!clientId || !clientSecret) { - return null; - } - - return { - clientId, - clientSecret, - }; - } - - private async getCurrentGoogleCredentials(): Promise { - const oauthAppCredentials = - await this.getCurrentGoogleOAuthAppCredentials(); - const refreshToken = - this.draft.googleRefreshToken.action === "set" - ? this.draft.googleRefreshToken.value - : this.draft.googleRefreshToken.action === "delete" - ? null - : await this.request.secrets.getSecret( - getGoogleConnectionSecretNames(DEFAULT_GOOGLE_CONNECTION_ID) - .refreshToken, - this.paths, - ); - - if (!oauthAppCredentials || !refreshToken) { - return null; - } - - return { - clientId: oauthAppCredentials.clientId, - clientSecret: oauthAppCredentials.clientSecret, - refreshToken, - }; - } - - private async ensureGoogleScopesForConnector( - connector: "gmail" | "google-calendar", - ): Promise { - const credentials = await this.getCurrentGoogleCredentials(); - const requiredScopes = await this.getRequiredGoogleScopes(connector); - if (credentials) { - try { - await this.authService.validateGoogleCredentials( - this.paths, - credentials, - requiredScopes, - ); - return true; - } catch (error) { - const message = - error instanceof Error - ? error.message - : "Google account is missing required scopes."; - setNotice(this.ui, { - kind: "error", - text: message, - }); - } - } else { - setNotice(this.ui, { - kind: "error", - text: "Google account setup is incomplete. Reconnect to continue.", - }); - } - - const authRoute = createConnectorAuthRoute(connector, "google-oauth"); - pushRoute(this.ui, authRoute); - const oauthAppCredentials = - await this.getCurrentGoogleOAuthAppCredentials(); - if (oauthAppCredentials) { - authRoute.values.googleClientId = oauthAppCredentials.clientId; - authRoute.values.googleClientSecret = oauthAppCredentials.clientSecret; - await this.runGoogleConnectFlow( - authRoute, - oauthAppCredentials.clientId, - oauthAppCredentials.clientSecret, - ); - if ( - getCurrentRoute(this.ui) !== authRoute || - authRoute.stage !== "success" - ) { - return false; - } - - popRoute(this.ui); - this.refreshView(); - return true; - } - - await this.openOAuthSetupPage(authRoute, () => - this.authService.openGoogleOAuthSetup(), - ); - return false; - } - - private async refreshGoogleCalendarSelection( - route: GoogleCalendarSelectionRoute, - ): Promise { - route.loading = true; - route.error = null; - this.refreshView(); - - try { - const credentials = await this.getCurrentGoogleCredentials(); - if (!credentials) { - throw new Error("Connect a Google account before selecting calendars."); - } - - if (!this.authService.listGoogleCalendars) { - throw new Error("Google calendar listing is unavailable."); - } - const calendars = await this.authService.listGoogleCalendars(credentials); - route.calendars = calendars; - route.selectedCalendarIds = route.selectedCalendarIds.filter((id) => - calendars.some((calendar) => calendar.id === id), - ); - route.loading = false; - route.error = null; - this.refreshView(); - } catch (error) { - route.loading = false; - route.error = - error instanceof Error - ? error.message - : "Failed to load Google calendars."; - this.refreshView(); - } - } - - private async runGoogleConnectFlow( - route: ConnectorAuthRoute, - clientId: string, - clientSecret: string, - ): Promise { - const runId = ++this.activeAuthRun; - route.stage = "opening-browser"; - route.error = null; - route.selectedIndex = 0; - route.inputValue = ""; - this.refreshView(); - - try { - const requiredScopes = await this.getRequiredGoogleScopes( - route.connector === "google-calendar" ? "google-calendar" : "gmail", - ); - const session = await this.authService.startGoogleSession( - clientId, - clientSecret, - requiredScopes, - ); - if (!this.isAuthRouteActive(route, runId)) { - await session.cancel(); - return; - } - - this.activeBrowserAuthSession = session; - route.stage = "waiting-callback"; - route.authUrl = session.authorizationUrl; - route.browserOpened = session.browserOpened; - route.browserError = session.browserError ?? null; - route.selectedIndex = 0; - this.refreshView(); - - const tokenResult = await session.complete(GOOGLE_AUTH_TIMEOUT_MS); - this.activeBrowserAuthSession = null; - if (!this.isAuthRouteActive(route, runId)) { - return; - } - - route.stage = "validating"; - this.refreshView(); - - const credentials: GoogleAuthCredentials = { - clientId, - clientSecret, - refreshToken: tokenResult.refreshToken, - }; - await this.authService.validateGoogleCredentials( - this.paths, - credentials, - requiredScopes, - ); - if (!this.isAuthRouteActive(route, runId)) { - return; - } - - const saved = await this.persistDraftMutation( - (draft) => - stageGoogleConnection( - draft, - clientId, - clientSecret, - tokenResult.refreshToken, - route.connector === "google-calendar" ? "google-calendar" : "gmail", - ), - "Failed to save Google account credentials.", - ); - if (!saved || !this.isAuthRouteActive(route, runId)) { - route.stage = "error"; - route.error = - this.ui.notice?.kind === "error" - ? this.ui.notice.text - : "Failed to save Google account credentials."; - this.refreshView(); - return; - } - - route.stage = "success"; - route.error = null; - route.selectedIndex = 0; - setNotice(this.ui, { - kind: "success", - text: "Google account connected.", - }); - this.refreshView(); - } catch (error) { - this.activeBrowserAuthSession = null; - if (!this.isAuthRouteActive(route, runId)) { - return; - } - route.stage = "error"; - route.error = - error instanceof Error - ? error.message - : "Unknown Google connection failure."; - route.selectedIndex = 0; - setNotice(this.ui, { - kind: "error", - text: route.error, - }); - this.refreshView(); - } - } - - private async runNotionOAuthConnectFlow( - route: ConnectorAuthRoute, - clientId: string, - clientSecret: string, - ): Promise { - const runId = ++this.activeAuthRun; - route.stage = "opening-browser"; - route.error = null; - route.selectedIndex = 0; - route.inputValue = ""; - this.refreshView(); - - try { - const session = await this.authService.startNotionOAuthSession( - clientId, - clientSecret, - ); - if (!this.isAuthRouteActive(route, runId)) { - await session.cancel(); - return; - } - - this.activeBrowserAuthSession = session; - route.stage = "waiting-callback"; - route.authUrl = session.authorizationUrl; - route.browserOpened = session.browserOpened; - route.browserError = session.browserError ?? null; - route.selectedIndex = 0; - this.refreshView(); - - const tokenResult = await session.complete(GOOGLE_AUTH_TIMEOUT_MS); - this.activeBrowserAuthSession = null; - if (!this.isAuthRouteActive(route, runId)) { - return; - } - - route.stage = "validating"; - this.refreshView(); - - await this.authService.validateNotionOAuthAccessToken( - this.paths, - tokenResult.accessToken, - ); - if (!this.isAuthRouteActive(route, runId)) { - return; - } - - const saved = await this.persistDraftMutation( - (draft) => - stageNotionOAuthConnection( - draft, - clientId, - clientSecret, - tokenResult.refreshToken, - { - workspaceId: tokenResult.workspaceId, - workspaceName: tokenResult.workspaceName, - botId: tokenResult.botId, - ownerUserId: tokenResult.ownerUserId, - ownerUserName: tokenResult.ownerUserName, - }, - ), - "Failed to save Notion OAuth credentials.", - ); - if (!saved || !this.isAuthRouteActive(route, runId)) { - route.stage = "error"; - route.error = - this.ui.notice?.kind === "error" - ? this.ui.notice.text - : "Failed to save Notion OAuth credentials."; - this.refreshView(); - return; - } - - route.stage = "success"; - route.error = null; - route.selectedIndex = 0; - setNotice(this.ui, { - kind: "success", - text: "Notion OAuth account connected.", - }); - this.refreshView(); - } catch (error) { - this.activeBrowserAuthSession = null; - if (!this.isAuthRouteActive(route, runId)) { - return; - } - route.stage = "error"; - route.error = - error instanceof Error - ? error.message - : "Unknown Notion OAuth connection failure."; - route.selectedIndex = 0; - setNotice(this.ui, { - kind: "error", - text: route.error, - }); - this.refreshView(); - } - } - - private async retryAuthFlow(route: ConnectorAuthRoute): Promise { - route.error = null; - route.authUrl = undefined; - route.browserOpened = undefined; - route.browserError = null; - route.selectedIndex = 0; - setNotice(this.ui, null); - - if (route.authMethod === "notion-token") { - route.stage = "intro"; - this.refreshView(); - return; - } - - route.stage = "collect-input"; - route.fieldIndex = 0; - route.inputValue = - route.authMethod === "notion-oauth" - ? (route.values.notionOauthClientId ?? "") - : (route.values.googleClientId ?? ""); - this.refreshView(); - } - - private async cancelAuthFlow(): Promise { - this.activeAuthRun += 1; - const session = this.activeBrowserAuthSession; - this.activeBrowserAuthSession = null; - if (session) { - await session.cancel().catch(() => {}); - } - - popRoute(this.ui); - this.refreshView(); - } - - private isAuthRouteActive(route: ConnectorAuthRoute, runId: number): boolean { - return runId === this.activeAuthRun && getCurrentRoute(this.ui) === route; - } - - private async refreshDiagnostics(): Promise { - const route = getCurrentRoute(this.ui); - if (route.id !== "diagnostics") { - return; - } - - route.loading = true; - setNotice(this.ui, null); - this.refreshView(); - - try { - const diagnostics = await collectDiagnostics( - this.request.app, - this.request.io, - this.paths, - this.draft, - ); - if (getCurrentRoute(this.ui) !== route) { - return; - } - - route.loading = false; - route.title = diagnostics.title; - route.body = diagnostics.body; - this.refreshView(); - } catch (error) { - if (getCurrentRoute(this.ui) !== route) { - return; - } - - route.loading = false; - setNotice(this.ui, { - kind: "error", - text: - error instanceof Error - ? error.message - : "Unknown diagnostics failure.", - }); - this.refreshView(); - } - } - - private async persistOutputDirectory( - outputDir: string, - route?: { error: string | null }, - ): Promise { - const normalizedOutputDir = normalizeOutputPath(outputDir); - const validationError = - await this.validateOutputDirectory(normalizedOutputDir); - if (validationError) { - if (route) { - route.error = validationError; - } - setNotice(this.ui, { - kind: "error", - text: validationError, - }); - this.refreshView(); - return false; - } - - if (route) { - route.error = null; - } - - return this.persistDraftMutation( - (draft) => setOutputDirectory(draft, normalizedOutputDir), - "Failed to save output directory.", - ); - } - - private async validateOutputDirectory( - outputDir: string, - ): Promise { - return validateManagedOutputDirectory(outputDir); - } - - private async persistDraftMutation( - mutate: (draft: DraftState) => void, - failureFallback: string, - ): Promise { - const next = cloneDraftState(this.draft); - mutate(next); - - try { - await saveDraft(this.paths, this.request.secrets, next); - syncDraftState(this.draft, next); - return true; - } catch (error) { - setNotice(this.ui, { - kind: "error", - text: error instanceof Error ? error.message : failureFallback, - }); - this.refreshView(); - return false; + return false; } } private async handleBack(): Promise { - const route = getCurrentRoute(this.ui); - if (route.id === "home") { - return; - } - - if ( - route.id === "syncDashboard" && - (route.busy || - route.snapshot.watch.active || - route.snapshot.integrations.some( - (integration) => - integration.running || integration.queuedImmediateRun, - )) - ) { - setNotice(this.ui, { - kind: "error", - text: "Stop the current sync before leaving the sync dashboard.", - }); - this.refreshView(); - return; - } - - if (route.id === "connectorAuth") { - await this.cancelAuthFlow(); - return; - } - - popRoute(this.ui); - this.refreshView(); + await this.runtimeController.handleBack(); } private async exitApp(): Promise { diff --git a/packages/tui/src/config-auth-controller.ts b/packages/tui/src/config-auth-controller.ts new file mode 100644 index 0000000..aeb9579 --- /dev/null +++ b/packages/tui/src/config-auth-controller.ts @@ -0,0 +1,727 @@ +import type { AppPaths } from "@syncdown/core"; +import { + collectGoogleProviderScopes, + DEFAULT_GOOGLE_CONNECTION_ID, + DEFAULT_GOOGLE_OAUTH_APP_ID, + getGoogleConnectionSecretNames, + getGoogleOAuthAppSecretNames, +} from "@syncdown/core"; +import type { + BrowserOpenResult, + GoogleAuthCredentials, + GoogleAuthSession, + NotionOAuthSession, + TuiAuthService, +} from "./auth.js"; +import type { ConfigTuiRequest } from "./index.js"; +import type { DraftState } from "./state.js"; +import { + getDraftIntegration, + isDraftConnectorEnabled, + stageGoogleConnection, + stageNotionConnection, + stageNotionOAuthConnection, +} from "./state.js"; +import type { + ConfigUiState, + ConnectorAuthRoute, + GoogleCalendarSelectionRoute, +} from "./view-state.js"; +import { + createConnectorAuthRoute, + getConnectorAuthDocsUrl, + getCurrentAuthField, + getCurrentRoute, + popRoute, + pushRoute, + setNotice, +} from "./view-state.js"; + +const GOOGLE_AUTH_TIMEOUT_MS = 5 * 60 * 1_000; + +type ActiveBrowserAuthSession = GoogleAuthSession | NotionOAuthSession; + +interface ConfigAuthControllerDeps { + ui: ConfigUiState; + draft: DraftState; + paths: AppPaths; + authService: TuiAuthService; + refreshView(): void; + persistDraftMutation( + mutate: (draft: DraftState) => void, + failureFallback: string, + ): Promise; + inspectApp(): ReturnType; + getSecret(name: string): Promise; + getActiveAuthRun(): number; + incrementActiveAuthRun(): number; + getActiveBrowserAuthSession(): ActiveBrowserAuthSession | null; + setActiveBrowserAuthSession(session: ActiveBrowserAuthSession | null): void; +} + +export function createConfigAuthController(deps: ConfigAuthControllerDeps) { + return { + async activateAuthSelection( + route: ConnectorAuthRoute, + selection: unknown, + ): Promise { + if (route.stage === "intro") { + if (selection === "cancel") { + popRoute(deps.ui); + deps.refreshView(); + return; + } + + if (selection === "openDocs") { + const docsUrl = getConnectorAuthDocsUrl(route, deps.ui.docsBaseUrl); + if (!docsUrl) { + setNotice(deps.ui, { + kind: "error", + text: "Docs link is unavailable for this auth flow.", + }); + deps.refreshView(); + return; + } + + const browserResult = await deps.authService.openUrl(docsUrl); + setNotice( + deps.ui, + browserResult.opened + ? { + kind: "success", + text: "Connector docs opened in your browser.", + } + : { + kind: "error", + text: + browserResult.error ?? + "Failed to open the connector docs in your browser.", + }, + ); + deps.refreshView(); + return; + } + + if (route.authMethod === "notion-token") { + await startNotionSetup(route); + } else if (route.authMethod === "notion-oauth") { + await openOAuthSetupPage(route, () => + deps.authService.openNotionOAuthSetup(), + ); + } else { + await openOAuthSetupPage(route, () => + deps.authService.openGoogleOAuthSetup(), + ); + } + return; + } + + if (route.stage === "success") { + const message = + deps.ui.notice?.kind === "success" + ? deps.ui.notice.text + : `${route.connector} connected.`; + popRoute(deps.ui); + setNotice(deps.ui, { + kind: "success", + text: message, + }); + deps.refreshView(); + return; + } + + if (selection === "cancel") { + await runCancelAuthFlow(); + return; + } + + if (route.stage === "error" && selection === "retry") { + await runRetryAuthFlow(route); + } + }, + + async submitConnectorAuthInput(route: ConnectorAuthRoute): Promise { + const field = getCurrentAuthField(route); + const value = route.inputValue.trim(); + if (!value) { + route.error = `${field.label} is required.`; + setNotice(deps.ui, { + kind: "error", + text: route.error, + }); + deps.refreshView(); + return; + } + + route.values[field.key] = value; + route.error = null; + setNotice(deps.ui, null); + + if ( + (route.authMethod === "google-oauth" || + route.authMethod === "notion-oauth") && + route.fieldIndex === 0 + ) { + route.fieldIndex = 1; + route.inputValue = + route.authMethod === "notion-oauth" + ? (route.values.notionOauthClientSecret ?? "") + : (route.values.googleClientSecret ?? ""); + deps.refreshView(); + return; + } + + if (route.authMethod === "notion-token") { + await validateNotionToken(route, value); + return; + } + + if (route.authMethod === "notion-oauth") { + const clientId = route.values.notionOauthClientId ?? ""; + const clientSecret = value; + await runNotionOAuthConnectFlow(route, clientId, clientSecret); + return; + } + + const clientId = route.values.googleClientId ?? ""; + const clientSecret = value; + await runGoogleConnectFlow(route, clientId, clientSecret); + }, + + async ensureGoogleScopesForConnector( + connector: "gmail" | "google-calendar", + ): Promise { + const credentials = await getCurrentGoogleCredentials(); + const requiredScopes = await getRequiredGoogleScopes(connector); + if (credentials) { + try { + await deps.authService.validateGoogleCredentials( + deps.paths, + credentials, + requiredScopes, + ); + return true; + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Google account is missing required scopes."; + setNotice(deps.ui, { + kind: "error", + text: message, + }); + } + } else { + setNotice(deps.ui, { + kind: "error", + text: "Google account setup is incomplete. Reconnect to continue.", + }); + } + + const authRoute = createConnectorAuthRoute(connector, "google-oauth"); + pushRoute(deps.ui, authRoute); + const oauthAppCredentials = await getCurrentGoogleOAuthAppCredentials(); + if (oauthAppCredentials) { + authRoute.values.googleClientId = oauthAppCredentials.clientId; + authRoute.values.googleClientSecret = oauthAppCredentials.clientSecret; + await runGoogleConnectFlow( + authRoute, + oauthAppCredentials.clientId, + oauthAppCredentials.clientSecret, + ); + if ( + getCurrentRoute(deps.ui) !== authRoute || + authRoute.stage !== "success" + ) { + return false; + } + + popRoute(deps.ui); + deps.refreshView(); + return true; + } + + await openOAuthSetupPage(authRoute, () => + deps.authService.openGoogleOAuthSetup(), + ); + return false; + }, + + async refreshGoogleCalendarSelection( + route: GoogleCalendarSelectionRoute, + ): Promise { + route.loading = true; + route.error = null; + deps.refreshView(); + + try { + const credentials = await getCurrentGoogleCredentials(); + if (!credentials) { + throw new Error( + "Connect a Google account before selecting calendars.", + ); + } + + if (!deps.authService.listGoogleCalendars) { + throw new Error("Google calendar listing is unavailable."); + } + const calendars = + await deps.authService.listGoogleCalendars(credentials); + route.calendars = calendars; + route.selectedCalendarIds = route.selectedCalendarIds.filter((id) => + calendars.some((calendar) => calendar.id === id), + ); + route.loading = false; + route.error = null; + deps.refreshView(); + } catch (error) { + route.loading = false; + route.error = + error instanceof Error + ? error.message + : "Failed to load Google calendars."; + deps.refreshView(); + } + }, + + async retryAuthFlow(route: ConnectorAuthRoute): Promise { + await runRetryAuthFlow(route); + }, + + async cancelAuthFlow(): Promise { + await runCancelAuthFlow(); + }, + }; + + async function runRetryAuthFlow(route: ConnectorAuthRoute): Promise { + route.error = null; + route.authUrl = undefined; + route.browserOpened = undefined; + route.browserError = null; + route.selectedIndex = 0; + setNotice(deps.ui, null); + + if (route.authMethod === "notion-token") { + route.stage = "intro"; + deps.refreshView(); + return; + } + + route.stage = "collect-input"; + route.fieldIndex = 0; + route.inputValue = + route.authMethod === "notion-oauth" + ? (route.values.notionOauthClientId ?? "") + : (route.values.googleClientId ?? ""); + deps.refreshView(); + } + + async function runCancelAuthFlow(): Promise { + deps.incrementActiveAuthRun(); + const session = deps.getActiveBrowserAuthSession(); + deps.setActiveBrowserAuthSession(null); + if (session) { + await session.cancel().catch(() => {}); + } + + popRoute(deps.ui); + deps.refreshView(); + } + + async function startNotionSetup(route: ConnectorAuthRoute): Promise { + const runId = deps.incrementActiveAuthRun(); + route.stage = "opening-browser"; + route.error = null; + route.selectedIndex = 0; + deps.refreshView(); + + const browserResult = await deps.authService.openNotionSetup(); + if (!isAuthRouteActive(route, runId)) { + return; + } + + route.browserOpened = browserResult.opened; + route.browserError = browserResult.error ?? null; + route.stage = "collect-input"; + route.fieldIndex = 0; + route.inputValue = route.values.notionToken ?? ""; + route.error = null; + deps.refreshView(); + } + + async function openOAuthSetupPage( + route: ConnectorAuthRoute, + openSetupPage: () => Promise, + ): Promise { + route.stage = "opening-browser"; + route.error = null; + route.selectedIndex = 0; + deps.refreshView(); + + const browserResult = await openSetupPage(); + if (getCurrentRoute(deps.ui) !== route) { + return; + } + + route.browserOpened = browserResult.opened; + route.browserError = browserResult.error ?? null; + route.stage = "collect-input"; + route.fieldIndex = 0; + route.inputValue = + route.authMethod === "notion-oauth" + ? (route.values.notionOauthClientId ?? "") + : (route.values.googleClientId ?? ""); + route.error = null; + route.selectedIndex = 0; + deps.refreshView(); + } + + async function validateNotionToken( + route: ConnectorAuthRoute, + token: string, + ): Promise { + const runId = deps.incrementActiveAuthRun(); + route.stage = "validating"; + route.error = null; + route.selectedIndex = 0; + deps.refreshView(); + + try { + await deps.authService.validateNotionToken(deps.paths, token); + if (!isAuthRouteActive(route, runId)) { + return; + } + + const saved = await deps.persistDraftMutation( + (draft) => stageNotionConnection(draft, token), + "Failed to save Notion credentials.", + ); + if (!saved || !isAuthRouteActive(route, runId)) { + route.stage = "collect-input"; + route.error = + deps.ui.notice?.kind === "error" + ? deps.ui.notice.text + : "Failed to save Notion credentials."; + deps.refreshView(); + return; + } + + route.stage = "success"; + route.error = null; + route.selectedIndex = 0; + setNotice(deps.ui, { + kind: "success", + text: "Notion connected.", + }); + deps.refreshView(); + } catch (error) { + if (!isAuthRouteActive(route, runId)) { + return; + } + route.stage = "collect-input"; + route.error = + error instanceof Error + ? error.message + : "Unknown Notion validation failure."; + setNotice(deps.ui, { + kind: "error", + text: route.error, + }); + deps.refreshView(); + } + } + + async function getRequiredGoogleScopes( + connectorId: "gmail" | "google-calendar", + ): Promise { + const snapshot = await deps.inspectApp(); + const integrations = snapshot.integrations.map((integration) => ({ + ...integration, + enabled: + integration.connectorId === "notion" + ? isDraftConnectorEnabled(deps.draft, "notion") + : integration.connectorId === "gmail" + ? isDraftConnectorEnabled(deps.draft, "gmail") + : integration.connectorId === "google-calendar" + ? isDraftConnectorEnabled(deps.draft, "google-calendar") + : integration.enabled, + })); + return collectGoogleProviderScopes(integrations, { + includeIds: [ + snapshot.integrations.find( + (integration) => integration.connectorId === connectorId, + )?.id ?? getDraftIntegration(deps.draft, connectorId).id, + ], + }); + } + + async function getCurrentGoogleOAuthAppCredentials(): Promise<{ + clientId: string; + clientSecret: string; + } | null> { + const clientId = + deps.draft.googleClientId.action === "set" + ? deps.draft.googleClientId.value + : deps.draft.googleClientId.action === "delete" + ? null + : await deps.getSecret( + getGoogleOAuthAppSecretNames(DEFAULT_GOOGLE_OAUTH_APP_ID) + .clientId, + ); + const clientSecret = + deps.draft.googleClientSecret.action === "set" + ? deps.draft.googleClientSecret.value + : deps.draft.googleClientSecret.action === "delete" + ? null + : await deps.getSecret( + getGoogleOAuthAppSecretNames(DEFAULT_GOOGLE_OAUTH_APP_ID) + .clientSecret, + ); + if (!clientId || !clientSecret) { + return null; + } + + return { + clientId, + clientSecret, + }; + } + + async function getCurrentGoogleCredentials(): Promise { + const oauthAppCredentials = await getCurrentGoogleOAuthAppCredentials(); + const refreshToken = + deps.draft.googleRefreshToken.action === "set" + ? deps.draft.googleRefreshToken.value + : deps.draft.googleRefreshToken.action === "delete" + ? null + : await deps.getSecret( + getGoogleConnectionSecretNames(DEFAULT_GOOGLE_CONNECTION_ID) + .refreshToken, + ); + + if (!oauthAppCredentials || !refreshToken) { + return null; + } + + return { + clientId: oauthAppCredentials.clientId, + clientSecret: oauthAppCredentials.clientSecret, + refreshToken, + }; + } + + async function runGoogleConnectFlow( + route: ConnectorAuthRoute, + clientId: string, + clientSecret: string, + ): Promise { + const runId = deps.incrementActiveAuthRun(); + route.stage = "opening-browser"; + route.error = null; + route.selectedIndex = 0; + route.inputValue = ""; + deps.refreshView(); + + try { + const requiredScopes = await getRequiredGoogleScopes( + route.connector === "google-calendar" ? "google-calendar" : "gmail", + ); + const session = await deps.authService.startGoogleSession( + clientId, + clientSecret, + requiredScopes, + ); + if (!isAuthRouteActive(route, runId)) { + await session.cancel(); + return; + } + + deps.setActiveBrowserAuthSession(session); + route.stage = "waiting-callback"; + route.authUrl = session.authorizationUrl; + route.browserOpened = session.browserOpened; + route.browserError = session.browserError ?? null; + route.selectedIndex = 0; + deps.refreshView(); + + const tokenResult = await session.complete(GOOGLE_AUTH_TIMEOUT_MS); + deps.setActiveBrowserAuthSession(null); + if (!isAuthRouteActive(route, runId)) { + return; + } + + route.stage = "validating"; + deps.refreshView(); + + const credentials: GoogleAuthCredentials = { + clientId, + clientSecret, + refreshToken: tokenResult.refreshToken, + }; + await deps.authService.validateGoogleCredentials( + deps.paths, + credentials, + requiredScopes, + ); + if (!isAuthRouteActive(route, runId)) { + return; + } + + const saved = await deps.persistDraftMutation( + (draft) => + stageGoogleConnection( + draft, + clientId, + clientSecret, + tokenResult.refreshToken, + route.connector === "google-calendar" ? "google-calendar" : "gmail", + ), + "Failed to save Google account credentials.", + ); + if (!saved || !isAuthRouteActive(route, runId)) { + route.stage = "error"; + route.error = + deps.ui.notice?.kind === "error" + ? deps.ui.notice.text + : "Failed to save Google account credentials."; + deps.refreshView(); + return; + } + + route.stage = "success"; + route.error = null; + route.selectedIndex = 0; + setNotice(deps.ui, { + kind: "success", + text: "Google account connected.", + }); + deps.refreshView(); + } catch (error) { + deps.setActiveBrowserAuthSession(null); + if (!isAuthRouteActive(route, runId)) { + return; + } + route.stage = "error"; + route.error = + error instanceof Error + ? error.message + : "Unknown Google connection failure."; + route.selectedIndex = 0; + setNotice(deps.ui, { + kind: "error", + text: route.error, + }); + deps.refreshView(); + } + } + + async function runNotionOAuthConnectFlow( + route: ConnectorAuthRoute, + clientId: string, + clientSecret: string, + ): Promise { + const runId = deps.incrementActiveAuthRun(); + route.stage = "opening-browser"; + route.error = null; + route.selectedIndex = 0; + route.inputValue = ""; + deps.refreshView(); + + try { + const session = await deps.authService.startNotionOAuthSession( + clientId, + clientSecret, + ); + if (!isAuthRouteActive(route, runId)) { + await session.cancel(); + return; + } + + deps.setActiveBrowserAuthSession(session); + route.stage = "waiting-callback"; + route.authUrl = session.authorizationUrl; + route.browserOpened = session.browserOpened; + route.browserError = session.browserError ?? null; + route.selectedIndex = 0; + deps.refreshView(); + + const tokenResult = await session.complete(GOOGLE_AUTH_TIMEOUT_MS); + deps.setActiveBrowserAuthSession(null); + if (!isAuthRouteActive(route, runId)) { + return; + } + + route.stage = "validating"; + deps.refreshView(); + + await deps.authService.validateNotionOAuthAccessToken( + deps.paths, + tokenResult.accessToken, + ); + if (!isAuthRouteActive(route, runId)) { + return; + } + + const saved = await deps.persistDraftMutation( + (draft) => + stageNotionOAuthConnection( + draft, + clientId, + clientSecret, + tokenResult.refreshToken, + { + workspaceId: tokenResult.workspaceId, + workspaceName: tokenResult.workspaceName, + botId: tokenResult.botId, + ownerUserId: tokenResult.ownerUserId, + ownerUserName: tokenResult.ownerUserName, + }, + ), + "Failed to save Notion OAuth credentials.", + ); + if (!saved || !isAuthRouteActive(route, runId)) { + route.stage = "error"; + route.error = + deps.ui.notice?.kind === "error" + ? deps.ui.notice.text + : "Failed to save Notion OAuth credentials."; + deps.refreshView(); + return; + } + + route.stage = "success"; + route.error = null; + route.selectedIndex = 0; + setNotice(deps.ui, { + kind: "success", + text: "Notion OAuth account connected.", + }); + deps.refreshView(); + } catch (error) { + deps.setActiveBrowserAuthSession(null); + if (!isAuthRouteActive(route, runId)) { + return; + } + route.stage = "error"; + route.error = + error instanceof Error + ? error.message + : "Unknown Notion OAuth connection failure."; + route.selectedIndex = 0; + setNotice(deps.ui, { + kind: "error", + text: route.error, + }); + deps.refreshView(); + } + } + + function isAuthRouteActive( + route: ConnectorAuthRoute, + runId: number, + ): boolean { + return ( + runId === deps.getActiveAuthRun() && getCurrentRoute(deps.ui) === route + ); + } +} diff --git a/packages/tui/src/config-route-actions.ts b/packages/tui/src/config-route-actions.ts new file mode 100644 index 0000000..0a6ecdd --- /dev/null +++ b/packages/tui/src/config-route-actions.ts @@ -0,0 +1,493 @@ +import type { + GmailSyncFilter, + SyncIntervalPreset, + SyncRuntimeSnapshot, +} from "@syncdown/core"; +import type { + ConnectorTarget, + DraftState, + OutputPresetAction, +} from "./state.js"; +import { + buildOutputPresetPaths, + getDraftSelectedGoogleCalendarIds, + hasAnyStoredCredentials, + isDraftConnectorEnabled, + setConnectorEnabled, + setGmailSyncFilter, + setSelectedGoogleCalendarIds, + setSyncInterval, + stageConnectorDisconnect, + stageProviderDisconnect, + stageStoredCredentialDisconnect, +} from "./state.js"; +import type { + ConfigUiState, + ConfirmDisconnectRoute, + ConnectorDetailsRoute, + ConnectorsRoute, + GmailFilterRoute, + GoogleCalendarSelectionRoute, + HomeRoute, + IntervalRoute, + OutputRoute, + ScheduleRoute, +} from "./view-state.js"; +import { + createConfirmDisconnectRoute, + createConnectorAuthRoute, + createConnectorDetailsRoute, + createGmailFilterRoute, + createGoogleCalendarSelectionRoute, + createIntervalRoute, + createOutputCustomRoute, + createSyncDashboardRoute, + createUpdateRoute, + popRoute, + pushRoute, + setNotice, +} from "./view-state.js"; + +type ManagedConnectorTarget = Exclude; + +interface ConfigRouteActionsDeps { + ui: ConfigUiState; + draft: DraftState; + getSyncSnapshot(): SyncRuntimeSnapshot; + refreshView(): void; + ensureGoogleScopesForConnector( + connector: "gmail" | "google-calendar", + ): Promise; + persistDraftMutation( + mutate: (draft: DraftState) => void, + failureFallback: string, + ): Promise; + persistOutputDirectory(outputDir: string): Promise; + refreshGoogleCalendarSelection( + route: GoogleCalendarSelectionRoute, + ): Promise; +} + +export function createConfigRouteActions(deps: ConfigRouteActionsDeps) { + async function openGoogleCalendarSelectionRoute( + selectedCalendarIds: string[], + ): Promise { + const calendarRoute = + createGoogleCalendarSelectionRoute(selectedCalendarIds); + pushRoute(deps.ui, calendarRoute); + deps.refreshView(); + await deps.refreshGoogleCalendarSelection(calendarRoute); + } + + async function enableConnector( + connector: ManagedConnectorTarget, + failureMessage: string, + successMessage: string, + ): Promise { + const saved = await deps.persistDraftMutation( + (draft) => setConnectorEnabled(draft, connector, true), + failureMessage, + ); + if (saved) { + setNotice(deps.ui, { + kind: "success", + text: successMessage, + }); + } + } + + async function handleEnableConnectorSelection( + connector: ConnectorTarget, + ): Promise { + if (connector === "gmail") { + const hasScopes = await deps.ensureGoogleScopesForConnector("gmail"); + if (!hasScopes) { + return; + } + + await enableConnector( + connector, + `Failed to enable ${getConnectorLabel(connector)}.`, + `${getConnectorLabel(connector)} enabled.`, + ); + return; + } + + if (connector === "google-calendar") { + const hasScopes = + await deps.ensureGoogleScopesForConnector("google-calendar"); + if (!hasScopes) { + return; + } + + if (getDraftSelectedGoogleCalendarIds(deps.draft).length === 0) { + await openGoogleCalendarSelectionRoute([]); + return; + } + + await enableConnector( + connector, + `Failed to enable ${getConnectorLabel(connector)}.`, + `${getConnectorLabel(connector)} enabled.`, + ); + return; + } + + if (connector === "apple-notes") { + await enableConnector( + connector, + `Failed to enable ${getConnectorLabel(connector)}.`, + `${getConnectorLabel(connector)} enabled.`, + ); + } + } + + return { + handleHomeSelection(route: HomeRoute, selection: unknown): void { + if (selection === "sync") { + pushRoute(deps.ui, createSyncDashboardRoute(deps.getSyncSnapshot())); + } else if (selection === "connectors") { + pushRoute(deps.ui, { id: "connectors", selectedIndex: 0 }); + } else if (selection === "output") { + pushRoute(deps.ui, { id: "output", selectedIndex: 0 }); + } else if (selection === "schedule") { + pushRoute(deps.ui, { id: "schedule", selectedIndex: 0 }); + } else if (selection === "advanced") { + pushRoute(deps.ui, { id: "advanced", selectedIndex: 0 }); + } else if (selection === "update") { + pushRoute(deps.ui, createUpdateRoute(route)); + } + + deps.refreshView(); + }, + + handleConnectorsSelection( + _route: ConnectorsRoute, + selection: unknown, + ): void { + if (isConnectorTarget(selection)) { + pushRoute(deps.ui, createConnectorDetailsRoute(selection)); + } + + deps.refreshView(); + }, + + async handleConnectorDetailsSelection( + route: ConnectorDetailsRoute, + selection: unknown, + ): Promise { + if (selection === "connectToken" && route.connector === "notion") { + pushRoute( + deps.ui, + createConnectorAuthRoute(route.connector, "notion-token"), + ); + deps.refreshView(); + return; + } + + if (selection === "connectOAuth" && route.connector === "notion") { + pushRoute( + deps.ui, + createConnectorAuthRoute(route.connector, "notion-oauth"), + ); + deps.refreshView(); + return; + } + + if (selection === "connect") { + pushRoute( + deps.ui, + createConnectorAuthRoute(route.connector, "google-oauth"), + ); + deps.refreshView(); + return; + } + + if (selection === "gmailFilter" && route.connector === "gmail") { + pushRoute(deps.ui, createGmailFilterRoute()); + deps.refreshView(); + return; + } + + if ( + selection === "googleCalendarSelection" && + route.connector === "google-calendar" + ) { + await openGoogleCalendarSelectionRoute( + getDraftSelectedGoogleCalendarIds(deps.draft), + ); + return; + } + + if (selection === "enable") { + await handleEnableConnectorSelection(route.connector); + deps.refreshView(); + return; + } + + if (selection === "disable") { + pushRoute( + deps.ui, + createConfirmDisconnectRoute(route.connector, "connector"), + ); + deps.refreshView(); + return; + } + + if ( + selection === "disconnectProvider" && + (route.connector === "gmail" || route.connector === "google-calendar") + ) { + pushRoute( + deps.ui, + createConfirmDisconnectRoute(route.connector, "provider", "google"), + ); + deps.refreshView(); + return; + } + + if (selection === "disconnect") { + if ( + !hasAnyStoredCredentials(deps.draft, route.connector) && + !isDraftConnectorEnabled(deps.draft, route.connector) + ) { + setNotice(deps.ui, { + kind: "error", + text: "Connector is already disconnected.", + }); + deps.refreshView(); + return; + } + + pushRoute( + deps.ui, + createConfirmDisconnectRoute(route.connector, "connector"), + ); + deps.refreshView(); + return; + } + + deps.refreshView(); + }, + + async handleConfirmDisconnectSelection( + route: ConfirmDisconnectRoute, + selection: unknown, + ): Promise { + if (selection === "cancel") { + popRoute(deps.ui); + deps.refreshView(); + return; + } + + const saved = await deps.persistDraftMutation( + (draft) => { + if (route.mode === "provider") { + stageProviderDisconnect(draft, route.provider ?? "google"); + return; + } + + if (route.connector === "notion") { + stageStoredCredentialDisconnect(draft, route.connector); + return; + } + + stageConnectorDisconnect(draft, route.connector); + }, + `Failed to disconnect ${getDisconnectLabel(route)}.`, + ); + if (!saved) { + return; + } + + popRoute(deps.ui); + setNotice(deps.ui, { + kind: "success", + text: getDisconnectSuccessText(route), + }); + deps.refreshView(); + }, + + async handleOutputSelection( + _route: OutputRoute, + selection: unknown, + ): Promise { + if (selection === "custom") { + pushRoute(deps.ui, createOutputCustomRoute(deps.draft)); + deps.refreshView(); + return; + } + + const preset = selection as + | Exclude + | undefined; + if (!preset) { + return; + } + + const presetPaths = buildOutputPresetPaths(); + const saved = await deps.persistOutputDirectory(presetPaths[preset]); + if (saved) { + setNotice(deps.ui, { + kind: "success", + text: "Output directory saved.", + }); + deps.refreshView(); + } + }, + + handleScheduleSelection(_route: ScheduleRoute, selection: unknown): void { + if (isConnectorTarget(selection)) { + pushRoute(deps.ui, createIntervalRoute(selection)); + } + + deps.refreshView(); + }, + + async handleIntervalSelection( + route: IntervalRoute, + selection: unknown, + ): Promise { + const interval = selection as SyncIntervalPreset | undefined; + if (!interval) { + return; + } + + const connectorLabel = getConnectorLabel(route.connector); + const saved = await deps.persistDraftMutation( + (draft) => setSyncInterval(draft, route.connector, interval), + `Failed to save the ${connectorLabel} interval.`, + ); + if (!saved) { + return; + } + + popRoute(deps.ui); + setNotice(deps.ui, { + kind: "success", + text: `${connectorLabel} interval saved.`, + }); + deps.refreshView(); + }, + + async handleGmailFilterSelection( + _route: GmailFilterRoute, + selection: unknown, + ): Promise { + const syncFilter = selection as GmailSyncFilter | undefined; + if (!syncFilter) { + return; + } + + const saved = await deps.persistDraftMutation( + (draft) => setGmailSyncFilter(draft, syncFilter), + "Failed to save the Gmail inbox filter.", + ); + if (!saved) { + return; + } + + popRoute(deps.ui); + setNotice(deps.ui, { + kind: "success", + text: "Gmail inbox filter saved. Run Gmail again to apply the new scope.", + }); + deps.refreshView(); + }, + + async handleGoogleCalendarSelection( + route: GoogleCalendarSelectionRoute, + selection: unknown, + ): Promise { + if (selection === "refresh") { + await deps.refreshGoogleCalendarSelection(route); + return; + } + + if (selection === "save") { + const selectedCalendarIds = [...route.selectedCalendarIds]; + const saved = await deps.persistDraftMutation( + (draft) => setSelectedGoogleCalendarIds(draft, selectedCalendarIds), + "Failed to save selected Google calendars.", + ); + if (!saved) { + return; + } + + popRoute(deps.ui); + setNotice(deps.ui, { + kind: "success", + text: "Google Calendar selection saved.", + }); + deps.refreshView(); + return; + } + + if ( + selection && + typeof selection === "object" && + "kind" in selection && + (selection as { kind?: string }).kind === "toggleCalendar" + ) { + const calendarId = (selection as unknown as { calendarId: string }) + .calendarId; + route.selectedCalendarIds = route.selectedCalendarIds.includes( + calendarId, + ) + ? route.selectedCalendarIds.filter((id) => id !== calendarId) + : [...route.selectedCalendarIds, calendarId]; + deps.refreshView(); + } + }, + }; +} + +function isConnectorTarget(selection: unknown): selection is ConnectorTarget { + return ( + selection === "notion" || + selection === "gmail" || + selection === "google-calendar" || + selection === "apple-notes" + ); +} + +function getConnectorLabel(connector: ConnectorTarget): string { + switch (connector) { + case "notion": + return "Notion"; + case "gmail": + return "Gmail"; + case "google-calendar": + return "Google Calendar"; + case "apple-notes": + return "Apple Notes"; + default: { + const exhaustiveConnector: never = connector; + return exhaustiveConnector; + } + } +} + +function getProviderDisconnectLabel(provider: "google" | "notion"): string { + return provider === "notion" ? "Notion OAuth account" : "Google account"; +} + +function getDisconnectLabel(route: ConfirmDisconnectRoute): string { + if (route.mode === "provider") { + return getProviderDisconnectLabel(route.provider ?? "google"); + } + + return getConnectorLabel(route.connector); +} + +function getDisconnectSuccessText(route: ConfirmDisconnectRoute): string { + if (route.mode === "provider") { + return `${getProviderDisconnectLabel(route.provider ?? "google")} disconnected.`; + } + + if (route.connector === "notion") { + return "Notion disconnected."; + } + + return `${getConnectorLabel(route.connector)} disabled.`; +} diff --git a/packages/tui/src/config-runtime-controller.ts b/packages/tui/src/config-runtime-controller.ts new file mode 100644 index 0000000..684a22a --- /dev/null +++ b/packages/tui/src/config-runtime-controller.ts @@ -0,0 +1,445 @@ +import type { AppPaths } from "@syncdown/core"; +import { EXIT_CODES } from "@syncdown/core"; +import type { ConfigTuiRequest } from "./index.js"; +import type { DraftState } from "./state.js"; +import { collectDiagnostics, getDraftIntegration } from "./state.js"; +import type { + ConfigUiState, + SyncDashboardRoute, + UpdateRoute, +} from "./view-state.js"; +import { + createConfirmResetRoute, + createDiagnosticsRoute, + getCurrentRoute, + popRoute, + pushRoute, + setNotice, +} from "./view-state.js"; + +function isSyncSnapshotBusy(route: SyncDashboardRoute): boolean { + return ( + route.snapshot.watch.active || + route.snapshot.integrations.some( + (integration) => integration.running || integration.queuedImmediateRun, + ) + ); +} + +interface ConfigRuntimeControllerDeps { + ui: ConfigUiState; + draft: DraftState; + paths: AppPaths; + request: ConfigTuiRequest; + updater: { + applyUpdate(): Promise<{ message: string }>; + }; + refreshView(): void; + runUpdateCheck(showNoticeOnFailure: boolean): Promise; + finish(code: number): void; + cancelAuthFlow(): Promise; +} + +export function createConfigRuntimeController( + deps: ConfigRuntimeControllerDeps, +) { + return { + async handleAdvancedSelection(selection: unknown): Promise { + if (selection === "diagnostics") { + pushRoute(deps.ui, createDiagnosticsRoute(deps.paths, deps.draft)); + deps.refreshView(); + await this.refreshDiagnostics(); + return; + } + + if (selection !== "resetAppData") { + return; + } + + const snapshot = deps.request.session.getSnapshot(); + if ( + snapshot.watch.active || + snapshot.integrations.some( + (integration) => + integration.running || integration.queuedImmediateRun, + ) + ) { + setNotice(deps.ui, { + kind: "error", + text: "Stop the current sync before resetting app data.", + }); + deps.refreshView(); + return; + } + + pushRoute(deps.ui, createConfirmResetRoute()); + deps.refreshView(); + }, + + async handleConfirmResetSelection(selection: unknown): Promise { + if (selection === "cancel") { + popRoute(deps.ui); + deps.refreshView(); + return; + } + + if (selection !== "reset") { + return; + } + + const snapshot = deps.request.session.getSnapshot(); + if ( + snapshot.watch.active || + snapshot.integrations.some( + (integration) => + integration.running || integration.queuedImmediateRun, + ) + ) { + setNotice(deps.ui, { + kind: "error", + text: "Stop the current sync before resetting app data.", + }); + popRoute(deps.ui); + deps.refreshView(); + return; + } + + const writes: string[] = []; + const errors: string[] = []; + await deps.request.session.dispose(); + const exitCode = await deps.request.app.reset({ + write(line) { + writes.push(line); + }, + error(line) { + errors.push(line); + }, + }); + + if (exitCode !== EXIT_CODES.OK) { + deps.finish(exitCode); + for (const line of errors.length > 0 + ? errors + : ["Failed to reset app data."]) { + deps.request.io.error(line); + } + return; + } + + deps.finish(EXIT_CODES.OK); + for (const line of writes) { + deps.request.io.write(line); + } + }, + + async handleDiagnosticsSelection(selection: unknown): Promise { + if (selection === "refresh") { + await this.refreshDiagnostics(); + } + }, + + async activateUpdateSelection( + route: UpdateRoute, + selection: unknown, + ): Promise { + if (route.installBusy) { + return; + } + + if (selection === "checkNow") { + await deps.runUpdateCheck(true); + return; + } + + if (selection !== "installUpdate") { + return; + } + + route.installBusy = true; + setNotice(deps.ui, null); + deps.refreshView(); + + try { + const result = await deps.updater.applyUpdate(); + setNotice(deps.ui, { + kind: "success", + text: result.message, + }); + } catch (error) { + setNotice(deps.ui, { + kind: "error", + text: + error instanceof Error ? error.message : "Unknown update failure.", + }); + } finally { + route.installBusy = false; + deps.refreshView(); + } + }, + + async activateSyncDashboardSelection( + route: SyncDashboardRoute, + selection: unknown, + ): Promise { + if (route.busy) { + if (selection === "cancelActiveRun" && !route.cancelPending) { + route.cancelPending = true; + setNotice(deps.ui, { + kind: "success", + text: "Cancelling sync...", + }); + deps.refreshView(); + void deps.request.session.cancelActiveRun().catch((error) => { + route.cancelPending = false; + setNotice(deps.ui, { + kind: "error", + text: + error instanceof Error + ? error.message + : "Unknown sync action failure.", + }); + deps.refreshView(); + }); + } + return; + } + + if (isSyncSnapshotBusy(route) && selection !== "cancelActiveRun") { + setNotice(deps.ui, { + kind: "error", + text: "Stop the current sync before using other actions.", + }); + deps.refreshView(); + return; + } + + if (selection === "clearLog") { + route.clearedAfter = + route.snapshot.logs.at(-1)?.timestamp ?? route.clearedAfter; + deps.refreshView(); + return; + } + + if (selection === "toggleDetailedLogs") { + route.showDetailedLogs = !route.showDetailedLogs; + deps.refreshView(); + return; + } + + route.busy = true; + route.cancelPending = false; + setNotice(deps.ui, null); + deps.refreshView(); + + try { + if (selection === "cancelActiveRun") { + if (route.snapshot.watch.active) { + await deps.request.session.cancelActiveRun(); + await deps.request.session.stopWatch(); + setNotice(deps.ui, { + kind: "success", + text: "Sync stopped.", + }); + } else { + await deps.request.session.cancelActiveRun(); + setNotice(deps.ui, { + kind: "success", + text: "Sync cancelled.", + }); + } + } else if (selection === "startWatch") { + await deps.request.session.startWatch({ kind: "per-integration" }); + setNotice(deps.ui, { + kind: "success", + text: "Watch started.", + }); + } else if (selection === "stopWatch") { + await deps.request.session.stopWatch(); + setNotice(deps.ui, { + kind: "success", + text: "Watch stopped.", + }); + } else if (selection === "runAll") { + await deps.request.session.runNow({ kind: "all" }); + setSyncRunNotice("Run completed."); + } else if (selection === "runAllReset") { + await deps.request.session.runNow( + { kind: "all" }, + { resetState: true }, + ); + setSyncRunNotice("Full resync completed."); + } else if (selection === "runNotion") { + await deps.request.session.runNow({ + kind: "integration", + integrationId: getDraftIntegration(deps.draft, "notion").id, + }); + setSyncRunNotice("Notion run completed."); + } else if (selection === "runNotionReset") { + await deps.request.session.runNow( + { + kind: "integration", + integrationId: getDraftIntegration(deps.draft, "notion").id, + }, + { resetState: true }, + ); + setSyncRunNotice("Notion full resync completed."); + } else if (selection === "runGmail") { + await deps.request.session.runNow({ + kind: "integration", + integrationId: getDraftIntegration(deps.draft, "gmail").id, + }); + setSyncRunNotice("Gmail run completed."); + } else if (selection === "runGmailReset") { + await deps.request.session.runNow( + { + kind: "integration", + integrationId: getDraftIntegration(deps.draft, "gmail").id, + }, + { resetState: true }, + ); + setSyncRunNotice("Gmail full resync completed."); + } else if (selection === "runGoogleCalendar") { + await deps.request.session.runNow({ + kind: "integration", + integrationId: getDraftIntegration(deps.draft, "google-calendar") + .id, + }); + setSyncRunNotice("Google Calendar run completed."); + } else if (selection === "runGoogleCalendarReset") { + await deps.request.session.runNow( + { + kind: "integration", + integrationId: getDraftIntegration(deps.draft, "google-calendar") + .id, + }, + { resetState: true }, + ); + setSyncRunNotice("Google Calendar full resync completed."); + } else if (selection === "runAppleNotes") { + await deps.request.session.runNow({ + kind: "integration", + integrationId: getDraftIntegration(deps.draft, "apple-notes").id, + }); + setSyncRunNotice("Apple Notes run completed."); + } else if (selection === "runAppleNotesReset") { + await deps.request.session.runNow( + { + kind: "integration", + integrationId: getDraftIntegration(deps.draft, "apple-notes").id, + }, + { resetState: true }, + ); + setSyncRunNotice("Apple Notes full resync completed."); + } + } catch (error) { + setNotice(deps.ui, { + kind: "error", + text: + error instanceof Error + ? error.message + : "Unknown sync action failure.", + }); + } finally { + route.busy = false; + route.cancelPending = false; + route.snapshot = deps.request.session.getSnapshot(); + deps.refreshView(); + } + }, + + async refreshDiagnostics(): Promise { + const route = getCurrentRoute(deps.ui); + if (route.id !== "diagnostics") { + return; + } + + route.loading = true; + setNotice(deps.ui, null); + deps.refreshView(); + + try { + const diagnostics = await collectDiagnostics( + deps.request.app, + deps.request.io, + deps.paths, + deps.draft, + ); + if (getCurrentRoute(deps.ui) !== route) { + return; + } + + route.loading = false; + route.title = diagnostics.title; + route.body = diagnostics.body; + deps.refreshView(); + } catch (error) { + if (getCurrentRoute(deps.ui) !== route) { + return; + } + + route.loading = false; + setNotice(deps.ui, { + kind: "error", + text: + error instanceof Error + ? error.message + : "Unknown diagnostics failure.", + }); + deps.refreshView(); + } + }, + + async handleBack(): Promise { + const route = getCurrentRoute(deps.ui); + if (route.id === "home") { + return; + } + + if ( + route.id === "syncDashboard" && + (route.busy || isSyncSnapshotBusy(route)) + ) { + setNotice(deps.ui, { + kind: "error", + text: "Stop the current sync before leaving the sync dashboard.", + }); + deps.refreshView(); + return; + } + + if (route.id === "connectorAuth") { + await deps.cancelAuthFlow(); + return; + } + + popRoute(deps.ui); + deps.refreshView(); + }, + }; + + function setSyncRunNotice(successText: string): void { + const snapshot = deps.request.session.getSnapshot(); + if (snapshot.lastRunError === "Sync cancelled by user.") { + setNotice(deps.ui, { + kind: "success", + text: "Sync cancelled.", + }); + return; + } + + if (snapshot.lastRunExitCode === EXIT_CODES.OK) { + setNotice(deps.ui, { + kind: "success", + text: successText, + }); + return; + } + + setNotice(deps.ui, { + kind: "error", + text: + snapshot.lastRunError ?? + `Sync failed with exit code ${snapshot.lastRunExitCode ?? "unknown"}.`, + }); + } +} From 7ffcae3e8ed8e24d5f71157fe75de9b8a2c77d83 Mon Sep 17 00:00:00 2001 From: hhhjin Date: Thu, 19 Mar 2026 11:03:01 +0900 Subject: [PATCH 2/3] refactor: reuse sync snapshot busy helper --- packages/tui/src/config-runtime-controller.ts | 31 +++++++------------ 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/packages/tui/src/config-runtime-controller.ts b/packages/tui/src/config-runtime-controller.ts index 684a22a..ebae048 100644 --- a/packages/tui/src/config-runtime-controller.ts +++ b/packages/tui/src/config-runtime-controller.ts @@ -1,4 +1,4 @@ -import type { AppPaths } from "@syncdown/core"; +import type { AppPaths, SyncRuntimeSnapshot } from "@syncdown/core"; import { EXIT_CODES } from "@syncdown/core"; import type { ConfigTuiRequest } from "./index.js"; import type { DraftState } from "./state.js"; @@ -17,10 +17,10 @@ import { setNotice, } from "./view-state.js"; -function isSyncSnapshotBusy(route: SyncDashboardRoute): boolean { +function isSyncSnapshotBusy(snapshot: SyncRuntimeSnapshot): boolean { return ( - route.snapshot.watch.active || - route.snapshot.integrations.some( + snapshot.watch.active || + snapshot.integrations.some( (integration) => integration.running || integration.queuedImmediateRun, ) ); @@ -57,13 +57,7 @@ export function createConfigRuntimeController( } const snapshot = deps.request.session.getSnapshot(); - if ( - snapshot.watch.active || - snapshot.integrations.some( - (integration) => - integration.running || integration.queuedImmediateRun, - ) - ) { + if (isSyncSnapshotBusy(snapshot)) { setNotice(deps.ui, { kind: "error", text: "Stop the current sync before resetting app data.", @@ -88,13 +82,7 @@ export function createConfigRuntimeController( } const snapshot = deps.request.session.getSnapshot(); - if ( - snapshot.watch.active || - snapshot.integrations.some( - (integration) => - integration.running || integration.queuedImmediateRun, - ) - ) { + if (isSyncSnapshotBusy(snapshot)) { setNotice(deps.ui, { kind: "error", text: "Stop the current sync before resetting app data.", @@ -204,7 +192,10 @@ export function createConfigRuntimeController( return; } - if (isSyncSnapshotBusy(route) && selection !== "cancelActiveRun") { + if ( + isSyncSnapshotBusy(route.snapshot) && + selection !== "cancelActiveRun" + ) { setNotice(deps.ui, { kind: "error", text: "Stop the current sync before using other actions.", @@ -397,7 +388,7 @@ export function createConfigRuntimeController( if ( route.id === "syncDashboard" && - (route.busy || isSyncSnapshotBusy(route)) + (route.busy || isSyncSnapshotBusy(route.snapshot)) ) { setNotice(deps.ui, { kind: "error", From 87ae2ee3fd665e3944a8bc50571616e2bb834dc7 Mon Sep 17 00:00:00 2001 From: hhhjin Date: Thu, 19 Mar 2026 11:05:38 +0900 Subject: [PATCH 3/3] changeset --- .changeset/fine-pugs-punch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fine-pugs-punch.md diff --git a/.changeset/fine-pugs-punch.md b/.changeset/fine-pugs-punch.md new file mode 100644 index 0000000..91d4ee8 --- /dev/null +++ b/.changeset/fine-pugs-punch.md @@ -0,0 +1,5 @@ +--- +"@syncdown/cli": patch +--- + +refactor the CLI TUI config flow to use dedicated auth, route, and runtime controllers