trigger.dev
Version:
A Command-Line Interface for Trigger.dev (v3) projects
428 lines • 19.4 kB
JavaScript
import { intro, isCancel, log, outro, select, text } from "@clack/prompts";
import { context, trace } from "@opentelemetry/api";
import { flattenAttributes } from "@trigger.dev/core/v3";
import { recordSpanException } from "@trigger.dev/core/v3/workers";
import chalk from "chalk";
import { Option as CommandOption } from "commander";
import { applyEdits, findNodeAtLocation, getNodeValue, modify, parseTree } from "jsonc-parser";
import { writeFile } from "node:fs/promises";
import { join, relative, resolve } from "node:path";
import { addDependency, addDevDependency, detectPackageManager } from "nypm";
import { resolveTSConfig } from "pkg-types";
import { z } from "zod";
import { CliApiClient } from "../apiClient.js";
import { CommonCommandOptions, OutroCommandError, SkipCommandError, SkipLoggingError, commonOptions, handleTelemetry, tracer, wrapCommandAction, } from "../cli/common.js";
import { loadConfig } from "../config.js";
import { cliLink } from "../utilities/cliOutput.js";
import { createFileFromTemplate, generateTemplateUrl, } from "../utilities/createFileFromTemplate.js";
import { createFile, pathExists, readFile } from "../utilities/fileSystem.js";
import { printStandloneInitialBanner } from "../utilities/initialBanner.js";
import { logger } from "../utilities/logger.js";
import { spinner } from "../utilities/windows.js";
import { login } from "./login.js";
const InitCommandOptions = CommonCommandOptions.extend({
projectRef: z.string().optional(),
overrideConfig: z.boolean().default(false),
tag: z.string().default("latest"),
skipPackageInstall: z.boolean().default(false),
runtime: z.string().default("node"),
pkgArgs: z.string().optional(),
gitRef: z.string().default("main"),
javascript: z.boolean().default(false),
});
export function configureInitCommand(program) {
return commonOptions(program
.command("init")
.description("Initialize your existing project for development with Trigger.dev")
.argument("[path]", "The path to the project", ".")
.option("-p, --project-ref <project ref>", "The project ref to use when initializing the project")
.option("--javascript", "Initialize the project with JavaScript instead of TypeScript", false)
.option("-t, --tag <package tag>", "The version of the @trigger.dev/sdk package to install", "latest")
.option("-r, --runtime <runtime>", "Which runtime to use for the project. Currently only supports node and bun", "node")
.option("--skip-package-install", "Skip installing the @trigger.dev/sdk package")
.option("--override-config", "Override the existing config file if it exists")
.option("--pkg-args <args>", "Additional arguments to pass to the package manager, accepts CSV for multiple args"))
.addOption(new CommandOption("--git-ref <git ref>", "The git ref to use when fetching templates from GitHub").hideHelp())
.action(async (path, options) => {
await handleTelemetry(async () => {
await printStandloneInitialBanner(true);
await initCommand(path, options);
});
});
}
export async function initCommand(dir, options) {
return await wrapCommandAction("initCommand", InitCommandOptions, options, async (opts) => {
return await _initCommand(dir, opts);
});
}
async function _initCommand(dir, options) {
const span = trace.getSpan(context.active());
intro("Initializing project");
const cwd = resolve(process.cwd(), dir);
const authorization = await login({
embedded: true,
defaultApiUrl: options.apiUrl,
profile: options.profile,
});
if (!authorization.ok) {
if (authorization.error === "fetch failed") {
throw new Error(`Failed to connect to ${authorization.auth?.apiUrl}. Are you sure it's the correct URL?`);
}
else {
throw new Error("You must login first. Use `trigger.dev login` to login.");
}
}
span?.setAttributes({
"cli.userId": authorization.userId,
"cli.email": authorization.email,
"cli.config.apiUrl": authorization.auth.apiUrl,
"cli.config.profile": authorization.profile,
});
const tsconfigPath = await tryResolveTsConfig(cwd);
if (!options.overrideConfig) {
try {
// check to see if there is an existing trigger.dev config file in the project directory
const result = await loadConfig({ cwd });
if (result.configFile && result.configFile !== "trigger.config") {
outro(result.configFile
? `Project already initialized: Found config file at ${result.configFile}. Pass --override-config to override`
: "Project already initialized");
return;
}
}
catch (e) {
// continue
}
}
const apiClient = new CliApiClient(authorization.auth.apiUrl, authorization.auth.accessToken);
const selectedProject = await selectProject(apiClient, authorization.dashboardUrl, options.projectRef);
span?.setAttributes({
...flattenAttributes(selectedProject, "cli.project"),
});
logger.debug("Selected project", selectedProject);
log.step(`Configuring project "${selectedProject.name}" (${selectedProject.externalRef})`);
// Install @trigger.dev/sdk package
if (!options.skipPackageInstall) {
await installPackages(dir, options);
}
else {
log.info("Skipping package installation");
}
const language = options.javascript ? "javascript" : "typescript";
// Create the trigger dir
const triggerDir = await createTriggerDir(dir, options, language);
// Create the config file
await writeConfigFile(dir, selectedProject, options, triggerDir, language);
// Add trigger.config.ts to tsconfig.json
if (tsconfigPath && language === "typescript") {
await addConfigFileToTsConfig(tsconfigPath, options);
}
// Ignore .trigger dir
await gitIgnoreDotTriggerDir(dir, options);
const projectDashboard = cliLink("project dashboard", `${authorization.dashboardUrl}/projects/v3/${selectedProject.externalRef}`);
log.success("Successfully initialized project for Trigger.dev v3 🫡");
log.info("Next steps:");
log.info(` 1. To start developing, run ${chalk.green(`npx trigger.dev@${options.tag} dev${options.profile ? "" : ` --profile ${options.profile}`}`)} in your project directory`);
log.info(` 2. Visit your ${projectDashboard} to view your newly created tasks.`);
log.info(` 3. Head over to our ${cliLink("v3 docs", "https://trigger.dev/docs")} to learn more.`);
log.info(` 4. Need help? Join our ${cliLink("Discord community", "https://trigger.dev/discord")} or email us at ${chalk.cyan("help@trigger.dev")}`);
outro(`Project initialized successfully. Happy coding!`);
}
async function createTriggerDir(dir, options, language) {
return await tracer.startActiveSpan("createTriggerDir", async (span) => {
try {
const defaultValue = join(dir, "src", "trigger");
const location = await text({
message: "Where would you like to create the Trigger.dev directory?",
defaultValue: defaultValue,
placeholder: defaultValue,
});
if (isCancel(location)) {
throw new OutroCommandError();
}
// Ensure that the path is always relative by stripping leading '/' if present
const relativeLocation = location.replace(/^\//, "");
const triggerDir = resolve(process.cwd(), relativeLocation);
logger.debug({ triggerDir });
span.setAttributes({
"cli.triggerDir": triggerDir,
});
if (await pathExists(triggerDir)) {
throw new Error(`Directory already exists at ${triggerDir}`);
}
const exampleSelection = await select({
message: `Choose an example to create in the ${location} directory`,
options: [
{ value: "simple", label: "Simple (Hello World)" },
{ value: "schedule", label: "Scheduled Task" },
{
value: "none",
label: "None",
hint: "skip creating an example",
},
],
});
if (isCancel(exampleSelection)) {
throw new OutroCommandError();
}
const example = exampleSelection;
span.setAttributes({
"cli.example": example,
});
if (example === "none") {
// Create a .gitkeep file in the trigger dir
await createFile(join(triggerDir, ".gitkeep"), "");
log.step(`Created directory at ${location}`);
span.end();
return { location, isCustomValue: location !== defaultValue };
}
const templateUrl = generateTemplateUrl(`examples/${example}.${language === "typescript" ? "ts" : "mjs"}`, options.gitRef);
const outputPath = join(triggerDir, `example.${language === "typescript" ? "ts" : "mjs"}`);
await createFileFromTemplate({
templateUrl,
outputPath,
replacements: {},
});
const relativeOutputPath = relative(process.cwd(), outputPath);
log.step(`Created example file at ${relativeOutputPath}`);
span.end();
return { location, isCustomValue: location !== defaultValue };
}
catch (e) {
if (!(e instanceof SkipCommandError)) {
recordSpanException(span, e);
}
span.end();
throw e;
}
});
}
async function gitIgnoreDotTriggerDir(dir, options) {
return await tracer.startActiveSpan("gitIgnoreDotTriggerDir", async (span) => {
try {
const projectDir = resolve(process.cwd(), dir);
const gitIgnorePath = join(projectDir, ".gitignore");
span.setAttributes({
"cli.projectDir": projectDir,
"cli.gitIgnorePath": gitIgnorePath,
});
if (!(await pathExists(gitIgnorePath))) {
// Create .gitignore file
await createFile(gitIgnorePath, ".trigger");
log.step(`Added .trigger to .gitignore`);
span.end();
return;
}
// Check if .gitignore already contains .trigger
const gitIgnoreContent = await readFile(gitIgnorePath);
if (gitIgnoreContent.includes(".trigger")) {
span.end();
return;
}
const newGitIgnoreContent = `${gitIgnoreContent}\n.trigger`;
await writeFile(gitIgnorePath, newGitIgnoreContent, "utf-8");
log.step(`Added .trigger to .gitignore`);
span.end();
}
catch (e) {
if (!(e instanceof SkipCommandError)) {
recordSpanException(span, e);
}
span.end();
throw e;
}
});
}
async function addConfigFileToTsConfig(tsconfigPath, options) {
return await tracer.startActiveSpan("addConfigFileToTsConfig", async (span) => {
try {
span.setAttributes({
"cli.tsconfigPath": tsconfigPath,
});
const tsconfigContent = await readFile(tsconfigPath);
const tsconfigContentTree = parseTree(tsconfigContent, undefined);
if (!tsconfigContentTree) {
span.end();
return;
}
const tsconfigIncludeOption = findNodeAtLocation(tsconfigContentTree, ["include"]);
if (!tsconfigIncludeOption) {
span.end();
return;
}
const tsConfigFileName = "trigger.config.ts";
const tsconfigIncludeOptionValue = getNodeValue(tsconfigIncludeOption);
if (tsconfigIncludeOptionValue.includes(tsConfigFileName)) {
span.end();
return;
}
const edits = modify(tsconfigContent, ["include", -1], tsConfigFileName, {
isArrayInsertion: true,
formattingOptions: {
tabSize: 2,
insertSpaces: true,
eol: "\n",
},
});
logger.debug("tsconfig.json edits", { edits });
const newTsconfigContent = applyEdits(tsconfigContent, edits);
logger.debug("new tsconfig.json content", { newTsconfigContent });
await writeFile(tsconfigPath, newTsconfigContent, "utf-8");
log.step(`Added trigger.config.ts to tsconfig.json`);
span.end();
}
catch (e) {
if (!(e instanceof SkipCommandError)) {
recordSpanException(span, e);
}
span.end();
throw e;
}
});
}
async function installPackages(dir, options) {
return await tracer.startActiveSpan("installPackages", async (span) => {
const projectDir = resolve(process.cwd(), dir);
const installSpinner = spinner();
const packageManager = await detectPackageManager(projectDir);
try {
span.setAttributes({
"cli.projectDir": projectDir,
"cli.packageManager": packageManager?.name,
"cli.tag": options.tag,
});
installSpinner.start(`Adding @trigger.dev/sdk@${options.tag}`);
await addDependency(`@trigger.dev/sdk@${options.tag}`, { cwd: projectDir, silent: true });
installSpinner.stop(`@trigger.dev/sdk@${options.tag} installed`);
installSpinner.start(`Adding @trigger.dev/build@${options.tag} to devDependencies`);
await addDevDependency(`@trigger.dev/build@${options.tag}`, {
cwd: projectDir,
silent: true,
});
installSpinner.stop(`@trigger.dev/build@${options.tag} installed`);
span.end();
}
catch (e) {
if (options.logLevel === "debug") {
installSpinner.stop(`Failed to install @trigger.dev/sdk@${options.tag}.`);
}
else {
installSpinner.stop(`Failed to install @trigger.dev/sdk@${options.tag}. Rerun command with --log-level debug for more details.`);
}
if (!(e instanceof SkipCommandError)) {
recordSpanException(span, e);
}
span.end();
throw e;
}
});
}
async function writeConfigFile(dir, project, options, triggerDir, language) {
return await tracer.startActiveSpan("writeConfigFile", async (span) => {
try {
const spnnr = spinner();
spnnr.start("Creating config file");
const projectDir = resolve(process.cwd(), dir);
const outputPath = join(projectDir, `trigger.config.${language === "typescript" ? "ts" : "mjs"}`);
const templateUrl = generateTemplateUrl(`trigger.config.${language === "typescript" ? "ts" : "mjs"}`, options.gitRef);
span.setAttributes({
"cli.projectDir": projectDir,
"cli.templatePath": templateUrl,
"cli.outputPath": outputPath,
"cli.runtime": options.runtime,
});
const result = await createFileFromTemplate({
templateUrl,
replacements: {
projectRef: project.externalRef,
runtime: options.runtime,
triggerDirectoriesOption: triggerDir.isCustomValue
? `\n dirs: ["${triggerDir.location}"],`
: `\n dirs: ["./src/trigger"],`,
},
outputPath,
override: options.overrideConfig,
});
const relativePathToOutput = relative(process.cwd(), outputPath);
spnnr.stop(result.success
? `Config file created at ${relativePathToOutput}`
: `Failed to create config file: ${result.error}`);
if (!result.success) {
throw new SkipLoggingError(result.error);
}
span.end();
return result.success;
}
catch (e) {
if (!(e instanceof SkipCommandError)) {
recordSpanException(span, e);
}
span.end();
throw e;
}
});
}
async function selectProject(apiClient, dashboardUrl, projectRef) {
return await tracer.startActiveSpan("selectProject", async (span) => {
try {
if (projectRef) {
const projectResponse = await apiClient.getProject(projectRef);
if (!projectResponse.success) {
log.error(`--project-ref ${projectRef} is not a valid project ref. Request to fetch data resulted in: ${projectResponse.error}`);
throw new SkipCommandError(projectResponse.error);
}
span.setAttributes({
...flattenAttributes(projectResponse.data, "cli.project"),
});
span.end();
return projectResponse.data;
}
const projectsResponse = await apiClient.getProjects();
if (!projectsResponse.success) {
throw new Error(`Failed to get projects: ${projectsResponse.error}`);
}
if (projectsResponse.data.length === 0) {
const newProjectLink = cliLink("Create new project", `${dashboardUrl}/projects/new?version=v3`);
outro(`You don't have any projects yet. ${newProjectLink}`);
throw new SkipCommandError();
}
const selectedProject = await select({
message: "Select an existing Trigger.dev project",
options: projectsResponse.data.map((project) => ({
value: project.externalRef,
label: `${project.name} - ${project.externalRef}`,
hint: project.organization.title,
})),
});
if (isCancel(selectedProject)) {
throw new OutroCommandError();
}
const projectData = projectsResponse.data.find((project) => project.externalRef === selectedProject);
if (!projectData) {
throw new Error("Invalid project ref");
}
span.setAttributes({
...flattenAttributes(projectData, "cli.project"),
});
span.end();
return projectData;
}
catch (e) {
if (!(e instanceof SkipCommandError)) {
recordSpanException(span, e);
}
span.end();
throw e;
}
});
}
async function tryResolveTsConfig(cwd) {
try {
const tsconfigPath = await resolveTSConfig(cwd);
return tsconfigPath;
}
catch (e) {
return;
}
}
//# sourceMappingURL=init.js.map