Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions src/cli/commands/connectors/oauth-prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { confirm, isCancel, log, spinner } from "@clack/prompts";
import open from "open";
import pWaitFor, { TimeoutError } from "p-wait-for";
import { theme } from "@/cli/utils/index.js";
import type {
ConnectorOAuthStatus,
ConnectorSyncResult,
IntegrationType,
} from "@/core/resources/connector/index.js";
import { getOAuthStatus } from "@/core/resources/connector/index.js";

const POLL_INTERVAL_MS = 2000;
const POLL_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes

export type OAuthFlowStatus = ConnectorOAuthStatus | "SKIPPED";

type PendingOAuthResult = ConnectorSyncResult & {
redirectUrl: string;
connectionId: string;
};

export function filterPendingOAuth(
results: ConnectorSyncResult[],
): PendingOAuthResult[] {
return results.filter(
(r): r is PendingOAuthResult =>
r.action === "needs_oauth" && !!r.redirectUrl && !!r.connectionId,
);
}

interface OAuthPromptOptions {
skipPrompt?: boolean;
}

/**
* Clack's block() puts stdin in raw mode where Ctrl+C calls process.exit(0)
* directly instead of emitting SIGINT. We override process.exit temporarily
* so Ctrl+C/Esc skips the current connector instead of killing the process.
*/
async function runOAuthFlowWithSkip(
connector: PendingOAuthResult,
): Promise<OAuthFlowStatus> {
await open(connector.redirectUrl);

// Mutated inside the pWaitFor callback — use `as` to prevent TS narrowing
let finalStatus = "PENDING" as OAuthFlowStatus;
let skipped = false;

const s = spinner();

const originalExit = process.exit;
process.exit = (() => {
skipped = true;
s.stop(`${connector.type} skipped`);
}) as unknown as typeof process.exit;

s.start(`Waiting for ${connector.type} authorization... (Esc to skip)`);

try {
await pWaitFor(
async () => {
if (skipped) {
finalStatus = "SKIPPED";
return true;
}
const response = await getOAuthStatus(
connector.type,
connector.connectionId,
);
finalStatus = response.status;
return response.status !== "PENDING";
},
{
interval: POLL_INTERVAL_MS,
timeout: POLL_TIMEOUT_MS,
},
);
} catch (err) {
if (err instanceof TimeoutError) {
finalStatus = "PENDING";
} else {
throw err;
}
} finally {
process.exit = originalExit;

if (!skipped) {
if (finalStatus === "ACTIVE") {
s.stop(`${connector.type} authorization complete`);
} else if (finalStatus === "FAILED") {
s.stop(`${connector.type} authorization failed`);
} else {
s.stop(`${connector.type} authorization timed out`);
}
}
}

return finalStatus;
}

/**
* Prompt the user to authorize connectors that need OAuth.
* Returns a map of connector type → final OAuth status for each connector
* that was processed. An empty map means either nothing needed OAuth or
* the prompt was skipped / declined.
*/
export async function promptOAuthFlows(
pending: PendingOAuthResult[],
options?: OAuthPromptOptions,
): Promise<Map<IntegrationType, OAuthFlowStatus>> {
const outcomes = new Map<IntegrationType, OAuthFlowStatus>();

if (pending.length === 0) {
return outcomes;
}

log.warn(
`${pending.length} connector(s) require authorization in your browser:`,
);
for (const connector of pending) {
log.info(` ${connector.type}: ${theme.styles.dim(connector.redirectUrl)}`);
}

if (options?.skipPrompt) {
return outcomes;
}

const shouldAuth = await confirm({
message: "Open browser to authorize now?",
});

if (isCancel(shouldAuth) || !shouldAuth) {
return outcomes;
}

for (const connector of pending) {
try {
log.info(`\nOpening browser for ${connector.type}...`);
const status = await runOAuthFlowWithSkip(connector);
outcomes.set(connector.type, status);
} catch (err) {
log.error(
`Failed to authorize ${connector.type}: ${err instanceof Error ? err.message : String(err)}`,
);
outcomes.set(connector.type, "FAILED");
}
}

return outcomes;
}
160 changes: 18 additions & 142 deletions src/cli/commands/connectors/push.ts
Original file line number Diff line number Diff line change
@@ -1,107 +1,19 @@
import { confirm, isCancel, log, spinner } from "@clack/prompts";
import { log } from "@clack/prompts";
import { Command } from "commander";
import open from "open";
import pWaitFor, { TimeoutError } from "p-wait-for";
import type { CLIContext } from "@/cli/types.js";
import { runCommand, runTask, theme } from "@/cli/utils/index.js";
import type { RunCommandResult } from "@/cli/utils/runCommand.js";
import { readProjectConfig } from "@/core/index.js";
import {
type ConnectorOAuthStatus,
type ConnectorSyncResult,
getOAuthStatus,
type IntegrationType,
pushConnectors,
} from "@/core/resources/connector/index.js";

const POLL_INTERVAL_MS = 2000;
const POLL_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes

