Adding the create-trigger CLI package
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
};
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
Reference in New Issue
Block a user