Skip to content

Commit 5ea67d0

Browse files
Paveltarnoclaude
andauthored
feat(connectors): allow skipping OAuth with Esc key (#217)
* feat(connectors): add OAuth connector resource schemas and file parsing Add connector resource module supporting 12 OAuth providers: googlecalendar, googledrive, gmail, googlesheets, googledocs, googleslides, slack, notion, salesforce, hubspot, linkedin, tiktok. - Zod discriminated union schema with type discriminator per provider - JSDoc links to official OAuth scope documentation for each provider - JSONC file reading with validation (filename must match type field) - API response schemas for upstream connector state - Unit tests with fixtures for valid, invalid, and mismatched connectors Part of: #184 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(connectors): implement push logic for syncing connectors (#191) * feat(connectors): implement push logic for syncing connectors Add pushConnectors function that: - Syncs all local connectors via /sync endpoint - Removes upstream-only connectors not in local config - Returns typed results (synced, removed, needs_oauth, error) Includes unit tests covering all scenarios. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(connectors): add OAuth flow handling with browser redirect and polling (#192) * feat(connectors): add OAuth flow handling with browser redirect and polling Add runOAuthFlow function that: - Opens OAuth redirect URL in browser - Polls getOAuthStatus until ACTIVE or FAILED - Returns PENDING on timeout (5 minutes) Uses p-wait-for TimeoutError for robust timeout detection. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * connectors: base44 connectors push (#194) * final connector work sofi 1 * scopes --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> * feat(connectors): support arbitrary OAuth providers Change provider field from closed enum to flexible union that accepts both known providers (googlecalendar, notion, slack, etc.) and any arbitrary provider string. This enables users to configure custom OAuth providers without waiting for first-class Base44 support. Schema changes: - Add GenericConnectorSchema for arbitrary provider types - Update ConnectorResourceSchema to union of specific + generic schemas - Update IntegrationTypeSchema to accept known enum OR any non-empty string - Only reject empty strings Test coverage: - Verify known providers continue to work - Verify arbitrary providers are accepted - Verify empty strings are rejected - All 137 tests passing Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * review fixes * feat(connectors): allow skipping OAuth authorization with Esc key Override process.exit temporarily during the spinner to intercept Ctrl+C/Escape (which clack's block() converts to process.exit(0)), letting users skip individual connector authorizations instead of killing the entire process. Adds SKIPPED status to the summary. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix * lint * review fixes: add process.exit override comment, use try/catch/finally Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 73b9d2f commit 5ea67d0

1 file changed

Lines changed: 95 additions & 36 deletions

File tree

src/cli/commands/connectors/push.ts

Lines changed: 95 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { confirm, isCancel, log } from "@clack/prompts";
1+
import { confirm, isCancel, log, spinner } from "@clack/prompts";
22
import { Command } from "commander";
33
import open from "open";
44
import pWaitFor, { TimeoutError } from "p-wait-for";
@@ -14,6 +14,86 @@ import {
1414
pushConnectors,
1515
} from "@/core/resources/connector/index.js";
1616

17+
const POLL_INTERVAL_MS = 2000;
18+
const POLL_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
19+
20+
interface OAuthFlowParams {
21+
type: IntegrationType;
22+
redirectUrl: string;
23+
connectionId: string;
24+
}
25+
26+
type OAuthFlowStatus = ConnectorOAuthStatus | "SKIPPED";
27+
28+
interface OAuthFlowResult {
29+
type: IntegrationType;
30+
status: OAuthFlowStatus;
31+
}
32+
33+
/**
34+
* Clack's block() puts stdin in raw mode where Ctrl+C calls process.exit(0)
35+
* directly instead of emitting SIGINT. We override process.exit temporarily
36+
* so Ctrl+C skips the current connector instead of killing the process.
37+
*/
38+
async function runOAuthFlowWithSkip(
39+
params: OAuthFlowParams,
40+
): Promise<OAuthFlowResult> {
41+
await open(params.redirectUrl);
42+
43+
let finalStatus = "PENDING" as OAuthFlowStatus;
44+
let skipped = false;
45+
46+
const s = spinner();
47+
48+
// Clack's spinner calls block() which puts stdin in raw mode — Esc/Ctrl+C
49+
// calls process.exit(0) directly, bypassing SIGINT. Override to skip instead.
50+
const originalExit = process.exit;
51+
process.exit = (() => {
52+
skipped = true;
53+
s.stop(`${params.type} skipped`);
54+
}) as unknown as typeof process.exit;
55+
56+
s.start(`Waiting for ${params.type} authorization... (Esc to skip)`);
57+
58+
try {
59+
await pWaitFor(
60+
async () => {
61+
if (skipped) {
62+
finalStatus = "SKIPPED";
63+
return true;
64+
}
65+
const response = await getOAuthStatus(params.type, params.connectionId);
66+
finalStatus = response.status;
67+
return response.status !== "PENDING";
68+
},
69+
{
70+
interval: POLL_INTERVAL_MS,
71+
timeout: POLL_TIMEOUT_MS,
72+
},
73+
);
74+
} catch (err) {
75+
if (err instanceof TimeoutError) {
76+
finalStatus = "PENDING";
77+
} else {
78+
throw err;
79+
}
80+
} finally {
81+
process.exit = originalExit;
82+
83+
if (!skipped) {
84+
if (finalStatus === "ACTIVE") {
85+
s.stop(`${params.type} authorization complete`);
86+
} else if (finalStatus === "FAILED") {
87+
s.stop(`${params.type} authorization failed`);
88+
} else {
89+
s.stop(`${params.type} authorization timed out`);
90+
}
91+
}
92+
}
93+
94+
return { type: params.type, status: finalStatus };
95+
}
96+
1797
type PendingOAuthResult = ConnectorSyncResult & {
1898
redirectUrl: string;
1999
connectionId: string;
@@ -25,11 +105,12 @@ function isPendingOAuth(r: ConnectorSyncResult): r is PendingOAuthResult {
25105

26106
function printSummary(
27107
results: ConnectorSyncResult[],
28-
oauthOutcomes: Map<IntegrationType, ConnectorOAuthStatus>,
108+
oauthOutcomes: Map<IntegrationType, OAuthFlowStatus>,
29109
): void {
30110
const synced: IntegrationType[] = [];
31111
const added: IntegrationType[] = [];
32112
const removed: IntegrationType[] = [];
113+
const skipped: IntegrationType[] = [];
33114
const failed: { type: IntegrationType; error?: string }[] = [];
34115

35116
for (const r of results) {
@@ -44,6 +125,8 @@ function printSummary(
44125
} else if (r.action === "needs_oauth") {
45126
if (oauthStatus === "ACTIVE") {
46127
added.push(r.type);
128+
} else if (oauthStatus === "SKIPPED") {
129+
skipped.push(r.type);
47130
} else if (oauthStatus === "PENDING") {
48131
failed.push({ type: r.type, error: "authorization timed out" });
49132
} else if (oauthStatus === "FAILED") {
@@ -66,6 +149,9 @@ function printSummary(
66149
if (removed.length > 0) {
67150
log.info(theme.styles.dim(`Removed: ${removed.join(", ")}`));
68151
}
152+
if (skipped.length > 0) {
153+
log.warn(`Skipped: ${skipped.join(", ")}`);
154+
}
69155
for (const r of failed) {
70156
log.error(`Failed: ${r.type}${r.error ? ` - ${r.error}` : ""}`);
71157
}
@@ -92,7 +178,7 @@ async function pushConnectorsAction(): Promise<RunCommandResult> {
92178
},
93179
);
94180

95-
const oauthOutcomes = new Map<IntegrationType, ConnectorOAuthStatus>();
181+
const oauthOutcomes = new Map<IntegrationType, OAuthFlowStatus>();
96182
const needsOAuth = results.filter(isPendingOAuth);
97183
let outroMessage = "Connectors pushed to Base44";
98184

@@ -125,41 +211,14 @@ async function pushConnectorsAction(): Promise<RunCommandResult> {
125211
for (const connector of needsOAuth) {
126212
try {
127213
log.info(`\nOpening browser for '${connector.type}'...`);
128-
await open(connector.redirectUrl);
129-
130-
let finalStatus: ConnectorOAuthStatus = "PENDING";
131-
132-
await runTask(
133-
`Waiting for '${connector.type}' authorization...`,
134-
async () => {
135-
await pWaitFor(
136-
async () => {
137-
const response = await getOAuthStatus(
138-
connector.type,
139-
connector.connectionId,
140-
);
141-
finalStatus = response.status;
142-
return response.status !== "PENDING";
143-
},
144-
{
145-
interval: 2000,
146-
timeout: 2 * 60 * 1000,
147-
},
148-
);
149-
},
150-
{
151-
successMessage: `'${connector.type}' authorization complete`,
152-
errorMessage: `'${connector.type}' authorization failed`,
153-
},
154-
).catch((err) => {
155-
if (err instanceof TimeoutError) {
156-
finalStatus = "PENDING";
157-
} else {
158-
throw err;
159-
}
214+
215+
const oauthResult = await runOAuthFlowWithSkip({
216+
type: connector.type,
217+
redirectUrl: connector.redirectUrl,
218+
connectionId: connector.connectionId,
160219
});
161220

162-
oauthOutcomes.set(connector.type, finalStatus);
221+
oauthOutcomes.set(connector.type, oauthResult.status);
163222
} catch (err) {
164223
log.error(
165224
`Failed to authorize '${connector.type}': ${err instanceof Error ? err.message : String(err)}`,

0 commit comments

Comments
 (0)