From e55ca0279b28900921f5d82e027354dc8a2fb249 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 18 Mar 2024 17:35:29 +0000 Subject: [PATCH] Adding profile support to the v3 CLI (#953) --- packages/cli-v3/src/cli/common.ts | 8 ++- packages/cli-v3/src/cli/index.ts | 34 +--------- packages/cli-v3/src/commands/deploy.ts | 2 +- packages/cli-v3/src/commands/dev.tsx | 63 +++++++---------- packages/cli-v3/src/commands/init.ts | 3 +- packages/cli-v3/src/commands/login.ts | 18 +++-- packages/cli-v3/src/commands/logout.ts | 38 +++++++++-- packages/cli-v3/src/commands/whoami.ts | 59 ++++++++-------- packages/cli-v3/src/utilities/configFiles.ts | 71 ++++++++++++++------ packages/cli-v3/src/utilities/session.ts | 41 +++++------ 10 files changed, 178 insertions(+), 159 deletions(-) diff --git a/packages/cli-v3/src/cli/common.ts b/packages/cli-v3/src/cli/common.ts index 6068fb850..9bf83c61e 100644 --- a/packages/cli-v3/src/cli/common.ts +++ b/packages/cli-v3/src/cli/common.ts @@ -10,12 +10,14 @@ export const CommonCommandOptions = z.object({ apiUrl: z.string().optional(), logLevel: z.enum(["debug", "info", "log", "warn", "error", "none"]).default("log"), skipTelemetry: z.boolean().default(false), + profile: z.string().default("default"), }); export type CommonCommandOptions = z.infer; export function commonOptions(command: Command) { return command + .option("--profile ", "The login profile to use", "default") .option("-a, --api-url ", "Override the API URL", "https://api.trigger.dev") .option( "-l, --log-level ", @@ -25,9 +27,9 @@ export function commonOptions(command: Command) { .option("--skip-telemetry", "Opt-out of sending telemetry"); } -export class SkipLoggingError extends Error {} -export class SkipCommandError extends Error {} -export class OutroCommandError extends SkipCommandError {} +export class SkipLoggingError extends Error { } +export class SkipCommandError extends Error { } +export class OutroCommandError extends SkipCommandError { } export async function handleTelemetry(action: () => Promise) { try { diff --git a/packages/cli-v3/src/cli/index.ts b/packages/cli-v3/src/cli/index.ts index 11cf6406d..faa7b874c 100644 --- a/packages/cli-v3/src/cli/index.ts +++ b/packages/cli-v3/src/cli/index.ts @@ -3,12 +3,10 @@ import { configureDeployCommand } from "../commands/deploy.js"; import { configureDevCommand } from "../commands/dev.js"; import { configureInitCommand } from "../commands/init.js"; import { configureLoginCommand } from "../commands/login.js"; -import { logoutCommand } from "../commands/logout.js"; -import { updateCommand } from "../commands/update.js"; +import { configureLogoutCommand } from "../commands/logout.js"; import { configureWhoamiCommand } from "../commands/whoami.js"; import { COMMAND_NAME } from "../consts.js"; import { getVersion } from "../utilities/getVersion.js"; -import { printInitialBanner } from "../utilities/initialBanner.js"; export const program = new Command(); @@ -19,35 +17,7 @@ program configureLoginCommand(program); configureInitCommand(program); - -program - .command("logout") - .description("Logout of Trigger.dev") - .version(getVersion(), "-v, --version", "Display the version number") - .action(async (options) => { - try { - await printInitialBanner(false); - await logoutCommand(options); - //todo login command - } catch (e) { - //todo error reporting - throw e; - } - }); - configureDevCommand(program); configureDeployCommand(program); - -program - .command("update") - .description( - "Updates all @trigger.dev/* packages to their latest compatible versions or the specified version" - ) - .argument("[path]", "The path to the directory that contains the package.json file", ".") - .option("-t, --to ", "The version to update to (ex: 2.1.4)", "latest") - .action(async (path, options) => { - await printInitialBanner(false); - await updateCommand(path, options); - }); - configureWhoamiCommand(program); +configureLogoutCommand(program); diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 51ee24fae..c06937852 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -134,7 +134,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { intro("Deploying project"); - const authorization = await login({ embedded: true, defaultApiUrl: options.apiUrl }); + const authorization = await login({ embedded: true, defaultApiUrl: options.apiUrl, profile: options.profile }); if (!authorization.ok) { if (authorization.error === "fetch failed") { diff --git a/packages/cli-v3/src/commands/dev.tsx b/packages/cli-v3/src/commands/dev.tsx index 168791ddd..6cda6cd37 100644 --- a/packages/cli-v3/src/commands/dev.tsx +++ b/packages/cli-v3/src/commands/dev.tsx @@ -24,7 +24,7 @@ import { ClientOptions, WebSocket as wsWebSocket } from "ws"; import { z } from "zod"; import * as packageJson from "../../package.json"; import { CliApiClient } from "../apiClient"; -import { CommonCommandOptions } from "../cli/common.js"; +import { CommonCommandOptions, commonOptions, wrapCommandAction } from "../cli/common.js"; import { readConfig } from "../utilities/configFiles"; import { printStandloneInitialBanner } from "../utilities/initialBanner.js"; import { detectPackageNameFromImportPath } from "../utilities/installPackages"; @@ -47,46 +47,31 @@ const DevCommandOptions = CommonCommandOptions.extend({ type DevCommandOptions = z.infer; export function configureDevCommand(program: Command) { - program - .command("dev") - .description("Run your Trigger.dev tasks locally") - .argument("[path]", "The path to the project", ".") - .option( - "-l, --log-level ", - "The log level to use (debug, info, log, warn, error, none)", - "log" - ) - .option( - "-c, --config ", - "The name of the config file, found at [path]", - "trigger.config.mjs" - ) - .option( - "-p, --project-ref ", - "The project ref. Required if there is no config file." - ) - .option("--debugger", "Enable the debugger") - .option("--debug-otel", "Enable OpenTelemetry debugging") - .action(async (path, options) => { - try { - await devCommand(path, options); - } catch (e) { - //todo error reporting - throw e; - } + return commonOptions( + program + .command("dev") + .description("Run your Trigger.dev tasks locally") + .argument("[path]", "The path to the project", ".") + .option( + "-c, --config ", + "The name of the config file, found at [path]", + "trigger.config.mjs" + ) + .option( + "-p, --project-ref ", + "The project ref. Required if there is no config file." + ) + .option("--debugger", "Enable the debugger") + .option("--debug-otel", "Enable OpenTelemetry debugging") + ).action(async (path, options) => { + wrapCommandAction("dev", DevCommandOptions, options, async (opts) => { + await devCommand(path, opts); }); + }); } -export async function devCommand(dir: string, anyOptions: unknown) { - const options = DevCommandOptions.safeParse(anyOptions); - - if (!options.success) { - console.log(fromZodError(options.error).toString()); - - process.exit(1); - } - - const authorization = await isLoggedIn(); +export async function devCommand(dir: string, options: DevCommandOptions) { + const authorization = await isLoggedIn(options.profile); if (!authorization.ok) { if (authorization.error === "fetch failed") { @@ -101,7 +86,7 @@ export async function devCommand(dir: string, anyOptions: unknown) { let watcher; try { - const devInstance = await startDev(dir, options.data, authorization.auth); + const devInstance = await startDev(dir, options, authorization.auth); watcher = devInstance.watcher; const { waitUntilExit } = devInstance.devReactElement; await waitUntilExit(); diff --git a/packages/cli-v3/src/commands/init.ts b/packages/cli-v3/src/commands/init.ts index e8a922c12..6a8a31c05 100644 --- a/packages/cli-v3/src/commands/init.ts +++ b/packages/cli-v3/src/commands/init.ts @@ -80,7 +80,7 @@ async function _initCommand(dir: string, options: InitCommandOptions) { intro("Initializing project"); - const authorization = await login({ embedded: true, defaultApiUrl: options.apiUrl }); + const authorization = await login({ embedded: true, defaultApiUrl: options.apiUrl, profile: options.profile }); if (!authorization.ok) { if (authorization.error === "fetch failed") { @@ -96,6 +96,7 @@ async function _initCommand(dir: string, options: InitCommandOptions) { "cli.userId": authorization.userId, "cli.email": authorization.email, "cli.config.apiUrl": authorization.auth.apiUrl, + "cli.config.profile": authorization.profile, }); if (!options.overrideConfig) { diff --git a/packages/cli-v3/src/commands/login.ts b/packages/cli-v3/src/commands/login.ts index 60a707a05..9299974a7 100644 --- a/packages/cli-v3/src/commands/login.ts +++ b/packages/cli-v3/src/commands/login.ts @@ -14,11 +14,12 @@ import { wrapCommandAction, } from "../cli/common.js"; import { chalkLink } from "../utilities/colors.js"; -import { readAuthConfigFile, writeAuthConfigFile } from "../utilities/configFiles.js"; +import { readAuthConfigProfile, writeAuthConfigProfile } from "../utilities/configFiles.js"; import { getVersion } from "../utilities/getVersion.js"; import { printInitialBanner } from "../utilities/initialBanner.js"; import { LoginResult } from "../utilities/session.js"; import { whoAmI } from "./whoami.js"; +import { logger } from "../utilities/logger.js"; export const LoginCommandOptions = CommonCommandOptions.extend({ apiUrl: z.string(), @@ -48,12 +49,13 @@ export async function loginCommand(options: unknown) { } async function _loginCommand(options: LoginCommandOptions) { - return login({ defaultApiUrl: options.apiUrl, embedded: false }); + return login({ defaultApiUrl: options.apiUrl, embedded: false, profile: options.profile }); } export type LoginOptions = { defaultApiUrl?: string; embedded?: boolean; + profile?: string; }; export async function login(options?: LoginOptions): Promise { @@ -63,16 +65,17 @@ export async function login(options?: LoginOptions): Promise { span.setAttributes({ "cli.config.apiUrl": opts.defaultApiUrl, + "cli.options.profile": opts.profile, }); if (!opts.embedded) { intro("Logging in to Trigger.dev"); } - const authConfig = readAuthConfigFile(); + const authConfig = readAuthConfigProfile(options?.profile); if (authConfig && authConfig.accessToken) { - const whoAmIResult = await whoAmI(undefined, opts.embedded); + const whoAmIResult = await whoAmI({ profile: options?.profile ?? "default", skipTelemetry: !span.isRecording(), logLevel: logger.loggerLevel }, opts.embedded); if (!whoAmIResult.success) { throw new Error(whoAmIResult.error); @@ -106,6 +109,7 @@ export async function login(options?: LoginOptions): Promise { return { ok: true as const, + profile: options?.profile ?? "default", userId: whoAmIResult.data.userId, email: whoAmIResult.data.email, dashboardUrl: whoAmIResult.data.dashboardUrl, @@ -126,6 +130,7 @@ export async function login(options?: LoginOptions): Promise { return { ok: true as const, + profile: options?.profile ?? "default", userId: whoAmIResult.data.userId, email: whoAmIResult.data.email, dashboardUrl: whoAmIResult.data.dashboardUrl, @@ -170,9 +175,9 @@ export async function login(options?: LoginOptions): Promise { getPersonalAccessTokenSpinner.stop(`Logged in with token ${indexResult.obfuscatedToken}`); - writeAuthConfigFile({ accessToken: indexResult.token, apiUrl: opts.defaultApiUrl }); + writeAuthConfigProfile({ accessToken: indexResult.token, apiUrl: opts.defaultApiUrl }, options?.profile); - const whoAmIResult = await whoAmI(undefined, opts.embedded); + const whoAmIResult = await whoAmI({ profile: options?.profile ?? "default", skipTelemetry: !span.isRecording(), logLevel: logger.loggerLevel }, opts.embedded); if (!whoAmIResult.success) { throw new Error(whoAmIResult.error); @@ -188,6 +193,7 @@ export async function login(options?: LoginOptions): Promise { return { ok: true as const, + profile: options?.profile ?? "default", userId: whoAmIResult.data.userId, email: whoAmIResult.data.email, dashboardUrl: whoAmIResult.data.dashboardUrl, diff --git a/packages/cli-v3/src/commands/logout.ts b/packages/cli-v3/src/commands/logout.ts index 3ccb136d2..2e8aef653 100644 --- a/packages/cli-v3/src/commands/logout.ts +++ b/packages/cli-v3/src/commands/logout.ts @@ -1,15 +1,41 @@ -import { readAuthConfigFile, writeAuthConfigFile } from "../utilities/configFiles.js"; +import { Command } from "commander"; +import { readAuthConfigProfile, writeAuthConfigProfile } from "../utilities/configFiles.js"; import { logger } from "../utilities/logger.js"; +import { CommonCommandOptions, commonOptions, handleTelemetry, wrapCommandAction } from "../cli/common.js"; +import { printInitialBanner } from "../utilities/initialBanner.js"; +import { z } from "zod"; -export async function logoutCommand(options: any) { - const config = readAuthConfigFile(); +const LogoutCommandOptions = CommonCommandOptions; + +type LogoutCommandOptions = z.infer; + +export function configureLogoutCommand(program: Command) { + return commonOptions(program + .command("logout") + .description("Logout of Trigger.dev")) + .action(async (options) => { + await handleTelemetry(async () => { + await printInitialBanner(false); + await logoutCommand(options); + }); + }); +} + +export async function logoutCommand(options: unknown) { + return await wrapCommandAction("logoutCommand", LogoutCommandOptions, options, async (opts) => { + return await logout(opts); + }); +} + +export async function logout(options: LogoutCommandOptions) { + const config = readAuthConfigProfile(options.profile); if (!config?.accessToken) { - logger.info("You are already logged out"); + logger.info(`You are already logged out [${options.profile ?? "default"}]`); return; } - writeAuthConfigFile({ ...config, accessToken: undefined, apiUrl: undefined }); + writeAuthConfigProfile({ ...config, accessToken: undefined, apiUrl: undefined }, options.profile); - logger.info("Logged out"); + logger.info(`Logged out of Trigger.dev [${options.profile ?? "default"}]`); } diff --git a/packages/cli-v3/src/commands/whoami.ts b/packages/cli-v3/src/commands/whoami.ts index ef8b53373..f5d9e0494 100644 --- a/packages/cli-v3/src/commands/whoami.ts +++ b/packages/cli-v3/src/commands/whoami.ts @@ -1,68 +1,65 @@ -import { note, spinner } from "@clack/prompts"; +import { intro, note, spinner } from "@clack/prompts"; import { chalkLink } from "../utilities/colors.js"; import { logger } from "../utilities/logger.js"; import { isLoggedIn } from "../utilities/session.js"; import { Command } from "commander"; import { printInitialBanner } from "../utilities/initialBanner.js"; -import { CommonCommandOptions } from "../cli/common.js"; +import { CommonCommandOptions, commonOptions, handleTelemetry, wrapCommandAction } from "../cli/common.js"; import { z } from "zod"; import { CliApiClient } from "../apiClient.js"; type WhoAmIResult = | { - success: true; - data: { - userId: string; - email: string; - dashboardUrl: string; - }; - } - | { - success: false; - error: string; + success: true; + data: { + userId: string; + email: string; + dashboardUrl: string; }; + } + | { + success: false; + error: string; + }; const WhoamiCommandOptions = CommonCommandOptions; type WhoamiCommandOptions = z.infer; export function configureWhoamiCommand(program: Command) { - program + return commonOptions(program .command("whoami") - .description("display the current logged in user and project details") - .option( - "-l, --log-level ", - "The log level to use (debug, info, log, warn, error, none)", - "log" - ) + .description("display the current logged in user and project details")) .action(async (options) => { - try { - await printInitialBanner(); - await whoAmI(WhoamiCommandOptions.parse(options)); - } catch (e) { - throw e; - } + await handleTelemetry(async () => { + await printInitialBanner(false); + await whoAmICommand(options); + }); }); } +export async function whoAmICommand(options: unknown) { + return await wrapCommandAction("whoamiCommand", WhoamiCommandOptions, options, async (opts) => { + return await whoAmI(opts); + }); +} + export async function whoAmI( options?: WhoamiCommandOptions, embedded: boolean = false ): Promise { - if (options?.logLevel) { - logger.loggerLevel = options?.logLevel; - } + intro(`Displaying your account details [${options?.profile ?? "default"}]`); const loadingSpinner = spinner(); loadingSpinner.start("Checking your account details"); - const authentication = await isLoggedIn(); + const authentication = await isLoggedIn(options?.profile); if (!authentication.ok) { if (authentication.error === "fetch failed") { loadingSpinner.stop("Fetch failed. Platform down?"); } else { - loadingSpinner.stop("You must login first. Use `trigger.dev login` to login."); + loadingSpinner.stop(`You must login first. Use \`trigger.dev login --profile ${options?.profile ?? "default"}\` to login.`); } return { @@ -90,7 +87,7 @@ export async function whoAmI( Email: ${userData.data.email} URL: ${chalkLink(authentication.auth.apiUrl)} `, - "Account details" + `Account details [${authentication.profile}]` ); } else { loadingSpinner.stop(`Retrieved your account details for ${userData.data.email}`); diff --git a/packages/cli-v3/src/utilities/configFiles.ts b/packages/cli-v3/src/utilities/configFiles.ts index c3500ad57..a2f87f0bb 100644 --- a/packages/cli-v3/src/utilities/configFiles.ts +++ b/packages/cli-v3/src/utilities/configFiles.ts @@ -24,11 +24,53 @@ export const UserAuthConfigSchema = z.object({ export type UserAuthConfig = z.infer; +const UserAuthConfigFileSchema = z.record(UserAuthConfigSchema); + +type UserAuthConfigFile = z.infer; + function getAuthConfigFilePath() { return path.join(getGlobalConfigFolderPath(), "default.json"); } -export function writeAuthConfigFile(config: UserAuthConfig) { +export function writeAuthConfigProfile(config: UserAuthConfig, profile: string = "default") { + const existingConfig = readAuthConfigFile() || {}; + + existingConfig[profile] = config; + + writeAuthConfigFile(existingConfig); +} + +export function readAuthConfigProfile(profile: string = "default"): UserAuthConfig | undefined { + try { + const authConfigFilePath = getAuthConfigFilePath(); + + logger.debug(`Reading auth config file`, { authConfigFilePath }); + + const json = readJSONFileSync(authConfigFilePath); + const parsed = UserAuthConfigFileSchema.parse(json); + return parsed[profile]; + } catch (error) { + logger.debug(`Error reading auth config file: ${error}`); + return undefined; + } +} + +function readAuthConfigFile(): UserAuthConfigFile | undefined { + try { + const authConfigFilePath = getAuthConfigFilePath(); + + logger.debug(`Reading auth config file`, { authConfigFilePath }); + + const json = readJSONFileSync(authConfigFilePath); + const parsed = UserAuthConfigFileSchema.parse(json); + return parsed; + } catch (error) { + logger.debug(`Error reading auth config file: ${error}`); + return undefined; + } +} + +function writeAuthConfigFile(config: UserAuthConfigFile) { const authConfigFilePath = getAuthConfigFilePath(); mkdirSync(path.dirname(authConfigFilePath), { recursive: true, @@ -38,19 +80,6 @@ export function writeAuthConfigFile(config: UserAuthConfig) { }); } -export function readAuthConfigFile(): UserAuthConfig | undefined { - try { - const authConfigFilePath = getAuthConfigFilePath(); - - const json = readJSONFileSync(authConfigFilePath); - const parsed = UserAuthConfigSchema.parse(json); - return parsed; - } catch (error) { - logger.debug(`Error reading auth config file: ${error}`); - return undefined; - } -} - async function getConfigPath(dir: string, fileName?: string): Promise { return await findUp(fileName ? [fileName] : CONFIG_FILES, { cwd: dir }); } @@ -62,14 +91,14 @@ export type ReadConfigOptions = { export type ReadConfigResult = | { - status: "file"; - config: ResolvedConfig; - path: string; - } + status: "file"; + config: ResolvedConfig; + path: string; + } | { - status: "in-memory"; - config: ResolvedConfig; - }; + status: "in-memory"; + config: ResolvedConfig; + }; export async function readConfig( dir: string, diff --git a/packages/cli-v3/src/utilities/session.ts b/packages/cli-v3/src/utilities/session.ts index 62cc3dfb5..7f33d30a5 100644 --- a/packages/cli-v3/src/utilities/session.ts +++ b/packages/cli-v3/src/utilities/session.ts @@ -1,34 +1,35 @@ import { recordSpanException } from "@trigger.dev/core/v3"; import { CliApiClient } from "../apiClient.js"; -import { readAuthConfigFile } from "./configFiles.js"; +import { readAuthConfigProfile } from "./configFiles.js"; import { getTracer } from "../telemetry/tracing.js"; const tracer = getTracer(); export type LoginResult = | { - ok: true; - userId: string; - email: string; - dashboardUrl: string; - auth: { - apiUrl: string; - accessToken: string; - }; - } - | { - ok: false; - error: string; - auth?: { - apiUrl: string; - accessToken: string; - }; + ok: true; + profile: string, + userId: string; + email: string; + dashboardUrl: string; + auth: { + apiUrl: string; + accessToken: string; }; + } + | { + ok: false; + error: string; + auth?: { + apiUrl: string; + accessToken: string; + }; + }; -export async function isLoggedIn(): Promise { +export async function isLoggedIn(profile: string = "default"): Promise { return await tracer.startActiveSpan("isLoggedIn", async (span) => { try { - const config = readAuthConfigFile(); + const config = readAuthConfigProfile(profile); if (!config?.accessToken || !config?.apiUrl) { span.recordException(new Error("You must login first")); @@ -57,12 +58,14 @@ export async function isLoggedIn(): Promise { "login.userId": userData.data.userId, "login.email": userData.data.email, "login.dashboardUrl": userData.data.dashboardUrl, + "login.profile": profile, }); span.end(); return { ok: true as const, + profile, userId: userData.data.userId, email: userData.data.email, dashboardUrl: userData.data.dashboardUrl,