diff --git a/.changeset/gorgeous-panthers-run.md b/.changeset/gorgeous-panthers-run.md new file mode 100644 index 000000000..63a677380 --- /dev/null +++ b/.changeset/gorgeous-panthers-run.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/cli": patch +--- + +Improve create-integration output. Use templates and shared configs. diff --git a/config-packages/tsconfig/integration.json b/config-packages/tsconfig/integration.json index 753e6091d..ff9d795e5 100644 --- a/config-packages/tsconfig/integration.json +++ b/config-packages/tsconfig/integration.json @@ -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/*"], diff --git a/config-packages/tsup/package.json b/config-packages/tsup/package.json new file mode 100644 index 000000000..8af32c7ae --- /dev/null +++ b/config-packages/tsup/package.json @@ -0,0 +1,9 @@ +{ + "name": "@trigger.dev/tsup", + "version": "0.0.0", + "private": true, + "license": "MIT", + "devDependencies": { + "tsup": "7.1.x" + } +} diff --git a/config-packages/tsup/src/index.ts b/config-packages/tsup/src/index.ts new file mode 100644 index 000000000..661e53cca --- /dev/null +++ b/config-packages/tsup/src/index.ts @@ -0,0 +1,3 @@ +export { defineConfig } from "tsup"; +export { deepMergeOptions } from "./utils"; +export { options as integrationOptions } from "./integration"; diff --git a/config-packages/tsup/src/integration.ts b/config-packages/tsup/src/integration.ts new file mode 100644 index 000000000..0fb2a3d7d --- /dev/null +++ b/config-packages/tsup/src/integration.ts @@ -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); diff --git a/config-packages/tsup/src/utils.ts b/config-packages/tsup/src/utils.ts new file mode 100644 index 000000000..6e46ff742 --- /dev/null +++ b/config-packages/tsup/src/utils.ts @@ -0,0 +1,32 @@ +import { Options } from "tsup"; + +export const deepMergeOptions = deepMergeRecords; + +function deepMergeRecords>(...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; +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 23436472d..b87ac8dbe 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts index 89829eb4f..cc3bc8713 100644 --- a/packages/cli/src/cli/index.ts +++ b/packages/cli/src/cli/index.ts @@ -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") diff --git a/packages/cli/src/commands/createIntegration.ts b/packages/cli/src/commands/createIntegration.ts index 312daab0f..ebe1f0c27 100644 --- a/packages/cli/src/commands/createIntegration.ts +++ b/packages/cli/src/commands/createIntegration.ts @@ -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 +) => { + for (const file of files) { + const result = await createIntegrationFileFromTemplate({ ...file, variables }); + handleCreateResult(file.outputPath, result); + } +}; + +const handleCreateResult = ( + outputPath: string, + result: Awaited> +) => { + if (!result.success) { + throw new Error(`Failed to create ${pathModule.basename(outputPath)}: ${result.error}`); + } + logger.success(`✔ Created ${pathModule.basename(outputPath)} at ${relativePath(outputPath)}`); +}; diff --git a/packages/cli/src/templates/integration/README.md.j2 b/packages/cli/src/templates/integration/README.md.j2 new file mode 100644 index 000000000..f4e2541a2 --- /dev/null +++ b/packages/cli/src/templates/integration/README.md.j2 @@ -0,0 +1 @@ +# {{ packageName }} diff --git a/packages/cli/src/templates/integration/events.js.j2 b/packages/cli/src/templates/integration/events.js.j2 new file mode 100644 index 000000000..77d100907 --- /dev/null +++ b/packages/cli/src/templates/integration/events.js.j2 @@ -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> = { + name: "Comment", + title: "On Comment", + source: "linear.app", + icon: "linear", + examples: [commentCreated, commentRemoved, commentUpdated], + parsePayload: (payload) => payload as Get{{ identifier | capitalize }}Payload, + runProperties: (payload) => [ + { label: "Event action", text: payload.action }, + ...onCommentProperties(payload), + ...updatedFromProperties(payload), + ], +}; + +export const onCommentCreated: EventSpecification> = { + name: "Comment", + title: "On Comment Created", + source: "linear.app", + icon: "linear", + filter: { + action: ["create"], + }, + examples: [commentCreated], + parsePayload: (payload) => payload as Get{{ identifier | capitalize }}Payload, + runProperties: (payload) => onCommentProperties(payload), +}; + +export const onCommentRemoved: EventSpecification> = { + name: "Comment", + title: "On Comment Removed", + source: "linear.app", + icon: "linear", + filter: { + action: ["remove"], + }, + examples: [commentRemoved], + parsePayload: (payload) => payload as Get{{ identifier | capitalize }}Payload, + runProperties: (payload) => onCommentProperties(payload), +}; + +export const onCommentUpdated: EventSpecification> = { + name: "Comment", + title: "On Comment Updated", + source: "linear.app", + icon: "linear", + filter: { + action: ["update"], + }, + examples: [commentUpdated], + parsePayload: (payload) => payload as Get{{ identifier | capitalize }}Payload, + runProperties: (payload) => [...onCommentProperties(payload), ...updatedFromProperties(payload)], +}; + +export const onIssue: EventSpecification> = { + name: "Issue", + title: "On Issue", + source: "linear.app", + icon: "linear", + examples: [issueCreated, issueRemoved, issueUpdated], + parsePayload: (payload) => payload as Get{{ identifier | capitalize }}Payload, + runProperties: (payload) => [ + { label: "Event action", text: payload.action }, + ...onIssueProperties(payload), + ...updatedFromProperties(payload), + ], +}; + +export const onIssueCreated: EventSpecification> = { + name: "Issue", + title: "On Issue Created", + source: "linear.app", + icon: "linear", + filter: { + action: ["create"], + }, + examples: [issueCreated], + parsePayload: (payload) => payload as Get{{ identifier | capitalize }}Payload, + runProperties: (payload) => onIssueProperties(payload), +}; + +export const onIssueRemoved: EventSpecification> = { + name: "Issue", + title: "On Issue Removed", + source: "linear.app", + icon: "linear", + filter: { + action: ["remove"], + }, + examples: [issueRemoved], + parsePayload: (payload) => payload as Get{{ identifier | capitalize }}Payload, + runProperties: (payload) => onIssueProperties(payload), +}; + +export const onIssueUpdated: EventSpecification> = { + name: "Issue", + title: "On Issue Updated", + source: "linear.app", + icon: "linear", + filter: { + action: ["update"], + }, + examples: [issueUpdated], + parsePayload: (payload) => payload as Get{{ identifier | capitalize }}Payload, + runProperties: (payload) => [...onIssueProperties(payload), ...updatedFromProperties(payload)], +}; diff --git a/packages/cli/src/templates/integration/index.js.j2 b/packages/cli/src/templates/integration/index.js.j2 new file mode 100644 index 000000000..289de3ee1 --- /dev/null +++ b/packages/cli/src/templates/integration/index.js.j2 @@ -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["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 | void>( + key: IntegrationTaskKey, + callback: (client: {{ identifier | capitalize }}Client, task: IOTask, io: IO) => Promise, + options?: RunTaskOptions, + errorCallback?: RunTaskErrorCallback + ): Promise { + if (!this._io) throw new Error("No IO"); + if (!this._connectionKey) throw new Error("No connection key"); + + return this._io.runTask( + 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( + key: IntegrationTaskKey, + params: { + route: string | URL; + options: Parameters<{{ identifier | capitalize }}Client["request"]>[1]; + } + ): {{ identifier | capitalize }}ReturnType { + 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 { + 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 = (obj: T): Prettify> => { + return JSON.parse(JSON.stringify(obj), (key, value) => { + if (typeof value === "function" || key.startsWith("_")) { + return undefined; + } + return value; + }); +}; diff --git a/packages/cli/src/templates/integration/models.js.j2 b/packages/cli/src/templates/integration/models.js.j2 new file mode 100644 index 000000000..0d67c7952 --- /dev/null +++ b/packages/cli/src/templates/integration/models.js.j2 @@ -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 { + 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 { + 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 { + return this.runTask( + key, + (client) => { + return client.models.versions.list(params.model_owner, params.model_name); + }, + { + name: "List Models", + params, + properties: modelProperties(params), + } + ); + } +} diff --git a/packages/cli/src/templates/integration/package.json.j2 b/packages/cli/src/templates/integration/package.json.j2 new file mode 100644 index 000000000..6e1391c90 --- /dev/null +++ b/packages/cli/src/templates/integration/package.json.j2 @@ -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" + } +} diff --git a/packages/cli/src/templates/integration/payload-examples/index.js.j2 b/packages/cli/src/templates/integration/payload-examples/index.js.j2 new file mode 100644 index 000000000..a35e18d3e --- /dev/null +++ b/packages/cli/src/templates/integration/payload-examples/index.js.j2 @@ -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, +}; diff --git a/packages/cli/src/templates/integration/schemas.js.j2 b/packages/cli/src/templates/integration/schemas.js.j2 new file mode 100644 index 000000000..9c51092e8 --- /dev/null +++ b/packages/cli/src/templates/integration/schemas.js.j2 @@ -0,0 +1,120 @@ +import { z } from "zod"; + +export const WebhookResourceTypeSchema = z.union([ + z.literal("Comment"), + z.literal("Issue"), +]); +export type WebhookResourceType = z.infer; + +export const WebhookActionTypeSchema = z.union([ + z.literal("create"), + z.literal("remove"), + z.literal("update"), +]); +export type WebhookActionType = z.infer; + +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; + +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; + +export const WebhookPayloadSchema = z.union([ + CommentEventSchema, + IssueEventSchema, +]); + +export type WebhookPayload = z.infer; diff --git a/packages/cli/src/templates/integration/tsconfig-external.json.j2 b/packages/cli/src/templates/integration/tsconfig-external.json.j2 new file mode 100644 index 000000000..2a94d35fa --- /dev/null +++ b/packages/cli/src/templates/integration/tsconfig-external.json.j2 @@ -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"] +} diff --git a/packages/cli/src/templates/integration/tsconfig-internal.json.j2 b/packages/cli/src/templates/integration/tsconfig-internal.json.j2 new file mode 100644 index 000000000..26ae70a15 --- /dev/null +++ b/packages/cli/src/templates/integration/tsconfig-internal.json.j2 @@ -0,0 +1,4 @@ +{ + "extends": "@trigger.dev/tsconfig/integration.json", + "include": ["./src/**/*.ts", "tsup.config.ts"] +} diff --git a/packages/cli/src/templates/integration/tsup.config-external.js.j2 b/packages/cli/src/templates/integration/tsup.config-external.js.j2 new file mode 100644 index 000000000..d1145e2b9 --- /dev/null +++ b/packages/cli/src/templates/integration/tsup.config-external.js.j2 @@ -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"], +}); diff --git a/packages/cli/src/templates/integration/tsup.config-internal.js.j2 b/packages/cli/src/templates/integration/tsup.config-internal.js.j2 new file mode 100644 index 000000000..3071b229a --- /dev/null +++ b/packages/cli/src/templates/integration/tsup.config-internal.js.j2 @@ -0,0 +1,7 @@ +import { defineConfig, deepMergeOptions, integrationOptions } from "@trigger.dev/tsup"; + +const options = deepMergeOptions(integrationOptions, { + // extend base config here +}); + +export default defineConfig(options); diff --git a/packages/cli/src/templates/integration/types.js.j2 b/packages/cli/src/templates/integration/types.js.j2 new file mode 100644 index 000000000..1a7ff133a --- /dev/null +++ b/packages/cli/src/templates/integration/types.js.j2 @@ -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; + +type FunctionKeys = { + [K in keyof T]: T[K] extends Function ? K : never; +}[keyof T]; + +export type Serialized{{ identifier | capitalize }}Output = T extends object + ? T extends Array + ? Array> + : { [K in keyof T as Exclude | `_${string}`>]: Serialized{{ identifier | capitalize }}Output } + : T; + +export type {{ identifier | capitalize }}ReturnType< + TPayload extends Omit, + K extends unknown = unknown, +> = Promise< + Awaited>> +>; + +export type AwaitNested = Omit & { + [key in K]: Awaited; +}; diff --git a/packages/cli/src/templates/integration/utils.js.j2 b/packages/cli/src/templates/integration/utils.js.j2 new file mode 100644 index 000000000..f3fe00dc9 --- /dev/null +++ b/packages/cli/src/templates/integration/utils.js.j2 @@ -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 = Partial<{ + [K in keyof T]: T[K] | null; +}>; + +export const onCommentProperties = (payload: Get{{ identifier | capitalize }}Payload) => { + 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) => { + 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) => { + 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) }]; +}; diff --git a/packages/cli/src/templates/integration/webhooks.js.j2 b/packages/cli/src/templates/integration/webhooks.js.j2 new file mode 100644 index 000000000..9bb55e514 --- /dev/null +++ b/packages/cli/src/templates/integration/webhooks.js.j2 @@ -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 { + 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 { + 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 & { 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 { + 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 & { 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 = ExternalSourceTrigger< + TEventSpecification, + ReturnType +>; + +export function createTrigger( + source: ReturnType, + event: TEventSpecification, + params: TriggerParams +): CreateTriggersResult { + 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: {}, + }, + ], + }; +} diff --git a/packages/cli/src/utils/createIntegrationFileFromTemplate.ts b/packages/cli/src/utils/createIntegrationFileFromTemplate.ts new file mode 100644 index 000000000..8ac261a53 --- /dev/null +++ b/packages/cli/src/utils/createIntegrationFileFromTemplate.ts @@ -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; + outputPath: string; +}): Promise { + 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), + }; + } +} diff --git a/packages/cli/src/utils/parseNameAndPath.ts b/packages/cli/src/utils/parseNameAndPath.ts index 400a534cf..e6016c0d9 100644 --- a/packages/cli/src/utils/parseNameAndPath.ts +++ b/packages/cli/src/utils/parseNameAndPath.ts @@ -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); +};