CLI create-integration templates and shared configs (#511)

* Add tsup config package

* Add integration tsconfig to extend from

* Return correct version via cli -v

* Make create-integration use templates

* Add changeset

* Switch to liquidjs templates

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
This commit is contained in:
nicktrn
2023-10-13 16:55:22 +01:00
committed by GitHub
parent 6769d6b439
commit 9df93d0798
25 changed files with 1369 additions and 98 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/cli": patch
---
Improve create-integration output. Use templates and shared configs.
@@ -3,6 +3,8 @@
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2019"],
"paths": {
"@trigger.dev/tsup/*": ["../../config-packages/tsup/src/*"],
"@trigger.dev/tsup": ["../../config-packages/tsup/src/index"],
"@trigger.dev/sdk/*": ["../../packages/trigger-sdk/src/*"],
"@trigger.dev/sdk": ["../../packages/trigger-sdk/src/index"],
"@trigger.dev/integration-kit/*": ["../../packages/integration-kit/src/*"],
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@trigger.dev/tsup",
"version": "0.0.0",
"private": true,
"license": "MIT",
"devDependencies": {
"tsup": "7.1.x"
}
}
+3
View File
@@ -0,0 +1,3 @@
export { defineConfig } from "tsup";
export { deepMergeOptions } from "./utils";
export { options as integrationOptions } from "./integration";
+22
View File
@@ -0,0 +1,22 @@
import { Options, defineConfig } from "tsup";
export const options: Options = {
name: "main",
entry: ["./src/index.ts"],
outDir: "./dist",
platform: "node",
format: ["cjs"],
legacyOutput: true,
sourcemap: true,
clean: true,
bundle: true,
splitting: false,
dts: true,
treeshake: {
preset: "smallest",
},
esbuildPlugins: [],
external: ["http", "https", "util", "events", "tty", "os", "timers"],
};
export default defineConfig(options);
+32
View File
@@ -0,0 +1,32 @@
import { Options } from "tsup";
export const deepMergeOptions = deepMergeRecords<Options>;
function deepMergeRecords<TRecord extends Record<any, any>>(...options: TRecord[]): TRecord {
const result = {} as TRecord;
for (const option of options) {
for (const key in option) {
if (option.hasOwnProperty(key)) {
const optionValue = option[key];
const existingValue = result[key];
if (
existingValue &&
typeof existingValue === "object" &&
typeof optionValue === "object" &&
!Array.isArray(existingValue) &&
!Array.isArray(optionValue) &&
existingValue !== null &&
optionValue !== null
) {
result[key] = deepMergeRecords(existingValue, optionValue);
} else {
result[key] = optionValue;
}
}
}
}
return result;
}
+1
View File
@@ -68,6 +68,7 @@
"execa": "^7.0.0",
"gradient-string": "^2.0.2",
"inquirer": "^9.1.4",
"liquidjs": "^10.9.2",
"localtunnel": "^2.0.2",
"mock-fs": "^5.2.0",
"nanoid": "^4.0.2",
+4 -1
View File
@@ -14,7 +14,10 @@ import { checkApiKeyIsDevServer } from "../utils/getApiKeyType";
export const program = new Command();
program.name(COMMAND_NAME).description("The Trigger.dev CLI").version("0.0.1");
program
.name(COMMAND_NAME)
.description("The Trigger.dev CLI")
.version(getVersion(), "-v, --version", "Display the version number");
program
.command("init")
+123 -97
View File
@@ -9,7 +9,8 @@ import { generateIntegrationFiles } from "../utils/generateIntegrationFiles";
import { getPackageName } from "../utils/getPackagName";
import { installDependencies } from "../utils/installDependencies";
import { logger } from "../utils/logger";
import { resolvePath } from "../utils/parseNameAndPath";
import { relativePath, resolvePath } from "../utils/parseNameAndPath";
import { createIntegrationFileFromTemplate } from "../utils/createIntegrationFileFromTemplate";
const CLIOptionsSchema = z.object({
packageName: z.string().optional(),
@@ -91,108 +92,107 @@ export async function createIntegrationCommand(path: string, cliOptions: any) {
process.exit(1);
}
// Create the package.json
const packageJson = {
name: resolvedOptions.packageName,
version: "0.0.1",
description: `Trigger.dev integration for ${resolvedOptions.sdkPackage}`,
main: "./dist/index.js",
types: "./dist/index.d.ts",
publishConfig: {
access: "public",
},
files: ["dist/index.js", "dist/index.d.ts", "dist/index.js.map"],
devDependencies: {
"@types/node": "16.x",
rimraf: "^3.0.2",
tsup: "7.1.x",
typescript: "4.9.4",
},
scripts: {
clean: "rimraf dist",
build: "npm run clean && npm run build:tsup",
"build:tsup": "tsup",
typecheck: "tsc --noEmit",
},
dependencies: {
[latestVersion.name]: `^${latestVersion.version}`,
[sdkVersion.name]: sdkVersion.version,
[integrationKitVersion.name]: integrationKitVersion.version,
},
engines: {
node: ">=16.8.0",
},
const integrationVersion = await getInternalOrExternalPackageVersion({
path: "integrations/github",
packageName: "@trigger.dev/github",
tag: "latest",
monorepoPath: triggerMonorepoPath,
prependWorkspace: false,
});
if (!integrationVersion) {
logger.error(
`Could not find the latest version of @trigger.dev/github. Please try again later.`
);
process.exit(1);
}
const baseVariables = {
packageName: resolvedOptions.packageName,
sdkPackage: resolvedOptions.sdkPackage,
integrationVersion: integrationVersion,
latestVersion: latestVersion,
sdkVersion: sdkVersion,
integrationKitVersion: integrationKitVersion,
triggerMonorepoPath,
};
await createFileInPath(resolvedPath, "package.json", JSON.stringify(packageJson, null, 2));
const getOutputPath = (relativePath: string) => pathModule.join(resolvedPath, relativePath);
// Create the tsconfig.json
const tsconfigJson = {
compilerOptions: {
composite: false,
declaration: false,
declarationMap: false,
esModuleInterop: true,
forceConsistentCasingInFileNames: true,
inlineSources: false,
isolatedModules: true,
moduleResolution: "node16",
noUnusedLocals: false,
noUnusedParameters: false,
preserveWatchOutput: true,
skipLibCheck: true,
strict: true,
experimentalDecorators: true,
emitDecoratorMetadata: true,
sourceMap: true,
resolveJsonModule: true,
lib: ["es2019"],
module: "commonjs",
target: "es2021",
const miscIntegrationFiles = [
{
relativeTemplatePath: "package.json.j2",
outputPath: getOutputPath("package.json"),
},
include: ["./src/**/*.ts", "tsup.config.ts"],
exclude: ["node_modules"],
};
await createFileInPath(resolvedPath, "tsconfig.json", JSON.stringify(tsconfigJson, null, 2));
const readme = `
# ${resolvedOptions.packageName}
`;
await createFileInPath(resolvedPath, "README.md", readme);
// Create the tsup.config.ts
const tsupConfig = `
import { defineConfig } from "tsup";
export default defineConfig([
{
name: "main",
entry: ["./src/index.ts"],
outDir: "./dist",
platform: "node",
format: ["cjs"],
legacyOutput: true,
sourcemap: true,
clean: true,
bundle: true,
splitting: false,
dts: true,
treeshake: {
preset: "smallest",
{
// use `tsc --showConfig` to update external tsconfig
relativeTemplatePath: `tsconfig-${triggerMonorepoPath ? "internal" : "external"}.json.j2`,
outputPath: getOutputPath("tsconfig.json"),
},
esbuildPlugins: [],
external: ["http", "https", "util", "events", "tty", "os", "timers"],
},
]);
{
relativeTemplatePath: `tsup.config-${triggerMonorepoPath ? "internal" : "external"}.js.j2`,
outputPath: getOutputPath("tsup.config.ts"),
},
{
relativeTemplatePath: "README.md.j2",
outputPath: getOutputPath("README.md"),
},
];
`;
await createFileInPath(resolvedPath, "tsup.config.ts", tsupConfig);
await createIntegrationFiles(miscIntegrationFiles, baseVariables);
// create src/*
if (resolvedOptions.skipGeneratingCode) {
await createFileInPath(resolvedPath, "src/index.ts", "export {}");
const getSrcOutputPath = (relativePath: string) =>
getOutputPath(pathModule.join("src", relativePath));
const srcIntegrationFiles = [
{
relativeTemplatePath: pathModule.join("payload-examples", "index.js.j2"),
outputPath: getSrcOutputPath(pathModule.join("payload-examples", "index.ts")),
},
{
relativeTemplatePath: "events.js.j2",
outputPath: getSrcOutputPath("events.ts"),
},
{
relativeTemplatePath: "index.js.j2",
outputPath: getSrcOutputPath("index.ts"),
},
{
relativeTemplatePath: "models.js.j2",
outputPath: getSrcOutputPath("models.ts"),
},
{
relativeTemplatePath: "schemas.js.j2",
outputPath: getSrcOutputPath("schemas.ts"),
},
{
relativeTemplatePath: "types.js.j2",
outputPath: getSrcOutputPath("types.ts"),
},
{
relativeTemplatePath: "utils.js.j2",
outputPath: getSrcOutputPath("utils.ts"),
},
{
relativeTemplatePath: "webhooks.js.j2",
outputPath: getSrcOutputPath("webhooks.ts"),
},
];
const validIdentifier = pathModule
.basename(path)
.replace(/[^a-zA-Z0-9]+/g, "")
.replace(/^[0-9]+/g, "");
await createIntegrationFiles(srcIntegrationFiles, {
...baseVariables,
apiKeyPropertyName: "apiKey", // TODO: prompt for this
authMethod: resolvedOptions.authMethod,
identifier: validIdentifier.length ? validIdentifier : "packageName",
});
} else {
await attemptToGenerateIntegrationFiles(pathModule.join(resolvedPath, "src"), resolvedOptions);
}
@@ -295,8 +295,9 @@ const resolveOptionsWithPrompts = async (
resolvedOptions.skipGeneratingCode = true;
}
resolvedOptions.authMethod = await promptAuthMethod();
if (!resolvedOptions.skipGeneratingCode) {
resolvedOptions.authMethod = await promptAuthMethod();
resolvedOptions.extraInfo = await promptExtraInfo();
}
} catch (err) {
@@ -448,11 +449,13 @@ async function getInternalOrExternalPackageVersion({
tag,
path,
monorepoPath,
prependWorkspace = true,
}: {
packageName: string;
tag: string;
path: string;
monorepoPath?: string;
prependWorkspace?: boolean;
}): Promise<{ name: string; version: string } | undefined> {
if (!monorepoPath) {
return await getLatestPackageVersion(packageName, tag);
@@ -470,7 +473,7 @@ async function getInternalOrExternalPackageVersion({
return {
name: packageJson.name,
version: `workspace:^${packageJson.version}`,
version: `${prependWorkspace ? "workspace:^" : ""}${packageJson.version}`,
};
}
@@ -550,3 +553,26 @@ async function updateJobCatalogWithNewIntegration(
};
await writeJSONFile(tsConfigPath, newTsConfig);
}
const createIntegrationFiles = async (
files: {
relativeTemplatePath: string;
outputPath: string;
}[],
variables?: Record<string, any>
) => {
for (const file of files) {
const result = await createIntegrationFileFromTemplate({ ...file, variables });
handleCreateResult(file.outputPath, result);
}
};
const handleCreateResult = (
outputPath: string,
result: Awaited<ReturnType<typeof createIntegrationFileFromTemplate>>
) => {
if (!result.success) {
throw new Error(`Failed to create ${pathModule.basename(outputPath)}: ${result.error}`);
}
logger.success(`✔ Created ${pathModule.basename(outputPath)} at ${relativePath(outputPath)}`);
};
@@ -0,0 +1 @@
# {{ packageName }}
@@ -0,0 +1,118 @@
import { EventSpecification } from "@trigger.dev/sdk";
import { CommentEvent, IssueEvent } from "./schemas";
import { Get{{ identifier | capitalize }}Payload } from "./types";
import {
commentCreated,
commentRemoved,
commentUpdated,
issueCreated,
issueRemoved,
issueUpdated,
} from "./payload-examples";
import { onCommentProperties, onIssueProperties, updatedFromProperties } from "./utils";
export const onComment: EventSpecification<Get{{ identifier | capitalize }}Payload<CommentEvent>> = {
name: "Comment",
title: "On Comment",
source: "linear.app",
icon: "linear",
examples: [commentCreated, commentRemoved, commentUpdated],
parsePayload: (payload) => payload as Get{{ identifier | capitalize }}Payload<CommentEvent>,
runProperties: (payload) => [
{ label: "Event action", text: payload.action },
...onCommentProperties(payload),
...updatedFromProperties(payload),
],
};
export const onCommentCreated: EventSpecification<Get{{ identifier | capitalize }}Payload<CommentEvent, "create">> = {
name: "Comment",
title: "On Comment Created",
source: "linear.app",
icon: "linear",
filter: {
action: ["create"],
},
examples: [commentCreated],
parsePayload: (payload) => payload as Get{{ identifier | capitalize }}Payload<CommentEvent, "create">,
runProperties: (payload) => onCommentProperties(payload),
};
export const onCommentRemoved: EventSpecification<Get{{ identifier | capitalize }}Payload<CommentEvent, "remove">> = {
name: "Comment",
title: "On Comment Removed",
source: "linear.app",
icon: "linear",
filter: {
action: ["remove"],
},
examples: [commentRemoved],
parsePayload: (payload) => payload as Get{{ identifier | capitalize }}Payload<CommentEvent, "remove">,
runProperties: (payload) => onCommentProperties(payload),
};
export const onCommentUpdated: EventSpecification<Get{{ identifier | capitalize }}Payload<CommentEvent, "update">> = {
name: "Comment",
title: "On Comment Updated",
source: "linear.app",
icon: "linear",
filter: {
action: ["update"],
},
examples: [commentUpdated],
parsePayload: (payload) => payload as Get{{ identifier | capitalize }}Payload<CommentEvent, "update">,
runProperties: (payload) => [...onCommentProperties(payload), ...updatedFromProperties(payload)],
};
export const onIssue: EventSpecification<Get{{ identifier | capitalize }}Payload<IssueEvent>> = {
name: "Issue",
title: "On Issue",
source: "linear.app",
icon: "linear",
examples: [issueCreated, issueRemoved, issueUpdated],
parsePayload: (payload) => payload as Get{{ identifier | capitalize }}Payload<IssueEvent>,
runProperties: (payload) => [
{ label: "Event action", text: payload.action },
...onIssueProperties(payload),
...updatedFromProperties(payload),
],
};
export const onIssueCreated: EventSpecification<Get{{ identifier | capitalize }}Payload<IssueEvent, "create">> = {
name: "Issue",
title: "On Issue Created",
source: "linear.app",
icon: "linear",
filter: {
action: ["create"],
},
examples: [issueCreated],
parsePayload: (payload) => payload as Get{{ identifier | capitalize }}Payload<IssueEvent, "create">,
runProperties: (payload) => onIssueProperties(payload),
};
export const onIssueRemoved: EventSpecification<Get{{ identifier | capitalize }}Payload<IssueEvent, "remove">> = {
name: "Issue",
title: "On Issue Removed",
source: "linear.app",
icon: "linear",
filter: {
action: ["remove"],
},
examples: [issueRemoved],
parsePayload: (payload) => payload as Get{{ identifier | capitalize }}Payload<IssueEvent, "remove">,
runProperties: (payload) => onIssueProperties(payload),
};
export const onIssueUpdated: EventSpecification<Get{{ identifier | capitalize }}Payload<IssueEvent, "update">> = {
name: "Issue",
title: "On Issue Updated",
source: "linear.app",
icon: "linear",
filter: {
action: ["update"],
},
examples: [issueUpdated],
parsePayload: (payload) => payload as Get{{ identifier | capitalize }}Payload<IssueEvent, "update">,
runProperties: (payload) => [...onIssueProperties(payload), ...updatedFromProperties(payload)],
};
@@ -0,0 +1,244 @@
import {
TriggerIntegration,
RunTaskOptions,
IO,
IOTask,
IntegrationTaskKey,
RunTaskErrorCallback,
Json,
retry,
ConnectionAuth,
Prettify,
} from "@trigger.dev/sdk";
import {{ identifier | capitalize }}Client from "{{ sdkPackage }}";
import * as events from "./events";
import { {{ identifier | capitalize }}ReturnType, Serialized{{ identifier | capitalize }}Output } from "./types";
import { TriggerParams, Webhooks, createTrigger, createWebhookEventSource } from "./webhooks";
import { Models } from "./models";
export type {{ identifier | capitalize }}IntegrationOptions = {
id: string;
{{ apiKeyPropertyName }}: string;
};
export type {{ identifier | capitalize }}RunTask = InstanceType<typeof {{ identifier | capitalize }}>["runTask"];
export class {{ identifier | capitalize }}{{ " " }} implements TriggerIntegration {
private _options: {{ identifier | capitalize }}IntegrationOptions;
private _client?: any;
private _io?: IO;
private _connectionKey?: string;
constructor(private options: {{ identifier | capitalize }}IntegrationOptions) {
if (Object.keys(options).includes("{{ apiKeyPropertyName }}") && !options.{{ apiKeyPropertyName }}) {
throw `Can't create {{ identifier | capitalize }} integration (${options.id}) as {{ apiKeyPropertyName }} was undefined`;
}
this._options = options;
}
get authSource() {
{% case authMethod %}
{% when "api-key" %}
return "LOCAL" as const;
{% when "oauth" %}
return "HOSTED" as const;
{% when "both-methods" %}
return this._options.{{ apiKeyPropertyName }} ? "LOCAL" : "HOSTED";
{% endcase %}
}
get id() {
return this.options.id;
}
get metadata() {
return { id: "{{ identifier }}", name: "{{ identifier | capitalize }}" };
}
get source() {
return createWebhookEventSource(this);
}
cloneForRun(io: IO, connectionKey: string, auth?: ConnectionAuth) {
const {{ identifier }} = new {{ identifier | capitalize }}(this._options);
{{ identifier }}._io = io;
{{ identifier }}._connectionKey = connectionKey;
{{ identifier }}._client = this.createClient(auth);
return {{ identifier }};
}
createClient(auth?: ConnectionAuth) {
// oauth
if (auth) {
return new {{ identifier | capitalize }}Client({
auth: auth.accessToken,
});
}
// apiKey auth
if (this._options.{{ apiKeyPropertyName }}) {
return new {{ identifier | capitalize }}Client({
apiKey: this._options.{{ apiKeyPropertyName }},
});
}
throw new Error("No auth");
}
runTask<T, TResult extends Json<T> | void>(
key: IntegrationTaskKey,
callback: (client: {{ identifier | capitalize }}Client, task: IOTask, io: IO) => Promise<TResult>,
options?: RunTaskOptions,
errorCallback?: RunTaskErrorCallback
): Promise<TResult> {
if (!this._io) throw new Error("No IO");
if (!this._connectionKey) throw new Error("No connection key");
return this._io.runTask<TResult>(
key,
(task, io) => {
if (!this._client) throw new Error("No client");
return callback(this._client, task, io);
},
{
icon: "{{ identifier }}",
retry: retry.standardBackoff,
...(options ?? {}),
connectionKey: this._connectionKey,
},
errorCallback ?? onError
);
}
// top-level task
request<T = any>(
key: IntegrationTaskKey,
params: {
route: string | URL;
options: Parameters<{{ identifier | capitalize }}Client["request"]>[1];
}
): {{ identifier | capitalize }}ReturnType<T> {
return this.runTask(
key,
async (client) => {
const response = await client.request(params.route, params.options);
return response.json();
},
{
name: "Send Request",
params,
properties: [
{ label: "Route", text: params.route.toString() },
...(params.options.method ? [{ label: "Method", text: params.options.method }] : []),
],
callback: { enabled: true },
}
);
}
// nested tasks
get models() {
return new Models(this.runTask.bind(this));
}
// events
onComment(params: TriggerParams = {}) {
return createTrigger(this.source, events.onComment, params);
}
onCommentCreated(params: TriggerParams = {}) {
return createTrigger(this.source, events.onCommentCreated, params);
}
onCommentRemoved(params: TriggerParams = {}) {
return createTrigger(this.source, events.onCommentRemoved, params);
}
onCommentUpdated(params: TriggerParams = {}) {
return createTrigger(this.source, events.onCommentUpdated, params);
}
// triggers (webhooks)
// private, just here to keep webhook logic in a separate file
get #webhooks() {
return new Webhooks(this.runTask.bind(this));
}
webhook = this.#webhooks.webhook;
webhooks = this.#webhooks.webhooks;
createWebhook = this.#webhooks.createWebhook;
deleteWebhook = this.#webhooks.deleteWebhook;
updateWebhook = this.#webhooks.updateWebhook;
}
class {{ identifier | capitalize }}ApiError extends Error {
constructor(
message: string,
readonly request: Request,
readonly response: Response
) {
super(message);
this.name = "{{ identifier | capitalize }}ApiError";
}
}
function is{{ identifier | capitalize }}ApiError(error: unknown): error is {{ identifier | capitalize }}ApiError {
if (typeof error !== "object" || error === null) {
return false;
}
const apiError = error as {{ identifier | capitalize }}ApiError;
return (
apiError.name === "{{ identifier | capitalize }}ApiError" &&
apiError.request instanceof Request &&
apiError.response instanceof Response
);
}
function shouldRetry(method: string, status: number) {
return status === 429 || (method === "GET" && status >= 500);
}
export function onError(error: unknown): ReturnType<RunTaskErrorCallback> {
if (!is{{ identifier | capitalize }}ApiError(error)) {
return;
}
if (!shouldRetry(error.request.method, error.response.status)) {
return {
skipRetrying: true,
};
}
const rateLimitRemaining = error.response.headers.get("ratelimit-remaining");
const rateLimitReset = error.response.headers.get("ratelimit-reset");
if (rateLimitRemaining === "0" && rateLimitReset) {
const resetDate = new Date(Number(rateLimitReset) * 1000);
if (!Number.isNaN(resetDate.getTime())) {
return {
retryAt: resetDate,
error,
};
}
}
}
export const serialize{{ identifier | capitalize }}Output = <T>(obj: T): Prettify<Serialized{{ identifier | capitalize }}Output<T>> => {
return JSON.parse(JSON.stringify(obj), (key, value) => {
if (typeof value === "function" || key.startsWith("_")) {
return undefined;
}
return value;
});
};
@@ -0,0 +1,79 @@
import { IntegrationTaskKey } from "@trigger.dev/sdk";
import { Model, ModelVersion } from "{{ sdkPackage }}";
import { {{ capitalizedIdentifier }}RunTask } from "./index";
import { modelProperties } from "./utils";
import { {{ capitalizedIdentifier }}ReturnType } from "./types";
export class Models {
constructor(private runTask: {{ capitalizedIdentifier }}RunTask) {}
get(
key: IntegrationTaskKey,
params: {
model_owner: string;
model_name: string;
}
): {{ capitalizedIdentifier }}ReturnType<Model> {
return this.runTask(
key,
(client) => {
return client.models.get(params.model_owner, params.model_name);
},
{
name: "Get Model",
params,
properties: modelProperties(params),
}
);
}
get versions() {
return new Versions(this.runTask);
}
}
class Versions {
constructor(private runTask: {{ capitalizedIdentifier }}RunTask) {}
get(
key: IntegrationTaskKey,
params: {
model_owner: string;
model_name: string;
version_id: string;
}
): {{ capitalizedIdentifier }}ReturnType<ModelVersion> {
return this.runTask(
key,
(client) => {
return client.models.versions.get(params.model_owner, params.model_name, params.version_id);
},
{
name: "Get Model Version",
params,
properties: modelProperties(params),
}
);
}
list(
key: IntegrationTaskKey,
params: {
model_owner: string;
model_name: string;
}
): {{ capitalizedIdentifier }}ReturnType<ModelVersion[]> {
return this.runTask(
key,
(client) => {
return client.models.versions.list(params.model_owner, params.model_name);
},
{
name: "List Models",
params,
properties: modelProperties(params),
}
);
}
}
@@ -0,0 +1,40 @@
{
"name": "{{ packageName }}",
"version": "{{ integrationVersion.version }}",
"description": "Trigger.dev integration for {{ sdkPackage }}",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"publishConfig": {
"access": "public"
},
"files": [
"dist/index.js",
"dist/index.d.ts",
"dist/index.js.map"
],
"devDependencies": {
{% if triggerMonorepoPath %}
"@trigger.dev/tsconfig": "workspace:*",
"@trigger.dev/tsup": "workspace:*",
{% endif %}
"@types/node": "16.x",
"rimraf": "^3.0.2",
"tsup": "7.1.x",
"typescript": "4.9.4"
},
"scripts": {
"clean": "rimraf dist",
"build": "npm run clean && npm run build:tsup",
"build:tsup": "tsup",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"{{ latestVersion.name }}": "^{{ latestVersion.version }}",
"{{ sdkVersion.name }}": "{{ sdkVersion.version }}",
"{{ integrationKitVersion.name }}": "{{ integrationKitVersion.version }}",
"zod": "3.21.4"
},
"engines": {
"node": ">=16.8.0"
}
}
@@ -0,0 +1,40 @@
import { EventSpecificationExample } from "@trigger.dev/sdk";
import CommentCreated from "./CommentCreated.json"
import CommentRemoved from "./CommentRemoved.json"
import CommentUpdated from "./CommentUpdated.json"
import IssueCreated from "./IssueCreated.json"
import IssueRemoved from "./IssueRemoved.json"
import IssueUpdated from "./IssueUpdated.json"
export const commentCreated: EventSpecificationExample = {
id: "CommentCreated",
name: "Comment created",
payload: CommentCreated,
};
export const commentRemoved: EventSpecificationExample = {
id: "CommentRemoved",
name: "Comment removed",
payload: CommentRemoved,
};
export const commentUpdated: EventSpecificationExample = {
id: "CommentUpdated",
name: "Comment updated",
payload: CommentUpdated,
};
export const issueCreated: EventSpecificationExample = {
id: "IssueCreated",
name: "Issue created",
payload: IssueCreated,
};
export const issueRemoved: EventSpecificationExample = {
id: "IssueRemoved",
name: "Issue removed",
payload: IssueRemoved,
};
export const issueUpdated: EventSpecificationExample = {
id: "IssueUpdated",
name: "Issue updated",
payload: IssueUpdated,
};
@@ -0,0 +1,120 @@
import { z } from "zod";
export const WebhookResourceTypeSchema = z.union([
z.literal("Comment"),
z.literal("Issue"),
]);
export type WebhookResourceType = z.infer<typeof WebhookResourceTypeSchema>;
export const WebhookActionTypeSchema = z.union([
z.literal("create"),
z.literal("remove"),
z.literal("update"),
]);
export type WebhookActionType = z.infer<typeof WebhookActionTypeSchema>;
const IssueLabelDataSchema = z.object({
archivedAt: z.coerce.date().optional().nullable(),
color: z.string(),
createdAt: z.coerce.date(),
creatorId: z.string().optional().nullable(),
description: z.string().optional().nullable(),
id: z.string(),
name: z.string(),
organizationId: z.string(),
parentId: z.string().optional().nullable(),
teamId: z.string().optional().nullable(),
updatedAt: z.coerce.date(),
});
const IssueDataSchema = z.object({
archivedAt: z.coerce.date().optional().nullable(),
assignee: z.object({ id: z.string(), name: z.string() }).optional().nullable(),
assigneeId: z.string().optional().nullable(),
id: z.string(),
labelIds: z.array(z.string()),
labels: z.array(IssueLabelDataSchema.pick({ id: true, color: true, name: true })),
number: z.number(),
parentId: z.string().optional().nullable(),
previousIdentifiers: z.array(z.string()),
priority: z.number(),
priorityLabel: z.string(),
projectId: z.string().optional().nullable(),
sortOrder: z.number(),
team: z.object({ id: z.string(), key: z.string(), name: z.string() }),
teamId: z.string(),
title: z.string(),
trashed: z.boolean().optional().nullable(),
triagedAt: z.coerce.date().optional().nullable(),
updatedAt: z.coerce.date(),
});
const CommentDataSchema = z.object({
archivedAt: z.coerce.date().optional().nullable(),
body: z.string(),
botActorId: z.string().optional().nullable(),
createdAt: z.coerce.date(),
editedAt: z.string().optional().nullable(),
id: z.string(),
issue: IssueDataSchema.pick({ id: true, title: true }),
issueId: z.string(),
parentId: z.string().optional().nullable(),
reactionData: z.array(z.object({}).passthrough()),
updatedAt: z.coerce.date(),
userId: z.string().optional().nullable(),
});
export const WebhookPayloadBaseSchema = z.object({
createdAt: z.coerce.date(),
organizationId: z.string().optional().nullable(),
url: z.string().url().optional().nullable(),
webhookId: z.string(),
webhookTimestamp: z.coerce.date(),
});
const CREATE = z.literal("create");
const REMOVE = z.literal("remove");
const UPDATE = z.literal("update");
export const CommentEventBaseSchema = WebhookPayloadBaseSchema.extend({
type: z.literal("Comment"),
data: CommentDataSchema,
});
export const CommentEventSchema = z.discriminatedUnion("action", [
CommentEventBaseSchema.extend({
action: CREATE,
}),
CommentEventBaseSchema.extend({
action: REMOVE,
}),
CommentEventBaseSchema.extend({
action: UPDATE,
updatedFrom: CommentDataSchema.partial(),
}),
]);
export type CommentEvent = z.infer<typeof CommentEventSchema>;
export const IssueEventBaseSchema = WebhookPayloadBaseSchema.extend({
type: z.literal("Issue"),
data: IssueDataSchema,
});
export const IssueEventSchema = z.discriminatedUnion("action", [
IssueEventBaseSchema.extend({
action: CREATE,
}),
IssueEventBaseSchema.extend({
action: REMOVE,
}),
IssueEventBaseSchema.extend({
action: UPDATE,
updatedFrom: IssueDataSchema.partial(),
}),
]);
export type IssueEvent = z.infer<typeof IssueEventSchema>;
export const WebhookPayloadSchema = z.union([
CommentEventSchema,
IssueEventSchema,
]);
export type WebhookPayload = z.infer<typeof WebhookPayloadSchema>;
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"composite": false,
"declaration": false,
"declarationMap": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"inlineSources": false,
"isolatedModules": true,
"moduleResolution": "node",
"noUnusedLocals": false,
"noUnusedParameters": false,
"preserveWatchOutput": true,
"skipLibCheck": true,
"strict": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"sourceMap": true,
"resolveJsonModule": true,
"lib": ["dom", "dom.iterable", "es2019"],
"module": "commonjs",
"target": "es2021",
"stripInternal": true
},
"include": ["./src/**/*.ts", "tsup.config.ts"],
"exclude": ["node_modules"]
}
@@ -0,0 +1,4 @@
{
"extends": "@trigger.dev/tsconfig/integration.json",
"include": ["./src/**/*.ts", "tsup.config.ts"]
}
@@ -0,0 +1,20 @@
import { defineConfig } from "tsup";
export default defineConfig({
name: "main",
entry: ["./src/index.ts"],
outDir: "./dist",
platform: "node",
format: ["cjs"],
legacyOutput: true,
sourcemap: true,
clean: true,
bundle: true,
splitting: false,
dts: true,
treeshake: {
preset: "smallest",
},
esbuildPlugins: [],
external: ["http", "https", "util", "events", "tty", "os", "timers"],
});
@@ -0,0 +1,7 @@
import { defineConfig, deepMergeOptions, integrationOptions } from "@trigger.dev/tsup";
const options = deepMergeOptions(integrationOptions, {
// extend base config here
});
export default defineConfig(options);
@@ -0,0 +1,28 @@
import { Request } from "{{ sdkPackage }}";
import { WebhookActionType, WebhookPayload } from "./schemas";
export type Get{{ identifier | capitalize }}Payload<
TPayload extends WebhookPayload,
TAction extends any = any,
> = TAction extends WebhookActionType ? Extract<TPayload, { action: TAction }> : TPayload;
type FunctionKeys<T> = {
[K in keyof T]: T[K] extends Function ? K : never;
}[keyof T];
export type Serialized{{ identifier | capitalize }}Output<T> = T extends object
? T extends Array<infer U>
? Array<Serialized{{ identifier | capitalize }}Output<U>>
: { [K in keyof T as Exclude<K, FunctionKeys<T> | `_${string}`>]: Serialized{{ identifier | capitalize }}Output<T[K]> }
: T;
export type {{ identifier | capitalize }}ReturnType<
TPayload extends Omit<Request, "_request">,
K extends unknown = unknown,
> = Promise<
Awaited<Serialized{{ identifier | capitalize }}Output<Awaited<K extends keyof TPayload ? TPayload[K] : TPayload>>>
>;
export type AwaitNested<T extends object, K extends keyof T> = Omit<T, K> & {
[key in K]: Awaited<T[K]>;
};
@@ -0,0 +1,77 @@
import { CommentEvent, IssueEvent, WebhookPayload } from "./schemas";
import { Get{{ identifier | capitalize }}Payload } from "./types";
export type QueryVariables = {
after: string;
before: string;
first: number;
includeArchived: boolean;
last: number;
};
export type Nullable<T> = Partial<{
[K in keyof T]: T[K] | null;
}>;
export const onCommentProperties = (payload: Get{{ identifier | capitalize }}Payload<CommentEvent>) => {
return [
{ label: "Comment ID", text: payload.data.id },
{ label: "Issue ID", text: payload.data.issueId },
{ label: "Issue Title", text: payload.data.issue.title, url: payload.url ?? undefined },
];
};
export const onIssueProperties = (payload: Get{{ identifier | capitalize }}Payload<IssueEvent>) => {
return [
{ label: "Issue ID", text: payload.data.id },
{
label: "Issue",
text: `[${payload.data.team.key}-${payload.data.number}] ${payload.data.title}`,
url: payload.url ?? undefined,
},
];
};
export const queryProperties = (query: Nullable<QueryVariables>) => {
return [
...(query.after ? [{ label: "After", text: query.after }] : []),
...(query.before ? [{ label: "Before", text: query.before }] : []),
...(query.first ? [{ label: "First", text: String(query.first) }] : []),
...(query.last ? [{ label: "Last", text: String(query.last) }] : []),
...(query.includeArchived
? [{ label: "Include archived", text: String(query.includeArchived) }]
: []),
];
};
export const updatedFromProperties = (payload: WebhookPayload) => {
if (payload.action !== "update") return [];
return [
{
label: "Updated Keys",
text: Object.keys(payload.updatedFrom)
.filter((key) => !["editedAt", "updatedAt"].includes(key))
.join(", "),
},
];
};
export const modelProperties = (
params: Partial<{
model_owner: string;
model_name: string;
version_id: string;
destination: string;
}>
) => {
return [
...(params.model_owner ? [{ label: "Model Owner", text: params.model_owner }] : []),
...(params.model_name ? [{ label: "Model Name", text: params.model_name }] : []),
...(params.version_id ? [{ label: "Model Version", text: params.version_id }] : []),
...(params.destination ? [{ label: "Destination Model", text: params.destination }] : []),
];
};
export const streamingProperty = (params: { stream?: boolean }) => {
return [{ label: "Streaming Enabled", text: String(!!params.stream) }];
};
@@ -0,0 +1,297 @@
import {
EventFilter,
ExternalSource,
ExternalSourceTrigger,
HandlerEvent,
IntegrationTaskKey,
Logger,
} from "@trigger.dev/sdk";
import {
Document,
{{ identifier | capitalize }}Webhooks,
WebhookPayload,
DeletePayload,
Webhook,
} from "{{ sdkPackage }}";
import { z } from "zod";
import * as events from "./events";
import { {{ identifier | capitalize }}, {{ identifier | capitalize }}RunTask, serialize{{ identifier | capitalize }}Output } from "./index";
import { WebhookPayloadSchema } from "./schemas";
import { {{ identifier | capitalize }}ReturnType } from "./types";
import { queryProperties } from "./utils";
export class Webhooks {
runTask: {{ identifier | capitalize }}RunTask;
constructor(runTask: {{ identifier | capitalize }}RunTask) {
this.runTask = runTask;
}
webhook(key: IntegrationTaskKey, params: { id: string }): {{ identifier | capitalize }}ReturnType<Webhook> {
return this.runTask(
key,
async (client, task, io) => {
return serialize{{ identifier | capitalize }}Output(await client.webhook(params.id));
},
{
name: "Get Webhook",
params,
properties: [{ label: "Webhook ID", text: params.id }],
}
);
}
webhooks(key: IntegrationTaskKey, params?: Document.WebhooksQueryVariables): {{ identifier | capitalize }}ReturnType<Webhook[]> {
return this.runTask(
key,
async (client, task, io) => {
let connections = await client.webhooks(params);
const hooks = connections.nodes;
while (connections.pageInfo.hasNextPage) {
connections = await connections.fetchNext();
hooks.push(...connections.nodes);
}
return serialize{{ identifier | capitalize }}Output(hooks);
},
{
name: "List Webhooks",
params,
properties: queryProperties(params ?? {}),
}
);
}
createWebhook(
key: IntegrationTaskKey,
params: Document.WebhookCreateInput
): {{ identifier | capitalize }}ReturnType<Omit<WebhookPayload, "webhook"> & { webhook: Webhook | undefined }> {
return this.runTask(
key,
async (client, task, io) => {
const payload = await client.createWebhook({ ...params, allPublicTeams: !params.teamId });
return serialize{{ identifier | capitalize }}Output({
...payload,
webhook: await payload.webhook,
});
},
{
name: "Create Webhook",
params,
properties: [
{ label: "Webhook URL", text: params.url },
{ label: "Resource Types", text: params.resourceTypes.join(", ") },
],
}
);
}
deleteWebhook(key: IntegrationTaskKey, params: { id: string }): {{ identifier | capitalize }}ReturnType<DeletePayload> {
return this.runTask(
key,
async (client, task, io) => {
return serialize{{ identifier | capitalize }}Output(await client.deleteWebhook(params.id));
},
{
name: "Delete Webhook",
params,
properties: [{ label: "Webhook ID", text: params.id }],
}
);
}
updateWebhook(
key: IntegrationTaskKey,
params: { id: string; input: Document.WebhookUpdateInput }
): {{ identifier | capitalize }}ReturnType<Omit<WebhookPayload, "webhook"> & { webhook: Webhook | undefined }> {
return this.runTask(
key,
async (client, task) => {
const payload = await client.updateWebhook(params.id, params.input);
return serialize{{ identifier | capitalize }}Output({
...payload,
webhook: await payload.webhook,
});
},
{
name: "Update Webhook",
params,
properties: [
{ label: "Webhook ID", text: params.id },
...(params.input.url ? [{ label: "Webhook URL", text: params.input.url }] : []),
...(params.input.resourceTypes
? [{ label: "Resource Types", text: params.input.resourceTypes.join(", ") }]
: []),
],
}
);
}
}
type {{ identifier | capitalize }}Events = (typeof events)[keyof typeof events];
export type TriggerParams = {
teamId?: string;
filter?: EventFilter;
};
type CreateTriggersResult<TEventSpecification extends {{ identifier | capitalize }}Events> = ExternalSourceTrigger<
TEventSpecification,
ReturnType<typeof createWebhookEventSource>
>;
export function createTrigger<TEventSpecification extends {{ identifier | capitalize }}Events>(
source: ReturnType<typeof createWebhookEventSource>,
event: TEventSpecification,
params: TriggerParams
): CreateTriggersResult<TEventSpecification> {
return new ExternalSourceTrigger({
event,
params,
source,
options: {},
});
}
const WebhookRegistrationDataSchema = z.object({
success: z.literal(true),
webhook: z.object({
id: z.string(),
enabled: z.boolean(),
}),
});
export function createWebhookEventSource(
integration: {{ identifier | capitalize }}
): ExternalSource<{{ identifier | capitalize }}, TriggerParams, "HTTP", {}> {
return new ExternalSource("HTTP", {
id: "{{ identifier }}.webhook",
schema: z.object({
teamId: z.string().optional(),
}),
version: "0.1.0",
integration,
key: (params) => `${params.teamId ? params.teamId : "all"}`,
handler: webhookHandler,
register: async (event, io, ctx) => {
const { params, source: httpSource, options } = event;
// (key-specific) stored data, undefined if not registered yet
const webhookData = WebhookRegistrationDataSchema.safeParse(httpSource.data);
// set of events to register
const allEvents = Array.from(new Set([...options.event.desired, ...options.event.missing]));
const registeredOptions = {
event: allEvents,
};
// easily identify webhooks on {{ identifier }}
const label = `trigger.${params.teamId ? params.teamId : "all"}`;
if (httpSource.active && webhookData.success) {
const hasMissingOptions = Object.values(options).some(
(option) => option.missing.length > 0
);
if (!hasMissingOptions) return;
const updatedWebhook = await io.integration.updateWebhook("update-webhook", {
id: webhookData.data.webhook.id,
input: {
label,
resourceTypes: allEvents,
secret: httpSource.secret,
url: httpSource.url,
},
});
return {
data: WebhookRegistrationDataSchema.parse(updatedWebhook),
options: registeredOptions,
};
}
// check for existing hooks that match url
const listResponse = await io.integration.webhooks("list-webhooks");
const existingWebhook = listResponse.find((w) => w.url === httpSource.url);
if (existingWebhook) {
const updatedWebhook = await io.integration.updateWebhook("update-webhook", {
id: existingWebhook.id,
input: {
label,
resourceTypes: allEvents,
secret: httpSource.secret,
url: httpSource.url,
},
});
return {
data: WebhookRegistrationDataSchema.parse(updatedWebhook),
options: registeredOptions,
};
}
const createPayload = await io.integration.createWebhook("create-webhook", {
label,
resourceTypes: allEvents,
secret: httpSource.secret,
teamId: params.teamId,
url: httpSource.url,
});
return {
data: WebhookRegistrationDataSchema.parse(createPayload),
secret: (await createPayload.webhook)?.secret,
options: registeredOptions,
};
},
});
}
async function webhookHandler(event: HandlerEvent<"HTTP">, logger: Logger, integration: {{ identifier | capitalize }}) {
logger.debug("[@trigger.dev/{{ identifier }}] Handling webhook payload");
const { rawEvent: request, source } = event;
const payloadUuid = request.headers.get("{{ identifier | capitalize }}-Delivery");
const payloadEvent = request.headers.get("{{ identifier | capitalize }}-Event");
if (!payloadUuid || !payloadEvent) {
logger.debug("[@trigger.dev/{{ identifier }}] Missing required {{ identifier | capitalize }} headers");
return { events: [] };
}
if (!request.body) {
logger.debug("[@trigger.dev/{{ identifier }}] No body found");
return { events: [] };
}
const signature = request.headers.get("WEBHOOK_SIGNATURE_HEADER");
if (!signature) {
logger.error("[@trigger.dev/{{ identifier }}] Error validating webhook signature, no signature found");
throw Error("[@trigger.dev/{{ identifier }}] No signature found");
}
const rawBody = await request.text();
const body = JSON.parse(rawBody);
const webhookHelper = new {{ identifier | capitalize }}Webhooks(source.secret);
if (!webhookHelper.verify(Buffer.from(rawBody), signature)) {
logger.error("[@trigger.dev/{{ identifier }}] Error validating webhook signature, they don't match");
throw Error("[@trigger.dev/{{ identifier }}] Invalid signature");
}
const webhookPayload = WebhookPayloadSchema.parse(body);
return {
events: [
{
id: payloadUuid,
name: payloadEvent,
source: "{{ identifier }}.app",
payload: webhookPayload,
context: {},
},
],
};
}
@@ -0,0 +1,61 @@
import fs from "fs/promises";
import { Liquid } from "liquidjs";
import path from "path";
import { pathExists } from "./fileSystem";
import { templatesPath } from "../paths";
type Result =
| {
success: true;
alreadyExisted: boolean;
}
| {
success: false;
error: string;
};
const templatesDir = path.join(templatesPath(), "integration");
const liquid = new Liquid({
root: templatesDir,
trimTagRight: true,
trimOutputRight: true,
});
export async function createIntegrationFileFromTemplate(params: {
relativeTemplatePath: string;
variables?: Record<string, any>;
outputPath: string;
}): Promise<Result> {
if (await pathExists(params.outputPath)) {
return {
success: true,
alreadyExisted: true,
};
}
try {
const output = await liquid.renderFile(params.relativeTemplatePath, params.variables);
const directoryName = path.dirname(params.outputPath);
await fs.mkdir(directoryName, { recursive: true });
await fs.writeFile(params.outputPath, output);
return {
success: true,
alreadyExisted: false,
};
} catch (e) {
if (e instanceof Error) {
return {
success: false,
error: e.message,
};
}
return {
success: false,
error: JSON.stringify(e),
};
}
}
@@ -4,3 +4,8 @@ import pathModule from "path";
export const resolvePath = (input: string) => {
return pathModule.resolve(process.cwd(), input);
};
// Takes an absolute path and derives the relative path from the current working directory
export const relativePath = (input: string) => {
return pathModule.relative(process.cwd(), input);
};