UNPKG

trigger.dev

Version:

A Command-Line Interface for Trigger.dev projects

651 lines (646 loc) • 30.3 kB
import { intro, isCancel, log, outro, select, text } from "@clack/prompts"; import { recordSpanException } from "@trigger.dev/core/v3/workers"; import open from "open"; import pRetry, { AbortError } from "p-retry"; import { z } from "zod"; import { CliApiClient } from "../apiClient.js"; import { CommonCommandOptions, SkipLoggingError, commonOptions, handleTelemetry, tracer, wrapCommandAction, } from "../cli/common.js"; import { chalkLink, prettyError } from "../utilities/cliOutput.js"; import { deletePendingAuthorization, deletePendingProfileName, readAuthConfigProfile, writeAuthConfigProfile, writeAuthConfigCurrentProfileName, writePendingAuthorization, writePendingProfileName, } from "../utilities/configFiles.js"; import { printInitialBanner } from "../utilities/initialBanner.js"; import { awaitAndDisplayPlatformNotification, fetchPlatformNotification, } from "../utilities/platformNotifications.js"; import { whoAmI } from "./whoami.js"; import { logger } from "../utilities/logger.js"; import { spinner } from "../utilities/windows.js"; import { isLinuxServer } from "../utilities/linux.js"; import { createPkcePair, listenForApprovalCallback, LoginTimedOutError, waitForApprovalCode, } from "../utilities/loginApproval.js"; import { VERSION } from "../version.js"; import { env, isCI } from "std-env"; import { CLOUD_API_URL } from "../consts.js"; import { validateAccessToken, NotPersonalAccessTokenError, NotAccessTokenError, } from "../utilities/accessTokens.js"; import { links } from "@trigger.dev/core/v3"; const LoginCommandOptions = CommonCommandOptions.extend({ browser: z.boolean().default(true), email: z.string().email().optional(), name: z.string().trim().min(3).max(50).optional(), cancelPending: z.boolean().default(false), wait: z.boolean().default(true), code: z.string().optional(), }); export function configureLoginCommand(program) { return commonOptions(program .command("login") .summary("Login with Trigger.dev so you can perform authenticated actions") .description(`Login with Trigger.dev so you can perform authenticated actions. Examples: # Interactive login (opens browser) $ trigger.dev login # Headless / agent (print URL and resume later) $ trigger.dev login --email you@example.com --name "Alex Smith" --no-browser --no-wait $ trigger.dev login --no-browser $ trigger.dev login --code ABCD-EFGH-JKLM # Login to a named profile $ trigger.dev login --profile staging`) .option("--no-browser", "Don't automatically open the browser; print the URL only") .option("--email <email>", "Prefill the email address on the login page") .option("--name <name>", "Set your full name after authorization") .option("--cancel-pending", "Cancel a pending login and keep the current account") .option("--no-wait", "Save the authorization and exit so login can be resumed later") .option("--code <code>", "Finish a pending login with the code shown in your browser")) .version(VERSION, "-v, --version", "Display the version number") .action(async (options) => { await handleTelemetry(async () => { await printInitialBanner(false, options.profile); await loginCommand(options); }); }); } async function loginCommand(options) { return await wrapCommandAction("loginCommand", LoginCommandOptions, options, async (opts) => { return await _loginCommand(opts); }); } async function _loginCommand(options) { return login({ defaultApiUrl: options.apiUrl, embedded: false, profile: options.profile, browser: options.browser, email: options.email, name: options.name, cancelPending: options.cancelPending, wait: options.wait, code: options.code, }); } export const PENDING_AUTHORIZATION_ERROR = "A login is pending. Run `trigger.dev login` to complete it, or `trigger.dev login --cancel-pending` to keep using the current account."; export function resolveLoginOptions(options) { return { embedded: false, silent: false, wait: true, ...options, defaultApiUrl: options?.defaultApiUrl ?? CLOUD_API_URL, }; } export async function login(options) { return await tracer.startActiveSpan("login", async (span) => { try { const opts = resolveLoginOptions(options); span.setAttributes({ "cli.config.apiUrl": opts.defaultApiUrl, "cli.options.profile": opts.profile, }); if (!opts.embedded) { intro("Logging in to Trigger.dev"); } const authConfig = readAuthConfigProfile(options?.profile); if (opts.cancelPending) { if (authConfig?.pendingAuthorization) { deletePendingAuthorization(options?.profile); if (!opts.embedded) { outro("Pending login cancelled. Your current account is unchanged."); } } else if (!opts.embedded) { outro("There is no pending login to cancel."); } span.end(); return { ok: false, error: "Pending login cancelled" }; } const accessTokenFromEnv = env.TRIGGER_ACCESS_TOKEN; if (accessTokenFromEnv) { const validationResult = validateAccessToken(accessTokenFromEnv); if (!validationResult.success) { // We deliberately don't surface the existence of organization access tokens to the user for now, as they're only used internally. // Once we expose them in the application, we should also communicate that option here. throw new NotAccessTokenError("Your TRIGGER_ACCESS_TOKEN is not a Personal Access Token, they start with 'tr_pat_'. You can generate one here: https://cloud.trigger.dev/account/tokens"); } const auth = { accessToken: accessTokenFromEnv, apiUrl: env.TRIGGER_API_URL ?? opts.defaultApiUrl ?? CLOUD_API_URL, }; const apiClient = new CliApiClient(auth.apiUrl, auth.accessToken); const userData = await apiClient.whoAmI(); if (!userData.success) { throw new Error(userData.error); } return { ok: true, profile: options?.profile ?? "default", userId: userData.data.userId, email: userData.data.email, dashboardUrl: userData.data.dashboardUrl, auth: { accessToken: auth.accessToken, tokenType: validationResult.type, apiUrl: auth.apiUrl, }, }; } const configuredApiUrl = options?.defaultApiUrl ?? authConfig?.pendingAuthorization?.apiUrl ?? authConfig?.apiUrl ?? CLOUD_API_URL; const apiUrlChanged = options?.defaultApiUrl !== undefined && options.defaultApiUrl !== authConfig?.apiUrl; const resumableAuthorization = isPendingAuthorizationValid(authConfig?.pendingAuthorization, configuredApiUrl) ? authConfig.pendingAuthorization : undefined; if (authConfig?.pendingAuthorization && !resumableAuthorization) { deletePendingAuthorization(options?.profile); } if (opts.code && resumableAuthorization?.approval !== "paste") { const error = "There's no pending login to finish with this code. Run `trigger.dev login` to start one."; if (!opts.embedded) { outro(error); } span.end(); return { ok: false, error }; } if (opts.embedded && resumableAuthorization) { span.end(); return { ok: false, error: PENDING_AUTHORIZATION_ERROR }; } if (resumableAuthorization && authConfig?.accessToken && !opts.embedded && !opts.code && opts.browser !== false && process.stdin.isTTY) { const pendingChoice = await select({ message: "Another login is awaiting approval.", options: [ { value: "resume", label: "Complete account switch" }, { value: "cancel", label: "Cancel switch and keep current account" }, { value: "exit", label: "Exit" }, ], initialValue: "resume", }); if (pendingChoice === "cancel") { deletePendingAuthorization(options?.profile); outro("Pending login cancelled. Your current account is unchanged."); span.end(); return { ok: false, error: "Pending login cancelled" }; } if (pendingChoice !== "resume") { outro("Pending login left unchanged."); span.end(); return { ok: false, error: "Authorization pending" }; } } if (authConfig?.accessToken && !resumableAuthorization && !apiUrlChanged) { await completeProfileIfNeeded({ apiClient: new CliApiClient(configuredApiUrl, authConfig.accessToken), name: authConfig.pendingProfileName, profile: options?.profile, }); const whoAmIResult = await whoAmI({ profile: options?.profile ?? "default", skipTelemetry: !span.isRecording(), logLevel: logger.loggerLevel, }, true, opts.silent); if (!whoAmIResult.success) { prettyError("Unable to validate existing personal access token", whoAmIResult.error); if (!opts.embedded) { outro(`Login failed using stored token. To fix, first logout using \`trigger.dev logout${options?.profile ? ` --profile ${options.profile}` : ""}\` and then try again.`); throw new SkipLoggingError(whoAmIResult.error); } else { throw new Error(whoAmIResult.error); } } else { if (opts.embedded) { span.setAttributes({ "cli.userId": whoAmIResult.data.userId, "cli.email": whoAmIResult.data.email, "cli.config.apiUrl": authConfig.apiUrl ?? opts.defaultApiUrl, }); span.end(); return { ok: true, profile: options?.profile ?? "default", userId: whoAmIResult.data.userId, email: whoAmIResult.data.email, dashboardUrl: whoAmIResult.data.dashboardUrl, auth: { accessToken: authConfig.accessToken, apiUrl: authConfig.apiUrl ?? opts.defaultApiUrl, tokenType: "personal", }, }; } if (opts.wait) { const continueOption = await select({ message: "You are already logged in.", options: [ { value: false, label: "Exit", }, { value: true, label: "Login with a different account", }, ], initialValue: false, }); if (continueOption !== true) { outro("Already logged in"); span.setAttributes({ "cli.userId": whoAmIResult.data.userId, "cli.email": whoAmIResult.data.email, "cli.config.apiUrl": authConfig.apiUrl ?? opts.defaultApiUrl, }); span.end(); return { ok: true, profile: options?.profile ?? "default", userId: whoAmIResult.data.userId, email: whoAmIResult.data.email, dashboardUrl: whoAmIResult.data.dashboardUrl, auth: { accessToken: authConfig.accessToken, apiUrl: authConfig.apiUrl ?? opts.defaultApiUrl, tokenType: "personal", }, }; } } } } if (isCI && !resumableAuthorization) { const apiUrl = env.TRIGGER_API_URL ?? authConfig?.apiUrl ?? opts.defaultApiUrl ?? CLOUD_API_URL; const isSelfHosted = apiUrl !== CLOUD_API_URL; // This is fine, as the api URL will generally be the same as the dashboard URL for self-hosted instances const dashboardUrl = isSelfHosted ? apiUrl : "https://cloud.trigger.dev"; throw new Error(`Authentication required in CI environment. Please set the TRIGGER_ACCESS_TOKEN environment variable with a Personal Access Token. - You can generate one here: ${dashboardUrl}/account/tokens - For more information, see: ${links.docs.gitHubActions.personalAccessToken}`); } if (opts.embedded) { log.step("You must login to continue."); } const apiUrl = configuredApiUrl; const apiClient = new CliApiClient(apiUrl); const canOpenBrowser = opts.browser !== false && !(await isLinuxServer()); const callback = !resumableAuthorization && opts.wait && canOpenBrowser ? await listenForApprovalCallback("Return to your terminal to continue.") : undefined; try { const { pendingAuthorization, resumed } = await getOrCreatePendingAuthorization({ apiClient, apiUrl, email: opts.email, name: opts.name, profile: options?.profile, existing: resumableAuthorization, callbackPort: callback?.port, }); try { const indexResult = await getTokenForPendingAuthorization({ apiClient, pendingAuthorization, resumed, callback, browser: opts.browser, wait: opts.wait, code: opts.code, embedded: opts.embedded, profile: options?.profile, }); if (!indexResult) { span.end(); return { ok: false, error: "Authorization pending", }; } writeAuthConfigProfile({ accessToken: indexResult.token, apiUrl, pendingProfileName: pendingAuthorization.name, }, options?.profile); await completeProfileIfNeeded({ apiClient: new CliApiClient(apiUrl, indexResult.token), name: pendingAuthorization.name, profile: options?.profile, }); // Only fetch notifications for standalone login, not when embedded in dev // (dev.ts handles its own notification fetch to avoid double counting) const notificationPromise = opts.embedded ? undefined : fetchPlatformNotification({ apiClient: new CliApiClient(apiUrl, indexResult.token), }); const whoAmIResult = await whoAmI({ profile: options?.profile ?? "default", skipTelemetry: !span.isRecording(), logLevel: logger.loggerLevel, }, opts.embedded); if (!whoAmIResult.success) { throw new Error(whoAmIResult.error); } const profileName = options?.profile ?? "default"; // Set this profile as the current default writeAuthConfigCurrentProfileName(profileName); if (opts.embedded) { log.step("Logged in successfully"); } else { outro("Logged in successfully"); } await awaitAndDisplayPlatformNotification(notificationPromise); span.end(); return { ok: true, profile: profileName, userId: whoAmIResult.data.userId, email: whoAmIResult.data.email, dashboardUrl: whoAmIResult.data.dashboardUrl, auth: { accessToken: indexResult.token, apiUrl, tokenType: "personal", }, }; } catch (e) { if (e instanceof AbortError || e instanceof LoginTimedOutError) { deletePendingAuthorization(options?.profile); log.error(e.message); } else if (e instanceof ApprovalCodeRejectedError) { log.error(e.message); } recordSpanException(span, e); span.end(); return { ok: false, error: e instanceof Error ? e.message : String(e), }; } } finally { callback?.close(); } } catch (e) { recordSpanException(span, e); span.end(); if (options?.embedded) { if (e instanceof NotPersonalAccessTokenError) { throw e; } return { ok: false, error: e instanceof Error ? e.message : String(e), }; } throw e; } }); } export const AUTHORIZATION_CODE_TTL_MS = 10 * 60 * 1000; export function addEmailToAuthorizationUrl(url, email) { if (!email) return url; const authorizationUrl = new URL(url); authorizationUrl.searchParams.set("email", email); return authorizationUrl.href; } export function isPendingAuthorizationValid(pendingAuthorization, apiUrl, now = Date.now()) { if (!pendingAuthorization || pendingAuthorization.apiUrl !== apiUrl || pendingAuthorization.approval === "callback") { return false; } const createdAt = Date.parse(pendingAuthorization.createdAt); return Number.isFinite(createdAt) && now - createdAt < AUTHORIZATION_CODE_TTL_MS; } async function getOrCreatePendingAuthorization({ apiClient, apiUrl, email, name, profile, existing, callbackPort, }) { if (isPendingAuthorizationValid(existing, apiUrl)) { const pendingAuthorization = { ...existing, url: addEmailToAuthorizationUrl(existing.url, email), name: name ?? existing.name, }; writePendingAuthorization(pendingAuthorization, profile); return { pendingAuthorization, resumed: true }; } if (existing) { deletePendingAuthorization(profile); } const { codeVerifier, codeChallenge } = createPkcePair(); const authorizationCodeResult = await createAuthorizationCode(apiClient, { codeChallenge, codeChallengeMethod: "S256", callbackPort, }); const pendingAuthorization = { authorizationCode: authorizationCodeResult.authorizationCode, url: addEmailToAuthorizationUrl(authorizationCodeResult.url, email), apiUrl, createdAt: new Date().toISOString(), name, codeVerifier: authorizationCodeResult.approval ? codeVerifier : undefined, approval: authorizationCodeResult.approval, }; if (pendingAuthorization.approval !== "callback") { writePendingAuthorization(pendingAuthorization, profile); } return { pendingAuthorization, resumed: false }; } /** The server rejected an approval code. The pending login is kept so the user can retry it. */ class ApprovalCodeRejectedError extends Error { } /** * Waits for the pending login to be approved and returns its token, or undefined when the login * is left pending for a later `trigger.dev login`. * * Logins the server binds to this client finish with the approval code that only the approving * browser receives: by redirect to `callback`, or pasted by the user. Logins against a server that * predates approval codes poll for the token with the authorization code alone, once a second for * about five minutes, inside the code's ten-minute validity. */ async function getTokenForPendingAuthorization({ apiClient, pendingAuthorization, resumed, callback, browser, wait, code, embedded, profile, }) { const { approval } = pendingAuthorization; const profileFlag = profile ? ` --profile ${profile}` : ""; if (resumed && !approval) { const resumedToken = await getPersonalAccessTokenIfReady(apiClient, pendingAuthorization.authorizationCode, profile); if (resumedToken) { log.success(`Logged in with token ${resumedToken.obfuscatedToken}`); return resumedToken; } } if (approval === "paste" && code) { const token = await exchangeApprovalCode(apiClient, pendingAuthorization, code); log.success(`Logged in with token ${token.obfuscatedToken}`); return token; } log.step(`Please visit the following URL to login:\n${chalkLink(pendingAuthorization.url)}`); if (browser === false) { log.message("Browser auto-open disabled. Visit the URL above to login."); } else if (await isLinuxServer()) { log.message("Please install `xdg-utils` to automatically open the login URL."); } else { await open(pendingAuthorization.url); } const canPrompt = approval === "paste" && process.stdin.isTTY; if (!wait || (approval === "paste" && !canPrompt)) { if (!embedded) { outro(approval === "paste" ? `Authorization saved. After approving access, run \`trigger.dev login --code <code>${profileFlag}\` with the code shown in your browser.` : `Authorization saved. Run \`trigger.dev login${profileFlag}\` again after approving access.`); } return undefined; } if (approval === "paste") { for (;;) { const pasted = await text({ message: "After approving, paste the code shown in your browser", validate: (value) => (value?.trim() ? undefined : "Enter the code from your browser"), }); if (isCancel(pasted)) { return undefined; } try { const token = await exchangeApprovalCode(apiClient, pendingAuthorization, pasted); log.success(`Logged in with token ${token.obfuscatedToken}`); return token; } catch (error) { if (!(error instanceof ApprovalCodeRejectedError)) { throw error; } log.error(error.message); } } } const getPersonalAccessTokenSpinner = spinner(); getPersonalAccessTokenSpinner.start("Waiting for you to login"); try { const token = approval === "callback" && callback ? await exchangeApprovalCode(apiClient, pendingAuthorization, await waitForApprovalCode(callback, Date.parse(pendingAuthorization.createdAt) + AUTHORIZATION_CODE_TTL_MS - Date.now(), `Login timed out waiting for the browser. If your browser can't reach this machine, run \`trigger.dev login --no-browser${profileFlag}\` to finish with a code instead.`)) : await pRetry(() => getPersonalAccessToken(apiClient, pendingAuthorization.authorizationCode), { factor: 1, retries: 300, minTimeout: 1000 }); getPersonalAccessTokenSpinner.stop(`Logged in with token ${token.obfuscatedToken}`); return token; } catch (error) { getPersonalAccessTokenSpinner.stop(`Failed to get access token`); throw error; } } /** Exchanges an approved, client-bound authorization code for its token. */ export async function exchangeApprovalCode(apiClient, pendingAuthorization, approvalCode) { const result = await apiClient.getPersonalAccessToken(pendingAuthorization.authorizationCode, { codeVerifier: pendingAuthorization.codeVerifier, approvalCode: approvalCode.trim(), }); if (!result.success) { if (result.statusCode === 400) { throw new ApprovalCodeRejectedError(`That code didn't work: ${result.error}. Check it matches the code shown in your browser.`); } throw new AbortError(result.error); } if (!result.data.token) { throw new ApprovalCodeRejectedError("This login hasn't been approved in the browser yet."); } return { token: result.data.token.token, obfuscatedToken: result.data.token.obfuscatedToken, }; } async function completeProfileIfNeeded({ apiClient, name, profile, }) { if (!name) { return; } try { const result = await apiClient.completeProfile({ name }); if (!result.success) { writePendingProfileName(name, profile); log.warn(`Logged in, but failed to save your name: ${result.error}`); return; } deletePendingProfileName(profile); if (result.data.updated) { log.success("Saved your account details"); } } catch (error) { writePendingProfileName(name, profile); log.warn(`Logged in, but failed to save your name: ${error instanceof Error ? error.message : String(error)}`); } } class AuthorizationPendingError extends Error { } async function getPersonalAccessTokenIfReady(apiClient, authorizationCode, profile) { try { return await getPersonalAccessToken(apiClient, authorizationCode); } catch (error) { if (error instanceof AbortError) { deletePendingAuthorization(profile); throw error; } if (error instanceof AuthorizationPendingError) { return undefined; } throw error; } } export async function getPersonalAccessToken(apiClient, authorizationCode) { return await tracer.startActiveSpan("getPersonalAccessToken", async (span) => { try { const token = await apiClient.getPersonalAccessToken(authorizationCode); if (!token.success) { // A 429 from the per-code poll rate limiter is transient. Keep polling // rather than abandoning the login. if (token.statusCode === 429) { throw new AuthorizationPendingError(token.error); } throw new AbortError(token.error); } if (!token.data.token) { throw new AuthorizationPendingError("No token found yet"); } span.end(); return { token: token.data.token.token, obfuscatedToken: token.data.token.obfuscatedToken, }; } catch (e) { if (e instanceof AbortError) { recordSpanException(span, e); } span.end(); throw e; } }); } async function createAuthorizationCode(apiClient, body) { return await tracer.startActiveSpan("createAuthorizationCode", async (span) => { try { //generate authorization code const createAuthCodeSpinner = spinner(); createAuthCodeSpinner.start("Creating authorization code"); const authorizationCodeResult = await apiClient.createAuthorizationCode(body); if (!authorizationCodeResult.success) { createAuthCodeSpinner.stop(`Failed to create authorization code\n${authorizationCodeResult.error}`); throw new SkipLoggingError(`Failed to create authorization code\n${authorizationCodeResult.error}`); } createAuthCodeSpinner.stop("Created authorization code"); span.end(); return authorizationCodeResult.data; } catch (e) { recordSpanException(span, e); span.end(); throw e; } }); } //# sourceMappingURL=login.js.map