From 74a6a21c35670a2c6cb3dfe9861ba05d4a12709a Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 24 Feb 2023 13:13:41 +0000 Subject: [PATCH] Adding the create-trigger CLI package --- .../app/routes/api/v1/internal/telemetry.ts | 41 +++ .../app/routes/api/v1/internal/whoami.ts | 1 + packages/create-trigger/README.md | 77 +++++ packages/create-trigger/package.json | 68 ++++ packages/create-trigger/src/cli/index.ts | 295 ++++++++++++++++++ packages/create-trigger/src/consts.ts | 22 ++ packages/create-trigger/src/index.ts | 71 +++++ .../src/utils/createDotEnvFile.ts | 14 + .../create-trigger/src/utils/createProject.ts | 74 +++++ .../src/utils/createTelemetryEvent.ts | 21 ++ .../src/utils/getUserPkgManager.ts | 19 ++ .../create-trigger/src/utils/getVersion.ts | 12 + packages/create-trigger/src/utils/git.ts | 137 ++++++++ .../src/utils/installDependencies.ts | 74 +++++ .../create-trigger/src/utils/logNextSteps.ts | 46 +++ packages/create-trigger/src/utils/logger.ts | 16 + .../src/utils/parseNameAndPath.ts | 38 +++ .../create-trigger/src/utils/renderTitle.ts | 24 ++ .../create-trigger/src/utils/templateRef.ts | 5 + .../create-trigger/src/utils/triggerApi.ts | 80 +++++ packages/create-trigger/tsconfig.json | 49 +++ packages/create-trigger/tsup.config.ts | 16 + 22 files changed, 1200 insertions(+) create mode 100644 apps/webapp/app/routes/api/v1/internal/telemetry.ts create mode 100644 packages/create-trigger/README.md create mode 100644 packages/create-trigger/package.json create mode 100644 packages/create-trigger/src/cli/index.ts create mode 100644 packages/create-trigger/src/consts.ts create mode 100644 packages/create-trigger/src/index.ts create mode 100644 packages/create-trigger/src/utils/createDotEnvFile.ts create mode 100644 packages/create-trigger/src/utils/createProject.ts create mode 100644 packages/create-trigger/src/utils/createTelemetryEvent.ts create mode 100644 packages/create-trigger/src/utils/getUserPkgManager.ts create mode 100644 packages/create-trigger/src/utils/getVersion.ts create mode 100644 packages/create-trigger/src/utils/git.ts create mode 100644 packages/create-trigger/src/utils/installDependencies.ts create mode 100644 packages/create-trigger/src/utils/logNextSteps.ts create mode 100644 packages/create-trigger/src/utils/logger.ts create mode 100644 packages/create-trigger/src/utils/parseNameAndPath.ts create mode 100644 packages/create-trigger/src/utils/renderTitle.ts create mode 100644 packages/create-trigger/src/utils/templateRef.ts create mode 100644 packages/create-trigger/src/utils/triggerApi.ts create mode 100644 packages/create-trigger/tsconfig.json create mode 100644 packages/create-trigger/tsup.config.ts diff --git a/apps/webapp/app/routes/api/v1/internal/telemetry.ts b/apps/webapp/app/routes/api/v1/internal/telemetry.ts new file mode 100644 index 000000000..332e2f7dc --- /dev/null +++ b/apps/webapp/app/routes/api/v1/internal/telemetry.ts @@ -0,0 +1,41 @@ +import type { ActionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { analytics } from "~/services/analytics.server"; +import { authenticateApiRequest } from "~/services/apiAuth.server"; + +const BodySchema = z.object({ + id: z.string(), + event: z.string(), + properties: z.record(z.union([z.string(), z.number()]), z.any()), +}); + +export async function action({ request }: ActionArgs) { + // first make sure this is a POST request + if (request.method.toUpperCase() !== "POST") { + return json({ error: "Method not allowed" }, { status: 405 }); + } + + const rawBody = await request.json(); + const body = BodySchema.parse(rawBody); + + // Next authenticate the request + const authenticatedEnv = await authenticateApiRequest(request); + + const event = { + userId: body.id, + event: body.event, + properties: { + ...body.properties, + environmentType: authenticatedEnv?.slug, + }, + organizationId: authenticatedEnv?.organizationId, + environmentId: authenticatedEnv?.id, + }; + + console.log("Capturing event", event); + + analytics.telemetry.capture(event); + + return json({ status: "OK" }); +} diff --git a/apps/webapp/app/routes/api/v1/internal/whoami.ts b/apps/webapp/app/routes/api/v1/internal/whoami.ts index d9bd8b3e9..103cd6b5f 100644 --- a/apps/webapp/app/routes/api/v1/internal/whoami.ts +++ b/apps/webapp/app/routes/api/v1/internal/whoami.ts @@ -14,5 +14,6 @@ export async function loader({ request }: LoaderArgs) { return json({ organizationId: authenticatedEnv.organizationId, env: authenticatedEnv.slug, + organizationSlug: authenticatedEnv.organization.slug, }); } diff --git a/packages/create-trigger/README.md b/packages/create-trigger/README.md new file mode 100644 index 000000000..6f6082262 --- /dev/null +++ b/packages/create-trigger/README.md @@ -0,0 +1,77 @@ +## ✨ Create Trigger - Get started writing Trigger.dev code quickly + +Trigger.dev is an open source platform that makes it easy to create event-driven background tasks directly your codebase. + +You can run these tasks (or "workflows" as we like to cal them) in your existing Node.js repo, but if you don't have one of those (👋 Next.js devs) or you just want to try us out without the setup, this `create-trigger` CLI will scaffold out a project for you in just a few seconds, either starting from scratch or using one of our many [templates](https://app.trigger.com/templates). + +## 💻 Usage + +To scaffold out a new project using `create-trigger`, run any of the following three commands and answer the prompts: + +### npm + +```sh +npm create trigger@latest +``` + +### yarn + +```sh +yarn create trigger +``` + +### pnpm + +```sh +pnpm create trigger@latest +``` + +You can also specify the [template](https://app.trigger.com/templates) you want to use by passing an argument to the command, like so: + +```sh +npm create trigger@latest github-stars-to-slack +``` + +## Advanced Usage + +| Option/Flag | Description | +| ------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `[template]` | The name of the template to use, e.g. basic-starter | +| `-p, --projectName` | The name of the project, as well as the name of the directory to create. Can be a path to a directory, e.g. ~/projects/my-project | +| `-k, --apiKey` | The development API key to use for the project. Visit https://app.trigger.dev to get yours | +| `--noGit` | Explicitly tell the CLI to not initialize a new git repo in the project | +| `--noInstall` | Explicitly tell the CLI to not run the package manager's install command | + +## Folder structure + +``` +├── LICENSE +├── README.md +├── package.json +├── render.yaml +├── .env +├── .env.example +├── src +│   └── index.ts +└── tsconfig.json +``` + +### `src/index.ts` + +All your Trigger.dev workflow code will be in here, and this is the part you can start customizing. + +### `.env` + +If provided, we'll save your development API Key here so running the project can connect to our servers. + +### `render.yaml` + +A [Render.com](https://render.com) Blueprint file that makes it easy to deploy your repo as a Background Worker. + +### `README.md` + +Contains useful instructions for getting started with the repo, including how to customize it, running it locally, testing it, and deploying it. + +## Next steps + +After you successfully scaffold out your project, take a look at the README. If you have any issues, please feel free to email us at hello@trigger.dev, or you can ask a question in our [Discord server](https://discord.gg/nkqV9xBYWy) diff --git a/packages/create-trigger/package.json b/packages/create-trigger/package.json new file mode 100644 index 000000000..8ca3151a5 --- /dev/null +++ b/packages/create-trigger/package.json @@ -0,0 +1,68 @@ +{ + "name": "create-trigger", + "version": "0.1.0", + "description": "The Trigger.dev CLI to easily create and manage a Trigger.dev project", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/triggerdotdev/trigger.dev.git", + "directory": "packages/create-trigger" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "typescript", + "trigger.dev", + "workflows", + "orchestration", + "events", + "webhooks", + "integrations", + "apis" + ], + "files": [ + "dist" + ], + "type": "module", + "exports": "./dist/index.js", + "bin": { + "create-trigger": "./dist/index.js" + }, + "devDependencies": { + "@types/fs-extra": "^11.0.1", + "@types/gradient-string": "^1.1.2", + "@types/inquirer": "^9.0.3", + "@types/node": "16", + "@types/node-fetch": "^2.6.2", + "rimraf": "^3.0.2", + "tsup": "^6.5.0", + "type-fest": "^3.6.0", + "typescript": "^4.9.5" + }, + "scripts": { + "typecheck": "tsc", + "build": "tsup", + "dev": "tsup --watch", + "clean": "rimraf dist", + "start": "node dist/index.js" + }, + "dependencies": { + "@types/degit": "^2.8.3", + "chalk": "^5.2.0", + "commander": "^9.4.1", + "degit": "^2.8.4", + "execa": "^7.0.0", + "fs-extra": "^11.1.0", + "gradient-string": "^2.0.2", + "inquirer": "^9.1.4", + "node-fetch": "^3.3.0", + "ora": "^6.1.2", + "terminal-link": "^3.0.0" + }, + "engines": { + "node": ">=16" + } +} \ No newline at end of file diff --git a/packages/create-trigger/src/cli/index.ts b/packages/create-trigger/src/cli/index.ts new file mode 100644 index 000000000..8773470d8 --- /dev/null +++ b/packages/create-trigger/src/cli/index.ts @@ -0,0 +1,295 @@ +import chalk from "chalk"; +import { Command } from "commander"; +import inquirer from "inquirer"; +import terminalLink from "terminal-link"; +import { + CREATE_TRIGGER, + DEFAULT_APP_NAME as DEFAULT_PROJECT_NAME, +} from "../consts.js"; +import { getUserPkgManager } from "../utils/getUserPkgManager.js"; +import { getVersion } from "../utils/getVersion.js"; +import { logger } from "../utils/logger.js"; +import { getTemplates } from "../utils/triggerApi.js"; + +export interface CliFlags { + noGit: boolean; + noInstall: boolean; + noTelemetry: boolean; + projectName: string; + apiKey?: string; +} + +export interface CliResults { + templateName: string; + flags: CliFlags; +} + +const defaultOptions: CliResults = { + templateName: "blank-starter", + flags: { + noGit: false, + noInstall: false, + noTelemetry: false, + projectName: DEFAULT_PROJECT_NAME, + }, +}; + +export const runCli = async () => { + const cliResults = defaultOptions; + + const program = new Command().name(CREATE_TRIGGER); + + program + .description("A CLI for creating Trigger.dev projects") + .argument( + "[template-name]", + "The name of the template to use, e.g. basic-starter", + "blank-starter" + ) + .option( + "-p, --projectName ", + "The name of the project, as well as the name of the directory to create. Can be a path to a directory, e.g. ~/projects/my-project", + false + ) + .option( + "-k, --apiKey ", + "The development API key to use for the project. Visit https://app.trigger.dev to get yours", + false + ) + .option( + "--noGit", + "Explicitly tell the CLI to not initialize a new git repo in the project", + false + ) + .option( + "--noInstall", + "Explicitly tell the CLI to not run the package manager's install command", + false + ) + .option( + "--noTelemetry", + "Explicitly tell the CLI to not send usage data to Trigger.dev", + false + ) + .version(getVersion(), "-v, --version", "Display the version number") + .addHelpText( + "afterAll", + `\n The create-trigger CLI was inspired by ${chalk + .hex("#E8DCFF") + .bold("create-t3-stack")} \n` + ) + .parse(process.argv); + + const templateName = program.args[0]; + + if (templateName) { + cliResults.templateName = templateName; + } + + cliResults.flags = program.opts(); + + try { + if ( + process.env.SHELL?.toLowerCase().includes("git") && + process.env.SHELL?.includes("bash") + ) { + logger.warn(` WARNING: It looks like you are using Git Bash which is non-interactive. Please run create-t3-app with another + terminal such as Windows Terminal or PowerShell if you want to use the interactive CLI.`); + + const error = new Error("Non-interactive environment"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (error as any).isTTYError = true; + throw error; + } + + if (!templateName) { + cliResults.templateName = await promptTemplateName( + cliResults.templateName + ); + } + + if (!cliResults.flags.projectName) { + cliResults.flags.projectName = await promptProjectName(); + } + + if (!cliResults.flags.apiKey) { + cliResults.flags.apiKey = await promptApiKey(); + } + + if (!cliResults.flags.noGit) { + cliResults.flags.noGit = !(await promptGit()); + } + + if (!cliResults.flags.noInstall) { + cliResults.flags.noInstall = !(await promptInstall()); + } + } catch (err) { + // If the user is not calling create-trigger from an interactive terminal, inquirer will throw an error with isTTYError = true + // If this happens, we catch the error, tell the user what has happened, and then continue to run the program with a default trigger project + // Otherwise we have to do some fancy namespace extension logic on the Error type which feels overkill for one line + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (err instanceof Error && (err as any).isTTYError) { + logger.warn( + `${CREATE_TRIGGER} needs an interactive terminal to provide options` + ); + + const { shouldContinue } = await inquirer.prompt<{ + shouldContinue: boolean; + }>({ + name: "shouldContinue", + type: "confirm", + message: `Continue creating a trigger.dev project?`, + default: true, + }); + + if (!shouldContinue) { + logger.info("Exiting..."); + process.exit(0); + } + + logger.info( + `Bootstrapping the default Trigger.dev template in ./${cliResults.templateName}` + ); + } else { + throw err; + } + } + + return cliResults; +}; + +const promptTemplateName = async ( + defaultTemplateName: string +): Promise => { + const templates = await getTemplates(); + + if (templates.length === 0) { + return defaultTemplateName; + } + + const defaultTemplate = templates.find( + (template) => template.slug === defaultTemplateName + ); + + const templateChoicesWithoutDefault = templates + .filter((template) => template.slug !== defaultTemplateName) + .map((template) => ({ + name: `${template.shortTitle} - ${template.description} [${terminalLink( + "View more", + template.repositoryUrl + )}]`, + value: template.slug, + })); + + const separator = new inquirer.Separator(); + + const choices = defaultTemplate + ? [ + { + name: `${defaultTemplate.shortTitle} - ${ + defaultTemplate.description + } [${terminalLink("View more", defaultTemplate.repositoryUrl)}]`, + value: defaultTemplate.slug, + }, + separator, + ...templateChoicesWithoutDefault, + ] + : templateChoicesWithoutDefault; + + const { templateName } = await inquirer.prompt<{ templateName: string }>({ + name: "templateName", + type: "list", + message: "What template would you like to use?", + choices, + default: defaultTemplateName, + }); + + logger.success(`Great! We're using the ${templateName} template`); + + return templateName; +}; + +const promptProjectName = async (): Promise => { + const { projectName } = await inquirer.prompt<{ projectName: string }>({ + name: "projectName", + type: "input", + message: "What would you like to name your project?", + default: DEFAULT_PROJECT_NAME, + }); + + logger.success(`Great! We're creating your project at ${projectName}`); + + return projectName; +}; + +const promptApiKey = async (): Promise => { + // First prompt if they want to enter their API key now, and if they say Yes, then prompt for it and return it + const { apiKey } = await inquirer.prompt<{ apiKey: string | undefined }>({ + type: "input", + name: "apiKey", + message: "Enter your development API key (optional)", + default: undefined, + validate: (input) => { + // Make sure they enter something like trigger_development_******** + if (input && !input.startsWith("trigger_development_")) { + return "Please enter a valid API key (e.g. trigger_development_********) or leave blank to skip"; + } + + return true; + }, + }); + + if (apiKey) { + logger.success( + `Fantastic! We'll save the API key (trigger_development_********) in the .env file.` + ); + } + + return apiKey; +}; + +const promptGit = async (): Promise => { + const { git } = await inquirer.prompt<{ git: boolean }>({ + name: "git", + type: "confirm", + message: "Initialize a new git repository?", + default: true, + }); + + if (git) { + logger.success("Nice one! Initializing repository!"); + } else { + logger.info("Sounds good! You can come back and run git init later."); + } + + return git; +}; + +const promptInstall = async (): Promise => { + const pkgManager = getUserPkgManager(); + + const { install } = await inquirer.prompt<{ install: boolean }>({ + name: "install", + type: "confirm", + message: + `Would you like us to run '${pkgManager}` + + (pkgManager === "yarn" ? `'?` : ` install'?`), + default: true, + }); + + if (install) { + logger.success("Alright. We'll install the dependencies for you!"); + } else { + if (pkgManager === "yarn") { + logger.info( + `No worries. You can run '${pkgManager}' later to install the dependencies.` + ); + } else { + logger.info( + `No worries. You can run '${pkgManager} install' later to install the dependencies.` + ); + } + } + + return install; +}; diff --git a/packages/create-trigger/src/consts.ts b/packages/create-trigger/src/consts.ts new file mode 100644 index 000000000..6a941e4a1 --- /dev/null +++ b/packages/create-trigger/src/consts.ts @@ -0,0 +1,22 @@ +import path from "path"; +import { fileURLToPath } from "url"; + +// With the move to TSUP as a build tool, this keeps path routes in other files (installers, loaders, etc) in check more easily. +// Path is in relation to a single index.js file inside ./dist +const __filename = fileURLToPath(import.meta.url); +const distPath = path.dirname(__filename); +export const PKG_ROOT = path.join(distPath, "../"); + +export const TITLE_TEXT = ` + _____ _ _ +|_ _| ___ |_| ___ ___ ___ ___ _| | ___ _ _ + | | | _|| || . || . || -_|| _| _ | . || -_|| | | + |_| |_| |_||_ ||_ ||___||_| |_||___||___| \\_/ + |___||___| +`; + +export const DEFAULT_APP_NAME = "my-triggers"; +export const CREATE_TRIGGER = "create-trigger"; +export const TEMPLATE_ORGANIZATION = "triggerdotdev"; +export const TRIGGER_BASE_URL = + process.env.TRIGGER_BASE_URL ?? "https://app.trigger.dev"; diff --git a/packages/create-trigger/src/index.ts b/packages/create-trigger/src/index.ts new file mode 100644 index 000000000..e06a2e4d5 --- /dev/null +++ b/packages/create-trigger/src/index.ts @@ -0,0 +1,71 @@ +#!/usr/bin/env node + +import { runCli } from "./cli/index.js"; +import { createProject } from "./utils/createProject.js"; +import { logger } from "./utils/logger.js"; +import { renderTitle } from "./utils/renderTitle.js"; +import { createTemplateRef } from "./utils/templateRef.js"; +import { installDependencies } from "./utils/installDependencies.js"; +import { initializeGit } from "./utils/git.js"; +import { parseNameAndPath } from "./utils/parseNameAndPath.js"; +import { logNextSteps } from "./utils/logNextSteps.js"; +import { createDotEnvFile } from "./utils/createDotEnvFile.js"; +import { sendTelemetry } from "./utils/triggerApi.js"; +import { createTelemetryEvent } from "./utils/createTelemetryEvent.js"; + +const main = async () => { + renderTitle(); + + const cli = await runCli(); + + const repositoryRef = createTemplateRef(cli.templateName); + + const [scopedProjectName, projectDir] = parseNameAndPath( + cli.flags.projectName + ); + + const projectPath = await createProject( + repositoryRef, + projectDir, + scopedProjectName ?? cli.templateName + ); + + if (!projectPath) { + process.exit(1); + } + + if (!cli.flags.noInstall) { + await installDependencies(projectPath); + } + + if (!cli.flags.noGit) { + await initializeGit(projectPath); + } + + await createDotEnvFile(projectPath, cli.flags.apiKey); + + await logNextSteps({ + projectName: projectDir, + noInstall: cli.flags.noInstall, + apiKey: cli.flags.apiKey, + }); + + if (!cli.flags.noTelemetry) { + await sendTelemetry(createTelemetryEvent(cli), cli.flags.apiKey); + } + + process.exit(0); +}; + +main().catch((err) => { + logger.error("Aborting installation..."); + if (err instanceof Error) { + logger.error(err); + } else { + logger.error( + "An unknown error has occurred. Please open an issue on github with the below:" + ); + console.log(err); + } + process.exit(1); +}); diff --git a/packages/create-trigger/src/utils/createDotEnvFile.ts b/packages/create-trigger/src/utils/createDotEnvFile.ts new file mode 100644 index 000000000..1e88b77a7 --- /dev/null +++ b/packages/create-trigger/src/utils/createDotEnvFile.ts @@ -0,0 +1,14 @@ +import path from "path"; +import fs from "fs-extra"; + +export async function createDotEnvFile(projectPath: string, apiKey?: string) { + const envPath = path.join(projectPath, ".env"); + const envExists = await fs.pathExists(envPath); + if (envExists) { + return; + } + const envContents = apiKey + ? `TRIGGER_API_KEY=${apiKey}` + : "TRIGGER_API_KEY="; + await fs.writeFile(envPath, envContents); +} diff --git a/packages/create-trigger/src/utils/createProject.ts b/packages/create-trigger/src/utils/createProject.ts new file mode 100644 index 000000000..2a258bc47 --- /dev/null +++ b/packages/create-trigger/src/utils/createProject.ts @@ -0,0 +1,74 @@ +import path from "node:path"; +import degit from "degit"; +import ora from "ora"; +import chalk from "chalk"; +import fs from "fs-extra"; +import { logger } from "./logger.js"; + +export async function createProject( + repositoryRef: string, + projectDir: string, + projectName: string +) { + const emitter = degit(repositoryRef); + + emitter.on("info", (info) => { + console.log(info.message); + }); + + emitter.on("warn", (warning) => { + console.warn(warning.message); + }); + + const projectPath = path.resolve(process.cwd(), projectDir); + + // If the project directory already exists, log an error and exit + if (fs.existsSync(projectPath)) { + logger.error(`A directory already exists at: ${projectPath}`); + return; + } + + const spinner = ora( + `Copying ${repositoryRef} to: ${projectDir}...\n` + ).start(); + + spinner.start(); + + await emitter.clone(projectPath); + + // Rewrite the package.json file to use the new project name + updatePackageJson(projectName, projectPath); + // Rewrite the README.md file to use the new project name + updateReadme(projectName, projectPath); + // Remove package-lock.json + fs.removeSync(path.resolve(projectPath, "package-lock.json")); + // Remove .env.example + fs.removeSync(path.resolve(projectPath, ".env.example")); + + spinner.succeed( + `${chalk.cyan.bold(projectName)} ${chalk.green("copied successfully!")}\n` + ); + + return projectDir; +} + +function updatePackageJson(projectName: string, projectDir: string) { + const existingPackageJson = fs.readJSONSync( + path.resolve(projectDir, "package.json") + ); + + const newPackageJson = { + ...existingPackageJson, + name: projectName, + }; + + fs.writeJSONSync(path.resolve(projectDir, "package.json"), newPackageJson, { + spaces: 2, + }); +} + +function updateReadme(projectName: string, projectDir: string) { + const existingReadme = fs.readFileSync(path.resolve(projectDir, "README.md")); + + fs.writeFileSync(path.resolve(projectDir, "README.md"), existingReadme); +} diff --git a/packages/create-trigger/src/utils/createTelemetryEvent.ts b/packages/create-trigger/src/utils/createTelemetryEvent.ts new file mode 100644 index 000000000..d8d0f3ab1 --- /dev/null +++ b/packages/create-trigger/src/utils/createTelemetryEvent.ts @@ -0,0 +1,21 @@ +import { CliResults } from "../cli/index.js"; +import { getVersion } from "./getVersion.js"; +import { TelemetryEvent } from "./triggerApi.js"; +import { randomUUID } from "crypto"; + +export function createTelemetryEvent(cli: CliResults): TelemetryEvent { + return { + id: `anon:${randomUUID()}`, + event: "scaffolded template", + properties: { + projectName: cli.flags.projectName, + templateName: cli.templateName, + noInstall: cli.flags.noInstall, + noGit: cli.flags.noGit, + arch: process.arch, + platform: process.platform, + nodeVersion: process.version, + packageVersion: getVersion(), + }, + }; +} diff --git a/packages/create-trigger/src/utils/getUserPkgManager.ts b/packages/create-trigger/src/utils/getUserPkgManager.ts new file mode 100644 index 000000000..47884b235 --- /dev/null +++ b/packages/create-trigger/src/utils/getUserPkgManager.ts @@ -0,0 +1,19 @@ +export type PackageManager = "npm" | "pnpm" | "yarn"; + +export const getUserPkgManager: () => PackageManager = () => { + // This environment variable is set by npm and yarn but pnpm seems less consistent + const userAgent = process.env.npm_config_user_agent; + + if (userAgent) { + if (userAgent.startsWith("yarn")) { + return "yarn"; + } else if (userAgent.startsWith("pnpm")) { + return "pnpm"; + } else { + return "npm"; + } + } else { + // If no user agent is set, assume npm + return "npm"; + } +}; diff --git a/packages/create-trigger/src/utils/getVersion.ts b/packages/create-trigger/src/utils/getVersion.ts new file mode 100644 index 000000000..1a7922d76 --- /dev/null +++ b/packages/create-trigger/src/utils/getVersion.ts @@ -0,0 +1,12 @@ +import { type PackageJson } from "type-fest"; +import path from "path"; +import fs from "fs-extra"; +import { PKG_ROOT } from "../consts.js"; + +export const getVersion = () => { + const packageJsonPath = path.join(PKG_ROOT, "package.json"); + + const packageJsonContent = fs.readJSONSync(packageJsonPath) as PackageJson; + + return packageJsonContent.version ?? "1.0.0"; +}; diff --git a/packages/create-trigger/src/utils/git.ts b/packages/create-trigger/src/utils/git.ts new file mode 100644 index 000000000..31c4ea639 --- /dev/null +++ b/packages/create-trigger/src/utils/git.ts @@ -0,0 +1,137 @@ +import chalk from "chalk"; +import { execSync } from "child_process"; +import { execa } from "execa"; +import fs from "fs-extra"; +import inquirer from "inquirer"; +import ora from "ora"; +import path from "path"; +import { logger } from "./logger.js"; + +const isGitInstalled = (dir: string): boolean => { + try { + execSync("git --version", { cwd: dir }); + return true; + } catch (_e) { + return false; + } +}; + +/** @returns Whether or not the provided directory has a `.git` subdirectory in it. */ +const isRootGitRepo = (dir: string): boolean => { + return fs.existsSync(path.join(dir, ".git")); +}; + +/** @returns Whether or not this directory or a parent directory has a `.git` directory. */ +const isInsideGitRepo = async (dir: string): Promise => { + try { + // If this command succeeds, we're inside a git repo + await execa("git", ["rev-parse", "--is-inside-work-tree"], { + cwd: dir, + stdout: "ignore", + }); + return true; + } catch (_e) { + // Else, it will throw a git-error and we return false + return false; + } +}; + +const getGitVersion = () => { + const stdout = execSync("git --version").toString().trim(); + const gitVersionTag = stdout.split(" ")[2]; + const major = gitVersionTag?.split(".")[0]; + const minor = gitVersionTag?.split(".")[1]; + return { major: Number(major), minor: Number(minor) }; +}; + +/** @returns The git config value of "init.defaultBranch". If it is not set, returns "main". */ +const getDefaultBranch = () => { + const stdout = execSync("git config --global init.defaultBranch || echo main") + .toString() + .trim(); + + return stdout; +}; + +// This initializes the Git-repository for the project +export const initializeGit = async (projectDir: string) => { + logger.info("Initializing Git..."); + + if (!isGitInstalled(projectDir)) { + logger.warn("Git is not installed. Skipping Git initialization."); + return; + } + + const spinner = ora("Creating a new git repo...\n").start(); + + const isRoot = isRootGitRepo(projectDir); + const isInside = await isInsideGitRepo(projectDir); + const dirName = path.parse(projectDir).name; // skip full path for logging + + if (isInside && isRoot) { + // Dir is a root git repo + spinner.stop(); + const { overwriteGit } = await inquirer.prompt<{ + overwriteGit: boolean; + }>({ + name: "overwriteGit", + type: "confirm", + message: `${chalk.redBright.bold( + "Warning:" + )} Git is already initialized in "${dirName}". Initializing a new git repository would delete the previous history. Would you like to continue anyways?`, + default: false, + }); + if (!overwriteGit) { + spinner.info("Skipping Git initialization."); + return; + } + // Deleting the .git folder + fs.removeSync(path.join(projectDir, ".git")); + } else if (isInside && !isRoot) { + // Dir is inside a git worktree + spinner.stop(); + const { initializeChildGitRepo } = await inquirer.prompt<{ + initializeChildGitRepo: boolean; + }>({ + name: "initializeChildGitRepo", + type: "confirm", + message: `${chalk.redBright.bold( + "Warning:" + )} "${dirName}" is already in a git worktree. Would you still like to initialize a new git repository in this directory?`, + default: false, + }); + if (!initializeChildGitRepo) { + spinner.info("Skipping Git initialization."); + return; + } + } + + // We're good to go, initializing the git repo + try { + const branchName = getDefaultBranch(); + + // --initial-branch flag was added in git v2.28.0 + const { major, minor } = getGitVersion(); + if (major < 2 || minor < 28) { + await execa("git", ["init"], { cwd: projectDir }); + await execa("git", ["branch", "-m", branchName], { cwd: projectDir }); + } else { + await execa("git", ["init", `--initial-branch=${branchName}`], { + cwd: projectDir, + }); + } + await execa("git", ["add", "."], { cwd: projectDir }); + spinner.succeed( + `${chalk.green("Successfully initialized and staged")} ${chalk.green.bold( + "git" + )}\n` + ); + } catch (error) { + // Safeguard, should be unreachable + spinner.fail( + `${chalk.bold.red( + "Failed:" + )} could not initialize git. Update git to the latest version!\n` + ); + } +}; diff --git a/packages/create-trigger/src/utils/installDependencies.ts b/packages/create-trigger/src/utils/installDependencies.ts new file mode 100644 index 000000000..f6812ef89 --- /dev/null +++ b/packages/create-trigger/src/utils/installDependencies.ts @@ -0,0 +1,74 @@ +import { getUserPkgManager, type PackageManager } from "./getUserPkgManager.js"; +import { logger } from "./logger.js"; +import ora, { type Ora } from "ora"; +import chalk from "chalk"; +import { execa } from "execa"; + +export async function installDependencies(projectDir: string) { + logger.info("Installing dependencies..."); + + const pkgManager = getUserPkgManager(); + + const installSpinner = await runInstallCommand(pkgManager, projectDir); + + // If the spinner was used to show the progress, use succeed method on it + // If not, use the succeed on a new spinner + (installSpinner || ora()).succeed( + chalk.green("Successfully installed dependencies!\n") + ); +} + +async function runInstallCommand( + pkgManager: PackageManager, + projectDir: string +): Promise { + switch (pkgManager) { + // When using npm, inherit the stderr stream so that the progress bar is shown + case "npm": + await execa(pkgManager, ["install"], { + cwd: projectDir, + stderr: "inherit", + }); + + return null; + // When using yarn or pnpm, use the stdout stream and ora spinner to show the progress + case "pnpm": + const pnpmSpinner = ora("Running pnpm install...").start(); + const pnpmSubprocess = execa(pkgManager, ["install"], { + cwd: projectDir, + stdout: "pipe", + }); + + await new Promise((res, rej) => { + pnpmSubprocess.stdout?.on("data", (data: Buffer) => { + const text = data.toString(); + + if (text.includes("Progress")) { + pnpmSpinner.text = text.includes("|") + ? text.split(" | ")[1] ?? "" + : text; + } + }); + pnpmSubprocess.on("error", (e) => rej(e)); + pnpmSubprocess.on("close", () => res()); + }); + + return pnpmSpinner; + case "yarn": + const yarnSpinner = ora("Running yarn...").start(); + const yarnSubprocess = execa(pkgManager, [], { + cwd: projectDir, + stdout: "pipe", + }); + + await new Promise((res, rej) => { + yarnSubprocess.stdout?.on("data", (data: Buffer) => { + yarnSpinner.text = data.toString(); + }); + yarnSubprocess.on("error", (e) => rej(e)); + yarnSubprocess.on("close", () => res()); + }); + + return yarnSpinner; + } +} diff --git a/packages/create-trigger/src/utils/logNextSteps.ts b/packages/create-trigger/src/utils/logNextSteps.ts new file mode 100644 index 000000000..6e7b941d7 --- /dev/null +++ b/packages/create-trigger/src/utils/logNextSteps.ts @@ -0,0 +1,46 @@ +import { DEFAULT_APP_NAME, TRIGGER_BASE_URL } from "../consts.js"; +import { getUserPkgManager } from "./getUserPkgManager.js"; +import { logger } from "./logger.js"; +import { whoami } from "./triggerApi.js"; + +// This logs the next steps that the user should take in order to advance the project +export async function logNextSteps({ + projectName = DEFAULT_APP_NAME, + noInstall, + apiKey, +}: { + projectName: string; + noInstall: boolean; + apiKey?: string; +}) { + const pkgManager = getUserPkgManager(); + + logger.info("Next steps:"); + projectName !== "." && logger.info(` cd ${projectName}`); + if (noInstall) { + // To reflect yarn's default behavior of installing packages when no additional args provided + if (pkgManager === "yarn") { + logger.info(` ${pkgManager}`); + } else { + logger.info(` ${pkgManager} install`); + } + } + + if (!apiKey) { + logger.info( + ` visit ${TRIGGER_BASE_URL} to get your development API key and update your .env file` + ); + } + + logger.info(` ${pkgManager === "npm" ? "npm run" : pkgManager} dev`); + + if (apiKey) { + const org = await whoami(apiKey); + + if (org) { + logger.info( + ` visit ${TRIGGER_BASE_URL}/orgs/${org.organizationSlug} to see your triggers` + ); + } + } +} diff --git a/packages/create-trigger/src/utils/logger.ts b/packages/create-trigger/src/utils/logger.ts new file mode 100644 index 000000000..e971b8b95 --- /dev/null +++ b/packages/create-trigger/src/utils/logger.ts @@ -0,0 +1,16 @@ +import chalk from "chalk"; + +export const logger = { + error(...args: unknown[]) { + console.log(chalk.red(...args)); + }, + warn(...args: unknown[]) { + console.log(chalk.yellow(...args)); + }, + info(...args: unknown[]) { + console.log(chalk.cyan(...args)); + }, + success(...args: unknown[]) { + console.log(chalk.green(...args)); + }, +}; diff --git a/packages/create-trigger/src/utils/parseNameAndPath.ts b/packages/create-trigger/src/utils/parseNameAndPath.ts new file mode 100644 index 000000000..3b1fb5e5e --- /dev/null +++ b/packages/create-trigger/src/utils/parseNameAndPath.ts @@ -0,0 +1,38 @@ +import pathModule from "path"; + +/** + * Parses the projectName and its path from the user input. + * + * Returns a tuple of of `[projectName, path]`, where `projectName` is the name put in the "package.json" + * file and `path` is the path to the directory where the project will be created. + * + * If `projectName` is ".", the name of the directory will be used instead. Handles the case where the + * input includes a scoped package name in which case that is being parsed as the name, but not + * included as the path. + * + * For example: + * + * - dir/@mono/app => ["@mono/app", "dir/app"] + * - dir/app => ["app", "dir/app"] + */ +export const parseNameAndPath = (input: string) => { + const paths = input.split("/"); + + let projectName = paths[paths.length - 1]; + + // If the user ran `npx create-t3-app .` or similar, the projectName should be the current directory + if (projectName === ".") { + const parsedCwd = pathModule.resolve(process.cwd()); + projectName = pathModule.basename(parsedCwd); + } + + // If the first part is a @, it's a scoped package + const indexOfDelimiter = paths.findIndex((p) => p.startsWith("@")); + if (paths.findIndex((p) => p.startsWith("@")) !== -1) { + projectName = paths.slice(indexOfDelimiter).join("/"); + } + + const path = paths.filter((p) => !p.startsWith("@")).join("/"); + + return [projectName, path] as const; +}; diff --git a/packages/create-trigger/src/utils/renderTitle.ts b/packages/create-trigger/src/utils/renderTitle.ts new file mode 100644 index 000000000..8fde7f985 --- /dev/null +++ b/packages/create-trigger/src/utils/renderTitle.ts @@ -0,0 +1,24 @@ +import gradient from "gradient-string"; +import { TITLE_TEXT } from "../consts.js"; +import { getUserPkgManager } from "./getUserPkgManager.js"; + +// colors brought in from vscode poimandres theme +const poimandresTheme = { + blue: "#add7ff", + cyan: "#89ddff", + green: "#5de4c7", + magenta: "#fae4fc", + red: "#d0679d", + yellow: "#fffac2", +}; + +export const renderTitle = () => { + const triggerGradient = gradient(Object.values(poimandresTheme)); + + // resolves weird behavior where the ascii is offset + const pkgManager = getUserPkgManager(); + if (pkgManager === "yarn" || pkgManager === "pnpm") { + console.log(""); + } + console.log(triggerGradient.multiline(TITLE_TEXT)); +}; diff --git a/packages/create-trigger/src/utils/templateRef.ts b/packages/create-trigger/src/utils/templateRef.ts new file mode 100644 index 000000000..23f812d67 --- /dev/null +++ b/packages/create-trigger/src/utils/templateRef.ts @@ -0,0 +1,5 @@ +import { TEMPLATE_ORGANIZATION } from "../consts.js"; + +export function createTemplateRef(templateName: string): string { + return `github:${TEMPLATE_ORGANIZATION}/${templateName}`; +} diff --git a/packages/create-trigger/src/utils/triggerApi.ts b/packages/create-trigger/src/utils/triggerApi.ts new file mode 100644 index 000000000..5a9e942fc --- /dev/null +++ b/packages/create-trigger/src/utils/triggerApi.ts @@ -0,0 +1,80 @@ +import fetch from "node-fetch"; +import { TRIGGER_BASE_URL } from "../consts.js"; + +export type WhoamiResponse = { + organizationId: number; + env: string; + organizationSlug: string; +}; + +export async function whoami( + apiKey: string +): Promise { + const response = await fetch(`${TRIGGER_BASE_URL}/api/v1/internal/whoami`, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (response.ok) { + return response.json() as Promise; + } + + return; +} + +export type TriggerTemplate = { + id: string; + slug: string; + title: string; + shortTitle: string; + description: string; + imageUrl: string; + repositoryUrl: string; + markdownDocs: string; + runLocalDocs: string; + priority: number; + services: string[]; + workflowIds: string[]; + createdAt: string; + updatedAt: string; +}; + +export async function getTemplates(): Promise> { + const response = await fetch(`${TRIGGER_BASE_URL}/api/v1/templates`, { + method: "GET", + headers: { + Accept: "application/json", + }, + }); + + if (response.ok) { + return response.json() as Promise>; + } + + return []; +} + +export type TelemetryEvent = { + id: string; + event: string; + properties: Record; +}; + +export async function sendTelemetry(event: TelemetryEvent, apiKey?: string) { + const headers: Record = { + Accept: "application/json", + }; + + if (apiKey) { + headers.Authorization = `Bearer ${apiKey}`; + } + + await fetch(`${TRIGGER_BASE_URL}/api/v1/internal/telemetry`, { + method: "POST", + headers, + body: JSON.stringify(event), + }); +} diff --git a/packages/create-trigger/tsconfig.json b/packages/create-trigger/tsconfig.json new file mode 100644 index 000000000..88dfdb381 --- /dev/null +++ b/packages/create-trigger/tsconfig.json @@ -0,0 +1,49 @@ +{ + "include": ["src", "tsup.config.ts"], + "compilerOptions": { + /* LANGUAGE COMPILATION OPTIONS */ + "target": "ES2020", + "lib": ["DOM", "DOM.Iterable", "ES2020"], + "module": "Node16", + "moduleResolution": "nodenext", + "resolveJsonModule": true, + "allowJs": true, + "checkJs": true, + + /* EMIT RULES */ + "outDir": "./dist", + "noEmit": true, // TSUP takes care of emitting js for us, in a MUCH faster way + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "removeComments": true, + + /* TYPE CHECKING RULES */ + "strict": true, + // "noImplicitAny": true, // Included in "Strict" + // "noImplicitThis": true, // Included in "Strict" + // "strictBindCallApply": true, // Included in "Strict" + // "strictFunctionTypes": true, // Included in "Strict" + // "strictNullChecks": true, // Included in "Strict" + // "strictPropertyInitialization": true, // Included in "Strict" + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "useUnknownInCatchVariables": true, + "noUncheckedIndexedAccess": true, // TLDR - Checking an indexed value (array[0]) now forces type as there is no confirmation that index exists + // THE BELOW ARE EXTRA STRICT OPTIONS THAT SHOULD ONLY BY CONSIDERED IN VERY SAFE PROJECTS + // "exactOptionalPropertyTypes": true, // TLDR - Setting to undefined is not the same as a property not being defined at all + // "noPropertyAccessFromIndexSignature": true, // TLDR - Use dot notation for objects if youre sure it exists, use ['index'] notaion if unsure + + /* OTHER OPTIONS */ + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + // "emitDecoratorMetadata": true, + // "experimentalDecorators": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "useDefineForClassFields": true + } +} diff --git a/packages/create-trigger/tsup.config.ts b/packages/create-trigger/tsup.config.ts new file mode 100644 index 000000000..94937b364 --- /dev/null +++ b/packages/create-trigger/tsup.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "tsup"; + +const isDev = process.env.npm_lifecycle_event === "dev"; + +export default defineConfig({ + clean: true, + dts: true, + entry: ["src/index.ts"], + format: ["esm"], + minify: !isDev, + metafile: !isDev, + sourcemap: true, + target: "esnext", + outDir: "dist", + onSuccess: isDev ? "node dist/index.js" : undefined, +});