Adding the create-trigger CLI package

This commit is contained in:
Eric Allam
2023-02-24 13:13:41 +00:00
parent b69058bfcb
commit 74a6a21c35
22 changed files with 1200 additions and 0 deletions
@@ -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" });
}
@@ -14,5 +14,6 @@ export async function loader({ request }: LoaderArgs) {
return json({
organizationId: authenticatedEnv.organizationId,
env: authenticatedEnv.slug,
organizationSlug: authenticatedEnv.organization.slug,
});
}
+77
View File
@@ -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)
+68
View File
@@ -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"
}
}
+295
View File
@@ -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 <project-name>",
"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 <api-key>",
"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<string> => {
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<string> => {
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<string | undefined> => {
// 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<boolean> => {
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<boolean> => {
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;
};
+22
View File
@@ -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";
+71
View File
@@ -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);
});
@@ -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=<enter your API key here>";
await fs.writeFile(envPath, envContents);
}
@@ -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);
}
@@ -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(),
},
};
}
@@ -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";
}
};
@@ -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";
};
+137
View File
@@ -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<boolean> => {
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`
);
}
};
@@ -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<Ora | null> {
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<void>((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<void>((res, rej) => {
yarnSubprocess.stdout?.on("data", (data: Buffer) => {
yarnSpinner.text = data.toString();
});
yarnSubprocess.on("error", (e) => rej(e));
yarnSubprocess.on("close", () => res());
});
return yarnSpinner;
}
}
@@ -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`
);
}
}
}
@@ -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));
},
};
@@ -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;
};
@@ -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));
};
@@ -0,0 +1,5 @@
import { TEMPLATE_ORGANIZATION } from "../consts.js";
export function createTemplateRef(templateName: string): string {
return `github:${TEMPLATE_ORGANIZATION}/${templateName}`;
}
@@ -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<WhoamiResponse | undefined> {
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<WhoamiResponse>;
}
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<Array<TriggerTemplate>> {
const response = await fetch(`${TRIGGER_BASE_URL}/api/v1/templates`, {
method: "GET",
headers: {
Accept: "application/json",
},
});
if (response.ok) {
return response.json() as Promise<Array<TriggerTemplate>>;
}
return [];
}
export type TelemetryEvent = {
id: string;
event: string;
properties: Record<string | number, any>;
};
export async function sendTelemetry(event: TelemetryEvent, apiKey?: string) {
const headers: Record<string, string> = {
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),
});
}
+49
View File
@@ -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 <T | undefined> 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
}
}
+16
View File
@@ -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,
});