diff --git a/.gitignore b/.gitignore index 0701fd2e..24b026ad 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ dist node_modules **/node_modules +# Docker +docker-compose-data + # Environment Variables .env .env.* @@ -19,6 +22,7 @@ node_modules # Generated Code **/generated **/prisma/schema.prisma +TypeAPI.d.ts # System Files .DS_Store diff --git a/bun.lockb b/bun.lockb index 13818963..86febfb4 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 00000000..e9789cdf --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,19 @@ +version: '3.7' +name: antribute-open-source +services: + postgres: + image: postgres:latest + restart: always + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=password + ports: + - '5432:5432' + volumes: + - ./docker-compose-data/postgres:/var/lib/postgresql/data + - ./seed.sql:/docker-entrypoint-initdb.d/init.sql + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U postgres'] + interval: 5s + timeout: 5s + retries: 5 diff --git a/packages/config-loader/LICENSE b/packages/config-loader/LICENSE new file mode 100644 index 00000000..1e65fa0b --- /dev/null +++ b/packages/config-loader/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Antribute, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/config-loader/README.md b/packages/config-loader/README.md new file mode 100644 index 00000000..bf489501 --- /dev/null +++ b/packages/config-loader/README.md @@ -0,0 +1,19 @@ +# Config Loader + +Loads TypeScript, JavaScript, and JSON configs in a Bun and Node friendly manner + +## Installation + +```bash +bun add @antribute/config-loader +``` + +## Usage + +1. Create a new file called `.antributerc.ts` +1. Add the following + ```typescript + import { defineConfig } from '@antribute/backend-core'; + export default defineConfig({ auth: { platform: '@antribute/backend-clerk' } }); + ``` +1. Run the Antribute CLI to auto-generate required files diff --git a/packages/config-loader/build.config.ts b/packages/config-loader/build.config.ts new file mode 100644 index 00000000..23417a1e --- /dev/null +++ b/packages/config-loader/build.config.ts @@ -0,0 +1,2 @@ +import unbuildConfig from '@antribute/config/unbuild/unbuild.config.base'; +export default unbuildConfig; diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json new file mode 100644 index 00000000..eee1ca00 --- /dev/null +++ b/packages/config-loader/package.json @@ -0,0 +1,47 @@ +{ + "name": "@antribute/config-loader", + "description": "Loads TypeScript, JavaScript, and JSON configs in a Bun and Node friendly manner", + "version": "0.1.0", + "type": "module", + "publishConfig": { + "access": "public" + }, + "exports": { + "bun": "./src/index.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "files": [ + "dist/**", + "src/**" + ], + "types": "./dist/index.d.ts", + "scripts": { + "build": "bunx --bun unbuild", + "clean": "rimraf .turbo && rimraf coverage && rimraf dist && rimraf node_modules && rimraf test-results", + "lint": "eslint --cache ./src", + "test": "bun test" + }, + "keywords": [ + "antribute", + "antribute-backend", + "antribute-backend-auth", + "clerk" + ], + "author": "Antribute, Inc. (https://www.antribute.com)", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.31.17", + "bundle-require": "^4.0.1", + "esbuild": "^0.17.18", + "joycon": "^3.1.1", + "lodash-es": "^4.17.21", + "type-fest": "^3.5.1" + }, + "devDependencies": { + "@antribute/config": "workspace:*", + "typescript": "^5.0.4", + "unbuild": "^2.0.0" + } +} diff --git a/packages/config-loader/src/bun.ts b/packages/config-loader/src/bun.ts new file mode 100644 index 00000000..4eb5b4c7 --- /dev/null +++ b/packages/config-loader/src/bun.ts @@ -0,0 +1,21 @@ +import { getConfigFilePath } from './common'; +import type { LoadConfigParams } from './types'; + +export const loadConfigBun = async >( + fileNames: string[], + opts?: LoadConfigParams +): Promise => { + const path = await getConfigFilePath(fileNames, opts); + if (!path) { + return null; + } + + const rawConfig = await import(path); + const configContent = rawConfig.default ?? rawConfig; + + if (typeof configContent !== 'object') { + return null; + } + + return configContent as ConfigShape; +}; diff --git a/packages/config-loader/src/common.ts b/packages/config-loader/src/common.ts new file mode 100644 index 00000000..e47da2bb --- /dev/null +++ b/packages/config-loader/src/common.ts @@ -0,0 +1,24 @@ +import { parse } from 'path'; + +import Joycon from 'joycon'; +import { merge } from 'lodash-es'; + +import type { LoadConfigParams } from './types'; + +export const getConfigFilePath = async (fileNames: string[], opts?: LoadConfigParams) => { + const cwd = opts?.cwd ?? process.cwd(); + const overridePath = opts?.overrideConfigPath; + const joycon = new Joycon(); + const configFile = await joycon.resolve({ + files: overridePath ? [overridePath] : fileNames, + cwd, + stopDir: parse(cwd).root, + }); + + return configFile; +}; + +export const mergeConfig = >( + configA: Partial, + configB: Partial +): Partial => merge(configA, configB); diff --git a/packages/config-loader/src/index.ts b/packages/config-loader/src/index.ts new file mode 100644 index 00000000..866cfc99 --- /dev/null +++ b/packages/config-loader/src/index.ts @@ -0,0 +1,70 @@ +import type { TObject } from '@sinclair/typebox'; +import { Value } from '@sinclair/typebox/value'; +import type { PartialDeep } from 'type-fest'; + +import { mergeConfig } from './common'; +import { loadConfigBun } from './bun'; +import { loadConfigNode } from './node'; +import type { LoadConfigParams } from './types'; + +export const defineConfig = >( + config: PartialDeep +) => config; + +export const loadConfig = async >( + fileNames: string[], + defaultConfig: ConfigShape, + opts?: LoadConfigParams +): Promise => { + let loadedConfig: ConfigShape | null; + if (typeof Bun === 'undefined') { + loadedConfig = await loadConfigNode(fileNames, opts); + } else { + loadedConfig = await loadConfigBun(fileNames, opts); + } + if (!loadedConfig) { + return defaultConfig; + } + return mergeConfig(defaultConfig, loadedConfig) as ConfigShape; +}; + +export const validateConfig = >( + configSchema: TObject, + config: unknown, + { castConfig } = { castConfig: true } +) => { + let finalConfig = config; + + if (castConfig) { + finalConfig = Value.Cast(configSchema, config); + } + const isConfigValid = Value.Check(configSchema, finalConfig); + + if (!isConfigValid) { + throw new Error('Invalid Configuration'); + } + + return finalConfig as ConfigShape; +}; + +export interface LoadAndValidateConfigOptions extends LoadConfigParams { + fileNames: string[]; + validationSchema: TObject; +} + +export const loadAndValidateConfig = async >({ + cwd, + fileNames, + overrideConfigPath, + validationSchema, +}: LoadAndValidateConfigOptions): Promise => { + const config = await loadConfig( + fileNames, + Value.Create(validationSchema) as ConfigShape, + { + cwd, + overrideConfigPath, + } + ); + return validateConfig(validationSchema, config); +}; diff --git a/packages/config-loader/src/node.ts b/packages/config-loader/src/node.ts new file mode 100644 index 00000000..8fdcba36 --- /dev/null +++ b/packages/config-loader/src/node.ts @@ -0,0 +1,22 @@ +import { bundleRequire } from 'bundle-require'; +import { getConfigFilePath } from './common'; +import type { LoadConfigParams } from './types'; + +export const loadConfigNode = async >( + fileNames: string[], + opts?: LoadConfigParams +): Promise => { + const path = await getConfigFilePath(fileNames, opts); + if (!path) { + return null; + } + + const rawConfig = await bundleRequire({ filepath: path }); + const configContent = rawConfig.mod.default ?? rawConfig.mod; + + if (typeof configContent !== 'object') { + return null; + } + + return configContent as ConfigShape; +}; diff --git a/packages/config-loader/src/types.ts b/packages/config-loader/src/types.ts new file mode 100644 index 00000000..8de2e725 --- /dev/null +++ b/packages/config-loader/src/types.ts @@ -0,0 +1,4 @@ +export interface LoadConfigParams { + cwd?: string; + overrideConfigPath?: string; +} diff --git a/packages/config-loader/tsconfig.json b/packages/config-loader/tsconfig.json new file mode 100644 index 00000000..e331bc38 --- /dev/null +++ b/packages/config-loader/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@antribute/config/tsconfig/tsconfig.base.json", + "include": ["src/**/*.ts"], + "exclude": ["node_modules"], + "compilerOptions": { + "baseUrl": "./src", + "outDir": "./dist" + } +} diff --git a/packages/typeapi/.typeapirc.ts b/packages/typeapi/.typeapirc.ts new file mode 100644 index 00000000..bcd2d841 --- /dev/null +++ b/packages/typeapi/.typeapirc.ts @@ -0,0 +1,9 @@ +import { defineConfig } from './src'; + +const config = defineConfig({ + database: { + connectionString: 'postgresql://postgres:password@localhost:5432/typeapi', + }, + server: { rootDir: 'example' }, +}); +export default config; diff --git a/packages/typeapi/LICENSE b/packages/typeapi/LICENSE new file mode 100644 index 00000000..1e65fa0b --- /dev/null +++ b/packages/typeapi/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Antribute, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/typeapi/README.md b/packages/typeapi/README.md new file mode 100644 index 00000000..1c40cea4 --- /dev/null +++ b/packages/typeapi/README.md @@ -0,0 +1,26 @@ +# TypeAPI + +Like your Favorite Backend Framework but Fully Typed + +## Installation + +```bash +pnpm i @antribute/typeapi +``` + +## Getting Started + +Run `pnpm exec typeapi create` + +When running this command, you'll see the following prompts + +``` +What is your project named? my-app +Would you like to use TypeScript? No / Yes +Would you like to use ESLint? No / Yes +Would you like to use Tailwind CSS? No / Yes +Would you like to use `src/` directory? No / Yes +Would you like to use App Router? (recommended) No / Yes +Would you like to customize the default import alias (@/*)? No / Yes +What import alias would you like configured? @/* +``` diff --git a/packages/typeapi/build.config.ts b/packages/typeapi/build.config.ts new file mode 100644 index 00000000..113ea9d5 --- /dev/null +++ b/packages/typeapi/build.config.ts @@ -0,0 +1,2 @@ +import unbuildConfig from '@antribute/config/unbuild/unbuild.config.react'; +export default unbuildConfig; diff --git a/packages/typeapi/example/migrations/0000_flawless_mach_iv.sql b/packages/typeapi/example/migrations/0000_flawless_mach_iv.sql new file mode 100644 index 00000000..51058555 --- /dev/null +++ b/packages/typeapi/example/migrations/0000_flawless_mach_iv.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS "tasks" ( + "id" serial PRIMARY KEY NOT NULL, + "isCompleted" boolean DEFAULT false, + "name" text NOT NULL +); diff --git a/packages/typeapi/example/migrations/meta/0000_snapshot.json b/packages/typeapi/example/migrations/meta/0000_snapshot.json new file mode 100644 index 00000000..117c59f7 --- /dev/null +++ b/packages/typeapi/example/migrations/meta/0000_snapshot.json @@ -0,0 +1,44 @@ +{ + "version": "5", + "dialect": "pg", + "id": "f3a052f9-bed1-44e9-9fc9-25f569de7fbc", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "isCompleted": { + "name": "isCompleted", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} diff --git a/packages/typeapi/example/migrations/meta/_journal.json b/packages/typeapi/example/migrations/meta/_journal.json new file mode 100644 index 00000000..ee63f670 --- /dev/null +++ b/packages/typeapi/example/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "5", + "dialect": "pg", + "entries": [ + { + "idx": 0, + "version": "5", + "when": 1698648927805, + "tag": "0000_flawless_mach_iv", + "breakpoints": true + } + ] +} diff --git a/packages/typeapi/example/todoList.config.ts b/packages/typeapi/example/todoList.config.ts new file mode 100644 index 00000000..1416f703 --- /dev/null +++ b/packages/typeapi/example/todoList.config.ts @@ -0,0 +1,5 @@ +import { defineModelConfig } from '../src'; + +export const tasksConfig = defineModelConfig('tasks', { + rest: true, +}); diff --git a/packages/typeapi/example/todoList.models.ts b/packages/typeapi/example/todoList.models.ts new file mode 100644 index 00000000..5d5bb259 --- /dev/null +++ b/packages/typeapi/example/todoList.models.ts @@ -0,0 +1,7 @@ +import { boolean, pgTable, serial, text } from 'drizzle-orm/pg-core'; + +export const tasks = pgTable('tasks', { + id: serial('id').primaryKey(), + isCompleted: boolean('isCompleted').default(false), + name: text('name').notNull(), +}); diff --git a/packages/typeapi/package.json b/packages/typeapi/package.json new file mode 100644 index 00000000..b2530c52 --- /dev/null +++ b/packages/typeapi/package.json @@ -0,0 +1,65 @@ +{ + "name": "@antribute/typeapi", + "description": "Like your Favorite Backend Framework but Fully Typed", + "version": "0.1.0", + "type": "module", + "bin": { + "typeapi": "./dist/cli/index.js" + }, + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "bun": "./src/index.ts", + "require": "./dist/index.js", + "import": "./dist/index.mjs", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist/**" + ], + "types": "./dist/index.d.ts", + "scripts": { + "build": "bunx --bun unbuild", + "clean": "rimraf .turbo && rimraf dist", + "cli": "bun run src/cli/index.ts", + "dev": "pnpm run build --watch" + }, + "keywords": [ + "antribute", + "typeapi" + ], + "author": "Antribute, Inc. (https://www.antribute.com)", + "license": "MIT", + "dependencies": { + "@antribute/config-loader": "workspace:*", + "@antribute/typecli": "workspace:*", + "@hono/node-server": "^1.2.0", + "@sinclair/typebox": "^0.31.17", + "bundle-require": "^4.0.1", + "dotenv": "^16.3.1", + "drizzle-kit": "^0.19.13", + "drizzle-orm": "^0.28.6", + "drizzle-pagination": "^1.0.10", + "drizzle-typebox": "^0.1.1", + "execa": "^7.1.1", + "fast-glob": "^3.2.12", + "fastify": "^4.23.2", + "graphql": "^16.6.0", + "graphql-yoga": "^3.1.1", + "hono": "^3.8.3", + "lodash-es": "^4.17.21", + "openapi-types": "^12.1.3", + "postgres": "^3.4.2", + "ts-morph": "^18.0.0", + "type-fest": "^3.5.1" + }, + "devDependencies": { + "@antribute/config": "workspace:0.1.0", + "@types/lodash-es": "^4.17.6", + "typescript": "^5.0.4", + "unbuild": "^2.0.0" + } +} diff --git a/packages/typeapi/src/cli/build.command.ts b/packages/typeapi/src/cli/build.command.ts new file mode 100644 index 00000000..50475ac0 --- /dev/null +++ b/packages/typeapi/src/cli/build.command.ts @@ -0,0 +1,16 @@ +import { createCommand } from '@antribute/typecli'; + +import { getConfig } from 'config'; +import { generateSchemaTypings } from 'db'; + +const buildCommand = createCommand({ + name: 'build', + description: 'Generates model typings for faster cold starts', + args: {}, + handler: async () => { + const config = await getConfig(); + await generateSchemaTypings(config); + }, +}); + +export default buildCommand; diff --git a/packages/typeapi/src/cli/index.ts b/packages/typeapi/src/cli/index.ts new file mode 100644 index 00000000..88be79f5 --- /dev/null +++ b/packages/typeapi/src/cli/index.ts @@ -0,0 +1,18 @@ +import { createProgram } from '@antribute/typecli'; + +import build from './build.command'; +import migrate from './migrate.command'; +import makemigrations from './makemigrations.command'; +import start from './start.command'; + +createProgram({ + name: 'typeapi', + description: 'TODO: Get description from package json', + version: 'TODO: get version from package.json', + cmds: { + build, + migrate, + makemigrations, + start, + }, +}); diff --git a/packages/typeapi/src/cli/makemigrations.command.ts b/packages/typeapi/src/cli/makemigrations.command.ts new file mode 100644 index 00000000..a4418629 --- /dev/null +++ b/packages/typeapi/src/cli/makemigrations.command.ts @@ -0,0 +1,18 @@ +import { createCommand, output } from '@antribute/typecli'; + +import { getConfig } from 'config'; +import { makeMigrations } from 'db'; + +const makeMigrationsCommand = createCommand({ + name: 'makemigrations', + description: 'Creates new database migrations based on changes made to .model.ts files', + args: {}, + handler: async () => { + output.info('Creating Migrations'); + const config = await getConfig(); + await makeMigrations(config); + output.success('Migrations Created Successfully'); + }, +}); + +export default makeMigrationsCommand; diff --git a/packages/typeapi/src/cli/migrate.command.ts b/packages/typeapi/src/cli/migrate.command.ts new file mode 100644 index 00000000..9b8ce3c7 --- /dev/null +++ b/packages/typeapi/src/cli/migrate.command.ts @@ -0,0 +1,18 @@ +import { createCommand, output } from '@antribute/typecli'; + +import { getConfig } from 'config'; +import { migrate } from 'db'; + +const makeMigrationsCommand = createCommand({ + name: 'migrate', + description: 'Applies migrations to database URL in config', + args: {}, + handler: async () => { + output.info('Applying Migrations'); + const config = await getConfig(); + await migrate(config); + output.success('Migrations Applied Successfully'); + }, +}); + +export default makeMigrationsCommand; diff --git a/packages/typeapi/src/cli/start.command.ts b/packages/typeapi/src/cli/start.command.ts new file mode 100644 index 00000000..4e7ecaac --- /dev/null +++ b/packages/typeapi/src/cli/start.command.ts @@ -0,0 +1,18 @@ +import { createCommand } from '@antribute/typecli'; + +import { getConfig } from 'config'; +import { buildRouter, createServer, startServer } from 'server'; + +const buildCommand = createCommand({ + name: 'start', + description: 'Starts your TypeAPI Server', + args: {}, + handler: async () => { + const config = await getConfig(); + const server = createServer(); + await buildRouter(config, server); + await startServer(config, server); + }, +}); + +export default buildCommand; diff --git a/packages/typeapi/src/config.ts b/packages/typeapi/src/config.ts new file mode 100644 index 00000000..84655eb4 --- /dev/null +++ b/packages/typeapi/src/config.ts @@ -0,0 +1,60 @@ +import { join } from 'path'; +import { fileURLToPath } from 'url'; + +import { + defineConfig as createDefineConfig, + loadAndValidateConfig, +} from '@antribute/config-loader'; +import { Type } from '@sinclair/typebox'; +import type { Static } from '@sinclair/typebox'; + +const dirname = fileURLToPath(new URL('.', import.meta.url)); + +export enum DatabaseEngine { + mysql = 'mysql', + postgresql = 'postgresql', + sqlite = 'sqlite', +} + +export enum PaginationConfiguration { + cursor = 'cursor', + disabled = 'disabled', + limitOffset = 'limitOffset', +} + +export const validationSchema = Type.Object({ + database: Type.Object({ + connectionString: Type.String({ default: process.env.DB_URL ?? '' }), + enabled: Type.Boolean({ default: true }), + engine: Type.Enum(DatabaseEngine, { default: DatabaseEngine.postgresql }), + pagination: Type.Enum(PaginationConfiguration, { default: PaginationConfiguration.cursor }), + }), + graphql: Type.Object({ + enabled: Type.Boolean({ default: true }), + endpoint: Type.String({ default: '/graphql' }), + }), + healthCheck: Type.Object({ + enabled: Type.Boolean({ default: true }), + endpoint: Type.String({ default: '/health' }), + }), + rest: Type.Object({ + enabled: Type.Boolean({ default: true }), + generateOpenApiSpec: Type.Boolean({ default: true }), + }), + server: Type.Object({ + generatedDir: Type.String({ default: join(dirname, 'generated') }), + port: Type.Number({ default: 8000 }), + rootDir: Type.String({ default: join('src', 'server') }), + }), +}); + +export type Config = Static; + +export const getConfig = (overrideConfigPath?: string) => + loadAndValidateConfig({ + fileNames: ['.typeapirc.ts', '.typeapirc.js', '.typeapirc.cjs', '.typeapirc.mjs'], + overrideConfigPath, + validationSchema, + }); + +export const defineConfig = createDefineConfig; diff --git a/packages/typeapi/src/db/drizzleUtils.ts b/packages/typeapi/src/db/drizzleUtils.ts new file mode 100644 index 00000000..55a5f2ac --- /dev/null +++ b/packages/typeapi/src/db/drizzleUtils.ts @@ -0,0 +1,230 @@ +import { Buffer } from 'buffer'; +import { join } from 'path'; + +import { output } from '@antribute/typecli'; +import { Type } from '@sinclair/typebox'; +import type { Static, TSchema } from '@sinclair/typebox'; +import { Value } from '@sinclair/typebox/value'; +import { and, asc, eq, gt, sql } from 'drizzle-orm'; +import type { + AnyColumn, + AnyTable, + InferColumnsDataTypes, + InferInsertModel, + InferSelectModel, +} from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/postgres-js'; +// import { withCursorPagination } from 'drizzle-pagination'; +import { createInsertSchema } from 'drizzle-typebox'; +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import { migrate as drizzleMigrate } from 'drizzle-orm/postgres-js/migrator'; +import { execa } from 'execa'; +import { HTTPException } from 'hono/http-exception'; +import postgres from 'postgres'; + +import { DatabaseEngine } from 'config'; +import type { Config } from 'config'; + +// @ts-expect-error: Our type generation process will always ensure that this typing extends the +// appropriate type, but we don't get autocompletion if we explicitly define it that way +export type Db = PostgresJsDatabase; + +export const ensureModelInputIsValid = (schema: TSchema, value: unknown) => { + const isValid = Value.Check(schema, value); + + if (isValid) { + return; + } + const errors = [...Value.Errors(schema, value)]; + const errorMessagesByField: Record = {}; + errors.forEach(({ message, path }) => { + const property = path.slice(1); + if (!errorMessagesByField[property]) { + errorMessagesByField[property] = [message]; + return; + } + errorMessagesByField[property] = [...(errorMessagesByField[property] || []), message]; + }); + const message = Object.entries(errorMessagesByField) + .map(([property, errors]) => `${property} (${errors.join(', ')})`) + .join(', '); + throw new HTTPException(422, { message: `The following fields are invalid: ${message}` }); +}; + +export const ensureTableHasPk = (methodName: string, table: AnyTable, primaryKey: AnyColumn) => { + if (!primaryKey) { + throw new HTTPException(500, { + message: `Field "id" not found on model "${table}" for method ${methodName}, manual configuration is required`, + }); + } +}; + +// IDs in SQL can be either strings or numbers. This function takes an ID, attempts to convert it +// to an int, and returns it as a string if the conversion fails. I'm not even sure if this is a +// good idea but fuck it we're doing it +export const parseId = (id: unknown) => { + const idAsString = String(id); + const idAsInt = Number.parseInt(idAsString, 10); + return Number.isNaN(idAsInt) ? idAsString : idAsInt; +}; + +export interface DataMultiItem { + data: DataShape[]; + pagination?: { + count: number; + next: string | null; + prev: string | null; + }; +} + +export interface DataSingleItem { + data: DataShape; +} + +export const createCrudOperations = ( + config: Config, + db: Db, + table: Table, + primaryKey: PrimaryKey +) => { + const insertSchema = createInsertSchema(table) as unknown as TSchema; + const updateSchema = Type.Optional(insertSchema); + + type PrimaryKeyInput = InferColumnsDataTypes<{ primaryKey: PrimaryKey }>['primaryKey']; + type InsertSchema = InferInsertModel
; + type UpdateSchema = Partial; + type ReturnSchema = InferSelectModel; + + // db.query.tasks.findMany({ where: {} }); + + const createOne = async (value: InsertSchema): Promise> => { + ensureTableHasPk('createOne', table, primaryKey); + ensureModelInputIsValid(insertSchema, value); + + const data = await db.insert(table).values(value).returning(); + return { data: data[0]! }; + }; + + const deleteOne = async (id: PrimaryKeyInput): Promise> => { + ensureTableHasPk('deleteOne', table, primaryKey); + + const data = await db + .delete(table) + .where(eq(primaryKey, parseId(id))) + .returning(); + return { data: data[0]! }; + }; + + const readMany = async (params?: { + pagination?: { + cursor?: string; + pageSize?: number; + }; + }): Promise> => { + ensureTableHasPk('readMany', table, primaryKey); + const cursor = params?.pagination?.cursor; + + const pageSize = params?.pagination?.pageSize ?? 10; + const [data, count] = await Promise.all([ + // TODO: Extend this to support custom orderBys + db + .select() + .from(table) + .orderBy(asc(primaryKey)) + .limit(pageSize) + .where(cursor ? gt(primaryKey, cursor) : undefined), + db.select({ count: sql`count(*)` }).from(table), + ]); + + if (config.database.pagination === 'disabled') { + return { data }; + } + + // For some reason count is returned as a string here, we need to cast that to a number + const parsedCount = count?.[0]?.count ? Number(count?.[0]?.count) : 0; + const nextCursor = + data.length === pageSize + ? Buffer.from(`${data?.[0]?.id}-${data?.[data.length - 1]?.id}`).toString('base64') + : null; + + return { + data, + pagination: { + count: parsedCount, + next: nextCursor, + prev: null, // TODO: Implement + }, + }; + }; + + const readOne = async (id: PrimaryKeyInput): Promise> => { + ensureTableHasPk('readOne', table, primaryKey); + + const data = await db + .select() + .from(table) + .where(eq(primaryKey, parseId(id))); + return { data }; + }; + + const updateOne = async ( + id: PrimaryKeyInput, + value: UpdateSchema + ): Promise> => { + ensureTableHasPk('updateOne', table, primaryKey); + ensureModelInputIsValid(updateSchema, value); + + const data = await db + .update(table) + // @ts-expect-error: I think this has something to do with the fact that the input object is + // partial but I'm not sure. Let's see if we can fix this before our initial release + .set(value) + .where(eq(primaryKey, parseId(id))) + .returning(); + return { data: data[0]! }; + }; + + return { createOne, deleteOne, readMany, readOne, updateOne }; +}; + +export const createDbConnection = (config: Config, schema?: TypeAPI.Schema) => { + switch (config.database.engine) { + case DatabaseEngine.postgresql: { + const client = postgres(config.database.connectionString); + // @ts-expect-error: TODO: The fuck is happening here, this follows docs??? + const db = drizzle(client, { schema }); + return db; + } + default: + throw new Error(`Database engine ${config.database.engine} is unsupported at this time`); + } +}; + +// Unfortunately we can't programmatically create Drizzle Kit migrations in TypeScript alone. In +// order to get around this, TypeAPI uses process execution (Bun.spawn in Bun and Execa in Node.js) +// to run drizzle-kit like any other command. Here we also insert some args to automatically +// configure drizzle-kit +export const makeMigrations = async (config: Config) => { + const outDir = join(process.cwd(), config.server.rootDir, 'migrations'); + const additionalArgs = [ + 'generate:pg', + '--schema', + join(process.cwd(), config.server.rootDir, '**', '*.models.ts'), + '--out', + outDir, + ]; + output.debug('Executing drizzle-kit'); + if (typeof Bun !== 'undefined') { + await Bun.spawn(['bunx', 'drizzle-kit', ...additionalArgs]).exited; + } else { + await execa('npx', ['drizzle-kit', ...additionalArgs]); + } + output.debug(`Migrations written to ${outDir}`); +}; + +export const migrate = async (config: Config) => { + const db = createDbConnection(config); + await drizzleMigrate(db, { + migrationsFolder: join(process.cwd(), config.server.rootDir, 'migrations'), + }); +}; diff --git a/packages/typeapi/src/db/index.ts b/packages/typeapi/src/db/index.ts new file mode 100644 index 00000000..0aa19271 --- /dev/null +++ b/packages/typeapi/src/db/index.ts @@ -0,0 +1,6 @@ +export { createCrudOperations, createDbConnection, makeMigrations, migrate } from './drizzleUtils'; +export type { Db } from './drizzleUtils'; +export { defineModelConfig } from './modelConfig'; +export type { ModelConfig } from './modelConfig'; +export { createCombinedSchema } from './schemaDefinitions'; +export { generateSchemaTypings } from './schemaGeneration'; diff --git a/packages/typeapi/src/db/modelConfig.ts b/packages/typeapi/src/db/modelConfig.ts new file mode 100644 index 00000000..195f856b --- /dev/null +++ b/packages/typeapi/src/db/modelConfig.ts @@ -0,0 +1,20 @@ +export interface RestOverride { + endpointUrl: string; +} + +export interface RestConfig { + baseUrl: string; + readOne: boolean | RestOverride; +} + +export interface ModelConfig { + rest: boolean | RestConfig; +} + +export const defineModelConfig = ( + name: ModelName, + config: Partial +) => ({ + name, + config, +}); diff --git a/packages/typeapi/src/db/schemaDefinitions.ts b/packages/typeapi/src/db/schemaDefinitions.ts new file mode 100644 index 00000000..3aabb14f --- /dev/null +++ b/packages/typeapi/src/db/schemaDefinitions.ts @@ -0,0 +1,41 @@ +import { join, sep } from 'path'; +import glob from 'fast-glob'; + +import type { Config } from 'config'; +import { output } from '@antribute/typecli'; + +const RELATIVE_PREFIX = `.${sep}`; + +export const getAllSchemaDefinitionPaths = async (config: Config): Promise => { + output.debug('Importing All .model.ts Files'); + const modelsGlob = await glob(join(process.cwd(), config.server.rootDir, '**', '*.models.ts')); + return modelsGlob; +}; + +export const parseSchemaDefinitionPath = (config: Config, schemaDefinitionPath: string) => { + const relativePath = schemaDefinitionPath.split(config.server.rootDir)[1]!; + let variableName = relativePath.split('.models.ts')[0]!; + if (variableName[0] === sep) { + variableName = variableName.substring(1); + } + const importPath = `${RELATIVE_PREFIX}${join(config.server.rootDir, `${variableName}.models`)}`; + return { + importPath, + relativePath, + variableName, + }; +}; + +export const createCombinedSchema = async (config: Config): Promise => { + const allModelFiles = await getAllSchemaDefinitionPaths(config); + const allModels = await Promise.all( + allModelFiles.map((modelFile) => import(modelFile) as Promise>) + ); + output.debug('Merging Models into Main Schema'); + const mergedSchemas = allModels.reduce( + (combinedSchema, currentModel) => ({ ...combinedSchema, ...currentModel }), + {} as TypeAPI.Schema // An empty object obviously isn't a schema so we cast this here + ); + output.debug('Main Schema Created'); + return mergedSchemas; +}; diff --git a/packages/typeapi/src/db/schemaGeneration.ts b/packages/typeapi/src/db/schemaGeneration.ts new file mode 100644 index 00000000..86df88c4 --- /dev/null +++ b/packages/typeapi/src/db/schemaGeneration.ts @@ -0,0 +1,69 @@ +import { join } from 'path'; + +import { output } from '@antribute/typecli'; + +import type { Config } from 'config'; +import { + createFile, + createGlobalDeclaration, + createTsProject, + getAllExportDeclarationsFromFile, + insertDisclaimer, +} from 'utils/codeGeneration'; + +import { getAllSchemaDefinitionPaths, parseSchemaDefinitionPath } from './schemaDefinitions'; + +// This algorithm is shit and needs to be improved +export const generateSchemaTypings = async (config: Config) => { + output.info('Generating Schema Typings'); + const project = createTsProject(); + const generatedGlobalTypingsFile = createFile( + project, + config, + join(process.cwd(), 'TypeAPI.d.ts'), + true + ); + + const schemaDefinitionPaths = await getAllSchemaDefinitionPaths(config); + const allSchemaImportDeclarations = generatedGlobalTypingsFile.addImportDeclarations( + schemaDefinitionPaths.map((schemaDefinitionPath) => { + const { importPath, variableName } = parseSchemaDefinitionPath(config, schemaDefinitionPath); + return { + moduleSpecifier: importPath, + namespaceImport: variableName, + isTypeOnly: true, + }; + }) + ); + + output.debug('Extracting Model Definitions'); + const allModels = allSchemaImportDeclarations.flatMap((schemaDefinitionFile) => { + const importName = schemaDefinitionFile.getNamespaceImportOrThrow().getText(); + return getAllExportDeclarationsFromFile( + schemaDefinitionFile.getModuleSpecifierSourceFile() + ).map((variableDeclaration) => ({ + importName, + modelName: variableDeclaration.getName(), + })); + }); + + output.debug('Creating Global Type Declaration'); + const globalDeclaration = createGlobalDeclaration(generatedGlobalTypingsFile); + + const typeAPINamespace = globalDeclaration.addModule({ isExported: true, name: 'TypeAPI' }); + const schemaInterface = typeAPINamespace.addInterface({ + name: 'Schema', + isExported: true, + }); + schemaInterface.addProperties( + allModels.map(({ importName, modelName }) => ({ + name: modelName, + type: `typeof ${importName}.${modelName}`, + })) + ); + + output.debug('Cleaning Up'); + insertDisclaimer(generatedGlobalTypingsFile); + await project.save(); + output.success('Schema Typings Generated'); +}; diff --git a/packages/typeapi/src/gql.ts b/packages/typeapi/src/gql.ts new file mode 100644 index 00000000..f3f2ab42 --- /dev/null +++ b/packages/typeapi/src/gql.ts @@ -0,0 +1,53 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { GraphQLObjectType, GraphQLSchema } from 'graphql'; +import { createYoga } from 'graphql-yoga'; + +import type { Config } from './config'; + +// TODO: Add the shit needed to create the schema itself here +export const createGraphqlSchema = async () => { + return new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: {}, + }), + }); +}; + +export const createGraphqlRoute = async (server: FastifyInstance, config: Config) => { + const schema = await createGraphqlSchema(); + + const yoga = createYoga<{ + req: FastifyRequest; + reply: FastifyReply; + }>({ + graphqlEndpoint: config.graphql.endpoint, + healthCheckEndpoint: '/health', + logging: { + debug: (...args) => args.forEach((arg) => server.log.debug(arg)), + info: (...args) => args.forEach((arg) => server.log.info(arg)), + warn: (...args) => args.forEach((arg) => server.log.warn(arg)), + error: (...args) => args.forEach((arg) => server.log.error(arg)), + }, + schema, + }); + + // Let's make sure this never gets included in a swagger file + server.route({ + method: ['GET', 'OPTIONS', 'POST'], + url: config.graphql.endpoint, + handler: async (req, reply) => { + const response = await yoga.handleNodeRequest(req, { + req, + reply, + }); + response.headers.forEach((value, key) => { + reply.header(key, value); + }); + reply.status(response.status); + reply.send(response.body); + + return reply; + }, + }); +}; diff --git a/packages/typeapi/src/index.ts b/packages/typeapi/src/index.ts new file mode 100644 index 00000000..92efaba4 --- /dev/null +++ b/packages/typeapi/src/index.ts @@ -0,0 +1,2 @@ +export { defineConfig } from './config'; +export { defineModelConfig } from './db'; diff --git a/packages/typeapi/src/rest/index.ts b/packages/typeapi/src/rest/index.ts new file mode 100644 index 00000000..d385c11c --- /dev/null +++ b/packages/typeapi/src/rest/index.ts @@ -0,0 +1 @@ +export { createRestApi } from './restRouter'; diff --git a/packages/typeapi/src/rest/openApi.ts b/packages/typeapi/src/rest/openApi.ts new file mode 100644 index 00000000..da49a937 --- /dev/null +++ b/packages/typeapi/src/rest/openApi.ts @@ -0,0 +1,3 @@ +import type { Config } from 'config'; + +export const generateOpenApiSchema = async (config: Config) => {}; diff --git a/packages/typeapi/src/rest/restRouter.ts b/packages/typeapi/src/rest/restRouter.ts new file mode 100644 index 00000000..adc04858 --- /dev/null +++ b/packages/typeapi/src/rest/restRouter.ts @@ -0,0 +1,93 @@ +import { output } from '@antribute/typecli'; +import type { Hono } from 'hono'; +import { get, snakeCase } from 'lodash-es'; + +import type { Config } from 'config'; +import { type Db, type ModelConfig, createCrudOperations } from 'db'; + +export const createModelRestEndpoints = ( + config: Config, + server: Hono, + db: Db, + schema: TypeAPI.Schema, + modelName: keyof TypeAPI.Schema, + modelConfig?: Partial +) => { + // If REST is disabled for this model in the config, we'll do nothing + if (typeof modelConfig?.rest === 'boolean' && modelConfig?.rest === false) { + return; + } + + let baseUrl = snakeCase(modelName as string); + // If REST is an object, we'll override the default config + if (typeof modelConfig?.rest === 'object') { + baseUrl = modelConfig?.rest?.baseUrl || baseUrl; + } + + // TODO: Lots of things for this function + // 1. Per-endpoint function overrides + // 2. Disabling endpoints + // 3. Swagger generation (might not need to be in this func tho) + // 4. Explore bulk endpoints + + const table = schema[modelName]; + // The ID field may or may not exist on a given table (we have checks to ensure the primary key + // is actually valid in createCrudOperations), so we use Lodash to safely get that value + const primaryKeyCol = get(table, 'id'); + const crudOperations = createCrudOperations(config, db, table, primaryKeyCol); + + const modelRootPath = `/${baseUrl}`; + const modelPathIdParam = `${baseUrl}_id`; + const modelChildPath = `/${baseUrl}s/:${modelPathIdParam}`; + + server.post(modelRootPath, async (c) => { + const body = await c.req.json(); + const data = await crudOperations.createOne(body); + return c.json({ data }); + }); + + server.get(modelRootPath, async (c) => { + const { cursor, pageSize = '10' } = c.req.query(); + const data = await crudOperations.readMany({ + pagination: { cursor, pageSize: Number.parseInt(pageSize, 10) }, + }); + return c.json(data); + }); + + server.get(modelChildPath, async (c) => { + const id = c.req.param(modelPathIdParam as never); + const data = await crudOperations.readOne(id); + return c.json({ data }); + }); + + server.post(modelChildPath, async (c) => { + const id = c.req.param(modelPathIdParam as never); + const body = await c.req.json(); + const data = await crudOperations.updateOne(id, body); + return c.json({ data }); + }); + + server.delete(modelChildPath, async (c) => { + const id = c.req.param(modelPathIdParam as never); + const data = await crudOperations.deleteOne(id); + return c.json({ data }); + }); +}; + +export const createRestApi = async ( + config: Config, + server: Hono, + db: Db, + schema: TypeAPI.Schema +) => { + if (!config.rest.enabled) { + output.debug('REST API Disabled in Config, Skipping Creation'); + return; + } + Object.keys(schema).forEach((modelName) => { + output.debug(` Creating REST Endpoints for model "${modelName}"`); + createModelRestEndpoints(config, server, db, schema, modelName as keyof TypeAPI.Schema); + output.debug(` Created REST Endpoints for model "${modelName}"`); + }); + output.debug('REST API Creation Complete'); +}; diff --git a/packages/typeapi/src/server/healthCheck.ts b/packages/typeapi/src/server/healthCheck.ts new file mode 100644 index 00000000..e70b6817 --- /dev/null +++ b/packages/typeapi/src/server/healthCheck.ts @@ -0,0 +1,13 @@ +import { output } from '@antribute/typecli'; +import type { Hono } from 'hono'; + +import type { Config } from 'config'; + +export const createHealthCheckEndpoint = (config: Config, server: Hono) => { + if (!config.healthCheck.enabled) { + output.debug('Health Check Endpoint Disabled in Config, Skipping Creation'); + return; + } + output.debug('Creating Health Check Endpoint at /health'); + server.get('/health', (c) => c.json({ status: 'Healthy', typeApiVersion: '0.1.0' })); +}; diff --git a/packages/typeapi/src/server/honoUtils.ts b/packages/typeapi/src/server/honoUtils.ts new file mode 100644 index 00000000..7b20e957 --- /dev/null +++ b/packages/typeapi/src/server/honoUtils.ts @@ -0,0 +1,50 @@ +import { output } from '@antribute/typecli'; +import { serve } from '@hono/node-server'; +import { Hono } from 'hono'; +import { HTTPException } from 'hono/http-exception'; + +import type { Config } from 'config'; + +export const createServer = () => { + const app = new Hono(); + + app.onError((err, c) => { + if (err instanceof HTTPException) { + c.status(err.status); + return c.json({ message: err.message }); + } + c.status(500); + output.error(`An improperly thrown error was caught: ${err.message}`); + return c.json({ message: 'An unknown error has occurred' }); + }); + return app; +}; + +export const startBunServer = (config: Config, server: Hono) => { + // Hono's documentation says to simply export default your server parameters and then to use the + // bun run command on that file. This technically works, but we prefer to wrap that in our own + // CLI for out of the box support for Node and Bun. The docs on that export default syntax for + // Bun.serve is limited, so I wanted to make sure to call that out here. For more info check out + // https://bun.sh/docs/api/http#object-syntax + Bun.serve({ ...server, port: config.server.port }); +}; + +export const startNodeServer = async (config: Config, server: Hono) => { + serve({ ...server, port: config.server.port }); +}; + +export const startServer = async (config: Config, server: Hono) => { + if (typeof Bun === 'undefined') { + // eslint-disable-next-line unicorn/prefer-type-error + throw new Error('Unsupported non-bun runtime'); + } else { + startBunServer(config, server); + } + // TODO: Should we consider Deno support? + output.success(`TypeAPI Server Started at Port ${config.server.port}`); + + // Also due to this being called from a CLI instead of Bun directly, we need to then await a + // never-resolving promise to ensure the server is only stopped when the process is exited. We'll + // have to figure out an alternative non-bun solution for a dev command here + await new Promise(() => {}); +}; diff --git a/packages/typeapi/src/server/index.ts b/packages/typeapi/src/server/index.ts new file mode 100644 index 00000000..0f2df1cb --- /dev/null +++ b/packages/typeapi/src/server/index.ts @@ -0,0 +1,2 @@ +export { createServer, startServer } from './honoUtils'; +export { buildRouter } from './router'; diff --git a/packages/typeapi/src/server/router.ts b/packages/typeapi/src/server/router.ts new file mode 100644 index 00000000..8af353a2 --- /dev/null +++ b/packages/typeapi/src/server/router.ts @@ -0,0 +1,13 @@ +import type { Hono } from 'hono'; + +import type { Config } from 'config'; +import { createCombinedSchema, createDbConnection } from 'db'; +import { createRestApi } from 'rest'; +import { createHealthCheckEndpoint } from './healthCheck'; + +export const buildRouter = async (config: Config, server: Hono) => { + createHealthCheckEndpoint(config, server); + const combinedSchema = await createCombinedSchema(config); + const db = createDbConnection(config, combinedSchema); + await createRestApi(config, server, db, combinedSchema); +}; diff --git a/packages/typeapi/src/utils/codeGeneration.ts b/packages/typeapi/src/utils/codeGeneration.ts new file mode 100644 index 00000000..91db2fab --- /dev/null +++ b/packages/typeapi/src/utils/codeGeneration.ts @@ -0,0 +1,49 @@ +import { join } from 'path'; + +import { ModuleDeclarationKind, Project } from 'ts-morph'; +import type { SourceFile } from 'ts-morph'; +import type { Config } from 'config'; + +export const createFile = ( + project: Project, + config: Config, + fileName: string, + omitServerDir = false +) => { + const file = project.createSourceFile( + omitServerDir ? fileName : join(process.cwd(), config.server.rootDir, fileName), + undefined, + { overwrite: true } + ); + return file; +}; + +export const createTsProject = () => { + const project = new Project({ + skipAddingFilesFromTsConfig: true, + // TODO: Add support for custom tsconfig paths + tsConfigFilePath: join(process.cwd(), 'tsconfig.json'), + }); + return project; +}; + +export const createGlobalDeclaration = (file: SourceFile) => { + return file.addModule({ + hasDeclareKeyword: true, + declarationKind: ModuleDeclarationKind.Global, + name: 'global', + }); +}; + +export const getAllExportDeclarationsFromFile = (file?: SourceFile) => + file + ?.getVariableStatements() + .filter((variableStatement) => variableStatement.hasExportKeyword()) + .map((variableStatement) => variableStatement.getDeclarations()) + .flatMap((variableDeclarations) => variableDeclarations) ?? []; + +export const insertDisclaimer = (file: SourceFile) => { + file.insertText(0, (writer) => { + writer.writeLine('// Autogenerated by TypeAPI, do not modify').blankLine(); + }); +}; diff --git a/packages/typeapi/tsconfig.json b/packages/typeapi/tsconfig.json new file mode 100644 index 00000000..f9d23f9a --- /dev/null +++ b/packages/typeapi/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@antribute/config/tsconfig/tsconfig.base.json", + "include": ["TypeAPI.d.ts", "src/**/*.ts", "example/**/*.ts"], + "exclude": ["node_modules"], + "compilerOptions": { + "baseUrl": "src", + "types": ["./TypeAPI.d.ts"] + } +} diff --git a/packages/typecli/LICENSE b/packages/typecli/LICENSE new file mode 100644 index 00000000..1e65fa0b --- /dev/null +++ b/packages/typecli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Antribute, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/typecli/README.md b/packages/typecli/README.md new file mode 100644 index 00000000..4701df60 --- /dev/null +++ b/packages/typecli/README.md @@ -0,0 +1,11 @@ +# TypeCLI + +An all-in-one kit of TypeScript friendly CLI utilities + +## Installation + +```bash +pnpm i @antribute/typecli +``` + +TODO: Write real docs for this diff --git a/packages/typecli/build.config.ts b/packages/typecli/build.config.ts new file mode 100644 index 00000000..113ea9d5 --- /dev/null +++ b/packages/typecli/build.config.ts @@ -0,0 +1,2 @@ +import unbuildConfig from '@antribute/config/unbuild/unbuild.config.react'; +export default unbuildConfig; diff --git a/packages/typecli/package.json b/packages/typecli/package.json new file mode 100644 index 00000000..1b08a14f --- /dev/null +++ b/packages/typecli/package.json @@ -0,0 +1,45 @@ +{ + "name": "@antribute/typecli", + "description": "An all-in-one kit of TypeScript friendly CLI utilities", + "version": "0.1.0", + "type": "module", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "bun": "./src/index.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "types": "./dist/index.d.ts", + "files": [ + "dist/**", + "src/**" + ], + "scripts": { + "build": "bunx --bun unbuild", + "clean": "rimraf .turbo rimraf dist" + }, + "keywords": [ + "antribute", + "typecli" + ], + "author": "Antribute, Inc. (https://www.antribute.com)", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.31.17", + "cmd-ts": "^0.12.1", + "execa": "^7.1.1", + "picocolors": "^1.0.0", + "prompts": "^2.4.2" + }, + "devDependencies": { + "@antribute/config": "workspace:*", + "@types/prompts": "^2.4.5", + "typescript": "^5.0.4", + "unbuild": "^2.0.0" + } +} diff --git a/packages/typecli/src/colors.ts b/packages/typecli/src/colors.ts new file mode 100644 index 00000000..ff0be4e1 --- /dev/null +++ b/packages/typecli/src/colors.ts @@ -0,0 +1,3 @@ +import { blue, gray, green, red, yellow } from 'picocolors'; + +export { gray as debug, red as error, blue as info, green as success, yellow as warn }; diff --git a/packages/typecli/src/command.ts b/packages/typecli/src/command.ts new file mode 100644 index 00000000..fd6803c3 --- /dev/null +++ b/packages/typecli/src/command.ts @@ -0,0 +1,20 @@ +import { command } from 'cmd-ts'; + +import { parseUnknownError } from './errorHandling'; +import { error as logError } from './output'; + +export type CreateCommandOptions = Parameters[0]; + +export const createCommand = ({ handler, ...options }: CreateCommandOptions) => + command({ + ...options, + handler: async (args) => { + try { + await handler(args); + process.exit(0); + } catch (err) { + logError(parseUnknownError(err)); + process.exit(1); + } + }, + }); diff --git a/packages/typecli/src/errorHandling.ts b/packages/typecli/src/errorHandling.ts new file mode 100644 index 00000000..a7f7ee95 --- /dev/null +++ b/packages/typecli/src/errorHandling.ts @@ -0,0 +1,13 @@ +export const parseUnknownError = (err: unknown) => { + let message = 'An unknown error has occurred, please try again later'; + + if (err instanceof Error && err.message.length) { + message = err.message; + } + + if (typeof err === 'string') { + message = err; + } + + return message; +}; diff --git a/packages/typecli/src/execution.ts b/packages/typecli/src/execution.ts new file mode 100644 index 00000000..772ff05e --- /dev/null +++ b/packages/typecli/src/execution.ts @@ -0,0 +1,35 @@ +import { execa } from 'execa'; +import type { ExecaError } from 'execa'; + +import { debug as logDebug, error as logError } from './output'; + +export const executeCommand = async ( + command: string, + args?: string[], + { logOutput } = { logOutput: true } +) => { + try { + const { stdout, stderr } = await execa(command, args); + + if (logOutput) { + stdout.split(/\r?\n/).forEach((line) => { + if (!line.length) { + return; + } + logDebug(line); + }); + stderr.split(/\r?\n/).forEach((line) => { + if (!line.length) { + return; + } + logError(line); + }); + } + } catch (err) { + // Execa will ALWAYS throw an ExecaError so we can safely cast this + const parsedError = err as ExecaError; + throw new Error( + `Command "${command}" Failed with Error: ${parsedError.originalMessage ?? 'Unknown Error'}` + ); + } +}; diff --git a/packages/typecli/src/index.ts b/packages/typecli/src/index.ts new file mode 100644 index 00000000..4fe73230 --- /dev/null +++ b/packages/typecli/src/index.ts @@ -0,0 +1,6 @@ +export * as colors from './colors'; +export { createCommand } from './command'; +export * as execution from './execution'; +export * as input from './input'; +export * as output from './output'; +export { createProgram } from './program'; diff --git a/packages/typecli/src/input.ts b/packages/typecli/src/input.ts new file mode 100644 index 00000000..04acbfd4 --- /dev/null +++ b/packages/typecli/src/input.ts @@ -0,0 +1,44 @@ +import type { TSchema } from '@sinclair/typebox'; +import { Value } from '@sinclair/typebox/value'; +import prompts from 'prompts'; +import type { PromptType } from 'prompts'; + +// The prompts package doesn't have the best TypeScript typings, especially for prompt names and +// prompt output types. In order to properly map this, we need to create a mapping of PromptTypes +// to actual typescript types and then hook that all together using a generic tied to the type +// parameter. It's pretty cool when you think about it, and maybe we should contribute this to the +// official @types/prompts package? We also threw in TypeBox validation instead of the builtin +// Prompts validation to standardize how we validate data across the Antribute stack +export interface AskQuestionOptions { + type: InputType; + validate?: TSchema; +} + +export interface OutputType extends Record { + text: string; + password: string; + invisible: string; + number: number; + confirm: boolean; + list: string[]; + toggle: boolean; + select: string; + multiselect: string[]; + autocomplete: string; + date: Date; + autocompleteMultiselect: string[]; +} +export const askQuestion = async ( + question: string, + { validate, ...options }: AskQuestionOptions +) => { + const { value } = (await prompts([ + { + ...options, + message: question, + name: 'value', + validate: validate ? (valueToValidate) => Value.Check(validate, valueToValidate) : undefined, + }, + ])) as { value: OutputType[InputType] }; + return value; +}; diff --git a/packages/typecli/src/output.ts b/packages/typecli/src/output.ts new file mode 100644 index 00000000..ebe9f36d --- /dev/null +++ b/packages/typecli/src/output.ts @@ -0,0 +1,49 @@ +import * as colors from './colors'; + +type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export const parseLogLevel = () => { + let logLevel = process.env.LOG_LEVEL ?? ''; + const allowedLogLevels: LogLevel[] = ['debug', 'info', 'warn', 'error']; + + if (!allowedLogLevels.includes(logLevel as LogLevel)) { + logLevel = 'info'; + } + + return logLevel as LogLevel; +}; + +export const debug = (message: string) => { + // Only show debug messages when LOG_LEVEL === debug + if (parseLogLevel() === 'debug') { + console.warn(`${colors.debug('[DEBUG]')} ${message}`); + } +}; + +export const error = (message: string) => { + // Always show error messages regardless of LOG_LEVEL + console.error(`${colors.error('[ERROR]')} ${message}`); +}; + +export const info = (message: string) => { + // Only show info messages when LOG_LEVEL === debug || info + if (!['warn', 'error'].includes(parseLogLevel())) { + // eslint-disable-next-line no-console + console.info(`${colors.info('[INFO]')} ${message}`); + } +}; + +export const success = (message: string) => { + // Only show success messages when LOG_LEVEL === debug || info + if (!['warn', 'error'].includes(parseLogLevel())) { + // eslint-disable-next-line no-console + console.info(`${colors.success('[SUCCESS]')} ${message}`); + } +}; + +export const warn = (message: string) => { + // Only show warn messages when LOG_LEVEL === debug || info || warn + if (parseLogLevel() !== 'error') { + console.warn(`${colors.warn('[WARN]')} ${message}`); + } +}; diff --git a/packages/typecli/src/program.ts b/packages/typecli/src/program.ts new file mode 100644 index 00000000..dd7c6ebb --- /dev/null +++ b/packages/typecli/src/program.ts @@ -0,0 +1,41 @@ +import { run, subcommands } from 'cmd-ts'; + +import { parseUnknownError } from './errorHandling'; +import { error as logError } from './output'; + +export type CreateProgramOptions = Parameters[0]; + +/** + * Creates and runs a new CLI program that requires the user to enter a single command form a list + * of provided commands or shows a help screen when the `--help` flag is provided + * + * @example + * ```typescript + * createProgram({ + * name: 'my-cli', + * cmds: { + * 'some-command': someCommand, + * 'another-command': anotherCommand + * }, + * }); + * ``` + */ +export const createProgram = async (options: CreateProgramOptions): Promise => { + // Bun has builtin dotenv support whereas Node.js doesn't. If TypeCLI detects that it's running + // on Node it'll conditionally import dotenv. This is a nice DX feature for things such as + // allowing users to easily set LOG_LEVEL or any other required env vars for the CLI that's + // consuming TypeAPI + + if (typeof Bun === 'undefined') { + await import('dotenv/config'); + } + const cli = subcommands(options); + + try { + await run(cli, process.argv.slice(2)); + process.exit(0); + } catch (err) { + logError(parseUnknownError(err)); + process.exit(1); + } +}; diff --git a/packages/typecli/tsconfig.json b/packages/typecli/tsconfig.json new file mode 100644 index 00000000..59d95c81 --- /dev/null +++ b/packages/typecli/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@antribute/config/tsconfig/tsconfig.base.json", + "include": ["src/**/*.ts", "example/**/*.ts", "TypeAPI.d.ts"], + "exclude": ["node_modules"], + "compilerOptions": { + "baseUrl": "src" + } +} diff --git a/seed.sql b/seed.sql new file mode 100644 index 00000000..badb5746 --- /dev/null +++ b/seed.sql @@ -0,0 +1 @@ +CREATE DATABASE typeapi; \ No newline at end of file