interface OAuthFlowParams {
type: IntegrationType;
redirectUrl: string;
connectionId: string;
}

type OAuthFlowStatus = ConnectorOAuthStatus | "SKIPPED";

interface OAuthFlowResult {
type: IntegrationType;
status: OAuthFlowStatus;
}

/**
* Clack's block() puts stdin in raw mode where Ctrl+C calls process.exit(0)
* directly instead of emitting SIGINT. We override process.exit temporarily
* so Ctrl+C skips the current connector instead of killing the process.
*/
async function runOAuthFlowWithSkip(
params: OAuthFlowParams,
): Promise<OAuthFlowResult> {
await open(params.redirectUrl);

let finalStatus = "PENDING" as OAuthFlowStatus;
let skipped = false;

const s = spinner();

// Clack's spinner calls block() which puts stdin in raw mode — Esc/Ctrl+C
// calls process.exit(0) directly, bypassing SIGINT. Override to skip instead.
const originalExit = process.exit;
process.exit = (() => {
skipped = true;
s.stop(`${params.type} skipped`);
}) as unknown as typeof process.exit;

s.start(`Waiting for ${params.type} authorization... (Esc to skip)`);

try {
await pWaitFor(
async () => {
if (skipped) {
finalStatus = "SKIPPED";
return true;
}
const response = await getOAuthStatus(params.type, params.connectionId);
finalStatus = response.status;
return response.status !== "PENDING";
},
{
interval: POLL_INTERVAL_MS,
timeout: POLL_TIMEOUT_MS,
},
);
} catch (err) {
if (err instanceof TimeoutError) {
finalStatus = "PENDING";
} else {
throw err;
}
} finally {
process.exit = originalExit;

if (!skipped) {
if (finalStatus === "ACTIVE") {
s.stop(`${params.type} authorization complete`);
} else if (finalStatus === "FAILED") {
s.stop(`${params.type} authorization failed`);
} else {
s.stop(`${params.type} authorization timed out`);
}
}
}

return { type: params.type, status: finalStatus };
}

type PendingOAuthResult = ConnectorSyncResult & {
redirectUrl: string;
connectionId: string;
};

function isPendingOAuth(r: ConnectorSyncResult): r is PendingOAuthResult {
return r.action === "needs_oauth" && !!r.redirectUrl && !!r.connectionId;
}
import {
filterPendingOAuth,
type OAuthFlowStatus,
promptOAuthFlows,
} from "./oauth-prompt.js";

function printSummary(
results: ConnectorSyncResult[],
Expand Down Expand Up @@ -137,7 +49,6 @@ function printSummary(
}
}

log.info("");
log.info(theme.styles.bold("Summary:"));

if (synced.length > 0) {
Expand Down Expand Up @@ -178,55 +89,20 @@ async function pushConnectorsAction(): Promise<RunCommandResult> {
},
);

const oauthOutcomes = new Map<IntegrationType, OAuthFlowStatus>();
const needsOAuth = results.filter(isPendingOAuth);
const needsOAuth = filterPendingOAuth(results);
let outroMessage = "Connectors pushed to Base44";

if (needsOAuth.length === 0) {
printSummary(results, oauthOutcomes);
return { outroMessage };
}

log.warn(
`${needsOAuth.length} connector(s) require authorization in your browser:`,
);
for (const connector of needsOAuth) {
log.info(
` '${connector.type}': ${theme.styles.dim(connector.redirectUrl)}`,
);
}

const pending = needsOAuth.map((c) => c.type).join(", ");

if (process.env.CI) {
outroMessage = `Skipped OAuth in CI. Pending: ${pending}. Run 'base44 connectors push' locally to authorize.`;
} else {
const shouldAuth = await confirm({
message: "Open browser to authorize now?",
});

if (isCancel(shouldAuth) || !shouldAuth) {
outroMessage = `Authorization skipped. Pending: ${pending}. Run 'base44 connectors push' again to complete.`;
} else {
for (const connector of needsOAuth) {
try {
log.info(`\nOpening browser for '${connector.type}'...`);

const oauthResult = await runOAuthFlowWithSkip({
type: connector.type,
redirectUrl: connector.redirectUrl,
connectionId: connector.connectionId,
});

oauthOutcomes.set(connector.type, oauthResult.status);
} catch (err) {
log.error(
`Failed to authorize '${connector.type}': ${err instanceof Error ? err.message : String(err)}`,
);
oauthOutcomes.set(connector.type, "FAILED");
}
}
}
const oauthOutcomes = await promptOAuthFlows(needsOAuth, {
skipPrompt: !!process.env.CI,
});

const allAuthorized =
oauthOutcomes.size > 0 &&
[...oauthOutcomes.values()].every((s) => s === "ACTIVE");
if (needsOAuth.length > 0 && !allAuthorized) {
outroMessage = process.env.CI
? "Skipped OAuth in CI. Run 'base44 connectors push' locally or open the links above to authorize."
: "Some connectors still require authorization. Run 'base44 connectors push' or open the links above to authorize.";
}

printSummary(results, oauthOutcomes);
Expand Down
Loading
Loading