diff --git a/.gitignore b/.gitignore index 11099b1..c8dc9f0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ docs/superpowers/ # locally generated test apps *-app/ +apps/ + +.idea/ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..3932801 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npm run format diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/CLAUDE.md b/CLAUDE.md index b60b25c..04edaf7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,17 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co `create-pipedrive-app` is a CLI scaffolding tool for external Pipedrive Marketplace developers. It generates a production-ready integration project via `npx create-pipedrive-app `. +## Commands + +```bash +npm run build # compile TypeScript to dist/ +npm run typecheck # type-check without emitting +npm run lint # ESLint +npm run format # Prettier (120 char width, tabs, trailing commas) +npm test # Vitest suite +npm run generate # generate test project in apps/test-app/ (gitignored) +``` + ## Architecture The tool is **CLI-first**, with an **AI plugin layer** built on top: @@ -21,6 +32,21 @@ The CLI asks for: - App Extensions frontend: React, Vanilla JS, or none - Webhooks: Yes/No +### Generator flow + +``` +cli.ts (collects prompts) + → prompts/ (projectName, database, appExtensions, webhooks) + → nodeGenerator (orchestrates 5 sub-generators) + → oauth.ts, database.ts, app.ts + → webhooks.ts (conditional), appExtensions.ts (conditional) + → serverEntry, packageJson, tsConfig, envExample, dockerCompose +``` + +**There is no template directory.** Generators build file content as strings using `dedent()`, with conditional string interpolation for optional features (webhooks, app extension types). The `src/utils/writeFile.ts` utility writes files, creates parent directories, and auto-formats output with Prettier — generated code is formatted automatically without an explicit format step. + +`app.ts` is the main example of the conditional pattern: imports and router mounts are included only when the relevant features are enabled, and the result is written once. + ### Generated project structure ``` @@ -38,6 +64,38 @@ The CLI asks for: marketplace-checklist.md ``` +## Adding features + +- **New prompt**: add `src/prompts/.ts` + `.test.ts`, export from `cli.ts` +- **New generator**: add `src/generators/node/.ts` + `.test.ts`, call from `nodeGenerator` +- **Modify generated scaffold**: edit template strings in the corresponding generator file + +## Builder Pattern + +The scaffold generator uses `NodeProjectBuilder` + `BuildStep` (`src/generators/node/projectBuilder.ts`) to compose features without scattering conditional logic across generators. + +**How it works:** +- `BuildStep` interface: `execute(outputDir: string, options: GeneratorOptions): Promise` +- Each feature is a private class implementing `BuildStep` (e.g. `OAuthStep`, `DatabaseStep`) +- `NodeProjectBuilder` queues steps and runs them in order via `.build()` +- Named methods like `.addOAuth()`, `.addDatabase()` push steps unconditionally +- `when(condition, fn)` adds steps conditionally at the call site in `index.ts` — never inside `execute()` + +**Adding a new feature:** +1. Create a private `BuildStep` class in `projectBuilder.ts` +2. Add a named method to `NodeProjectBuilder`: `addMyFeature(): this { return this.addStep(new MyFeatureStep()); }` +3. Call it in `index.ts`, via `when()` if conditional: + ```ts + .when(options.webhooks, b => b.addWebhooks()) + ``` + +**Rule: never put conditional logic inside `execute()`** — use `when()` at the call site. Steps must be unconditional internally; the builder chain controls what runs. + +**File content helpers:** +- Use `SourceFileBuilder` (`src/utils/sourceFileBuilder.ts`) for TypeScript files with conditional imports or blocks — handles deduplication and formatting automatically +- Use `RouterMountBuilder` (`src/utils/templates.ts`) to accumulate `app.use()` calls conditionally +- Use plain `dedent` for static content (YAML, JSON, `.env`, SQL) + ## MVP Scope The initial implementation targets: @@ -49,7 +107,7 @@ The initial implementation targets: - **Frontend** (optional): React App Extensions UI - Outputs `.env.example` and a Marketplace readiness checklist -PHP and MySQL/SQLite backends come after MVP. +PHP and MySQL/SQLite backends come after MVP. The PHP generator exists but throws "not yet implemented". App Extensions frontend is prompted but not yet generated. ## Core Modules @@ -64,24 +122,20 @@ Structure: - `migrations/` — SQL migration files managed by `drizzle-kit` - `db.ts` — driver setup (selects `postgres-js`, `mysql2`, or `better-sqlite3` based on the chosen DB) -Key packages: `drizzle-orm`, `drizzle-kit`, and the appropriate driver for the selected database. - ### Pipedrive API client (`backend/pipedrive-client/`) Wrapper around the official Pipedrive Node.js client with preconfigured authentication and helpers for common API calls. ### App Extensions frontend (`frontend/app-extension-ui/`) Only generated when the user opts in. Iframe-based UI using the App Extensions SDK, supporting: initialization, resizing, modals, notifications/snackbars, theme handling. -## Development +## Tests -To test app generation locally: +Vitest. Tests generate files into a `tmpdir()/cpa-app-test` directory, read them back to verify content, and clean up in `afterEach`. Run a single test file: ```bash -npx tsx src/cli.ts app +npx vitest run src/generators/node/app.test.ts ``` -This creates an `app/` directory in the repo root (gitignored via `*-app/`). - ## AI Plugin Commands (future layer) ``` diff --git a/README.md b/README.md index 784d109..b1d1785 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,52 @@ # create-pipedrive-app -Scaffold a production-ready Pipedrive Marketplace app with OAuth, database, webhooks, and App Extensions in seconds. + +CLI scaffolding tool for Pipedrive Marketplace integrations. + +## Usage + +```bash +npx create-pipedrive-app +``` + +The CLI will prompt for: + +- **Database**: Postgres, MySQL, or SQLite +- **App Extensions**: custom panel, custom modal, or none +- **Webhooks**: yes or no + +## Generated project + +``` +/ + src/ + index.ts # server entry point (port 3000) + app.ts # Express app with OAuth router (+ optional webhooks/extensions) + oauth/ # OAuth 2.0 install, callback, token exchange, refresh + database/ # Drizzle ORM schema, migrations, db driver + pipedrive-client/ # Pipedrive API client wrapper + webhooks/ # Webhook handlers (if selected) + app-extensions/ # App Extensions handlers (if selected) + .env.example + docker-compose.yml # Postgres or MySQL (if applicable) + package.json + tsconfig.json +``` + +The generated project uses **Express + TypeScript + Drizzle ORM** (ESM, Node.js). + +## Next steps after generation + +```bash +cd +cp .env.example .env +docker-compose up -d # if Postgres or MySQL was selected +npm install +npm run dev +``` + +Fill in `PIPEDRIVE_CLIENT_ID`, `PIPEDRIVE_CLIENT_SECRET`, and `DATABASE_URL` in `.env`. + +## Requirements + +- Node.js 18+ +- Docker (if using Postgres or MySQL) diff --git a/package-lock.json b/package-lock.json index 9cc1846..f7fa595 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@types/dedent": "^0.7.2", "@types/node": "^20.19.39", "eslint": "^9.0.0", + "husky": "^9.1.7", "tsx": "^4.7.0", "typescript": "^5.4.0", "typescript-eslint": "^8.0.0", @@ -2206,6 +2207,22 @@ "node": ">=16.17.0" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://npm-registry-proxy.pipedrive.tools/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://npm-registry-proxy.pipedrive.tools/ignore/-/ignore-5.3.2.tgz", diff --git a/package.json b/package.json index a2bf837..753cb20 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "format": "prettier --write src", "lint": "eslint src", "test": "vitest run", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "generate": "tsx src/cli.ts apps/test-app", + "prepare": "husky" }, "dependencies": { "@clack/prompts": "^0.9.0", @@ -23,6 +25,7 @@ "@types/dedent": "^0.7.2", "@types/node": "^20.19.39", "eslint": "^9.0.0", + "husky": "^9.1.7", "tsx": "^4.7.0", "typescript": "^5.4.0", "typescript-eslint": "^8.0.0", diff --git a/src/cli.ts b/src/cli.ts index 8270c25..74e98f3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,6 @@ import * as clack from '@clack/prompts'; -import { basename, resolve } from 'path'; +import { spawn } from 'node:child_process'; +import { basename, resolve } from 'node:path'; import { promptAppExtensions } from './prompts/appExtensions.js'; import { promptDatabase } from './prompts/database.js'; import { promptProjectName } from './prompts/projectName.js'; @@ -26,12 +27,25 @@ async function main(): Promise { clack.outro(`✓ Created ${projectName}`); + const installDeps = await clack.confirm({ message: 'Install dependencies now?' }); + if (clack.isCancel(installDeps)) process.exit(0); + + if (installDeps) { + const spinner = clack.spinner(); + spinner.start('Installing dependencies'); + const ok = await new Promise((resolve) => { + const child = spawn('npm', ['install'], { cwd: outputDir, stdio: 'ignore' }); + child.on('close', (code) => resolve(code === 0)); + }); + spinner.stop(ok ? 'Dependencies installed' : 'npm install failed — run it manually'); + } + const needsDocker = database === 'postgres' || database === 'mysql'; console.log('\nNext steps:'); console.log(` cd ${nameOrPath}`); console.log(' cp .env.example .env'); if (needsDocker) console.log(' docker-compose up -d'); - console.log(' npm install'); + if (!installDeps) console.log(' npm install'); console.log(' npm run dev'); } diff --git a/src/generators/node/app.ts b/src/generators/node/app.ts index 90642a6..d1fb86c 100644 --- a/src/generators/node/app.ts +++ b/src/generators/node/app.ts @@ -1,41 +1,30 @@ -import dedent from 'dedent'; import { join } from 'path'; import { writeFile } from '../../utils/writeFile.js'; import type { GeneratorOptions } from '../interface.js'; +import { SourceFileBuilder } from '../../utils/sourceFileBuilder.js'; +import { RouterMountBuilder } from '../../utils/templates.js'; export async function generateApp(outputDir: string, options: GeneratorOptions): Promise { - const webhooksImport = options.webhooks ? `import webhooksRouter from './webhooks/index.js';` : ''; - const panelImport = options.appExtensions.includes('custom-panel') - ? `import panelRouter from './app-extensions/panel/index.js';` - : ''; - const modalImport = options.appExtensions.includes('custom-modal') - ? `import modalRouter from './app-extensions/modal/index.js';` - : ''; + const hasPanel = options.appExtensions.includes('custom-panel'); + const hasModal = options.appExtensions.includes('custom-modal'); - const webhooksMount = options.webhooks ? `app.use('/webhooks', webhooksRouter);` : ''; - const panelMount = options.appExtensions.includes('custom-panel') - ? `app.use('/extensions/panel', panelRouter);` - : ''; - const modalMount = options.appExtensions.includes('custom-modal') - ? `app.use('/extensions/modal', modalRouter);` - : ''; + const mounts = new RouterMountBuilder() + .add('/oauth', 'oauthRouter') + .addIf(options.webhooks, '/webhooks', 'webhooksRouter') + .addIf(hasPanel, '/extensions/panel', 'panelRouter') + .addIf(hasModal, '/extensions/modal', 'modalRouter') + .build(); - const content = dedent` - import express from 'express'; - import oauthRouter from './oauth/index.js'; - ${webhooksImport} - ${panelImport} - ${modalImport} - - const app = express(); - - app.use('/oauth', oauthRouter); - ${webhooksMount} - ${panelMount} - ${modalMount} - - export default app; - `; + const content = new SourceFileBuilder() + .importDefault('express', 'express') + .importDefault('./oauth/index.js', 'oauthRouter') + .importDefaultIf(options.webhooks, './webhooks/index.js', 'webhooksRouter') + .importDefaultIf(hasPanel, './app-extensions/panel/index.js', 'panelRouter') + .importDefaultIf(hasModal, './app-extensions/modal/index.js', 'modalRouter') + .addBlock('const app = express();') + .addBlock(mounts) + .exportDefault('app') + .build(); await writeFile(join(outputDir, 'src/app.ts'), content); } diff --git a/src/generators/node/appExtensions.test.ts b/src/generators/node/appExtensions.test.ts index baa11b5..3ce4c3f 100644 --- a/src/generators/node/appExtensions.test.ts +++ b/src/generators/node/appExtensions.test.ts @@ -5,7 +5,11 @@ import { tmpdir } from 'os'; import type { GeneratorOptions } from '../interface.js'; const tmpDir = join(tmpdir(), 'cpa-appext-test'); -const exists = (p: string) => access(p).then(() => true, () => false); +const exists = (p: string) => + access(p).then( + () => true, + () => false, + ); afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }); diff --git a/src/generators/node/database.test.ts b/src/generators/node/database.test.ts index db11045..c87d2c9 100644 --- a/src/generators/node/database.test.ts +++ b/src/generators/node/database.test.ts @@ -5,29 +5,235 @@ import { tmpdir } from 'os'; import type { GeneratorOptions } from '../interface.js'; const tmpDir = join(tmpdir(), 'cpa-database-test'); -const exists = (p: string) => access(p).then(() => true, () => false); -const options: GeneratorOptions = { +const exists = (p: string) => + access(p).then( + () => true, + () => false, + ); +const read = (p: string) => readFile(join(tmpDir, p), 'utf-8'); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +const pgOptions: GeneratorOptions = { projectName: 'test-app', database: 'postgres', webhooks: false, appExtensions: [], }; -afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); +const mysqlOptions: GeneratorOptions = { + projectName: 'test-app', + database: 'mysql', + webhooks: false, + appExtensions: [], +}; + +const sqliteOptions: GeneratorOptions = { + projectName: 'test-app', + database: 'sqlite', + webhooks: false, + appExtensions: [], +}; + +describe('generateDatabase — schema.ts', () => { + it('generates src/database/schema.ts for postgres', async () => { + const { generateDatabase } = await import('./database.js'); + await generateDatabase(tmpDir, pgOptions); + expect(await exists(join(tmpDir, 'src/database/schema.ts'))).toBe(true); + const content = await read('src/database/schema.ts'); + expect(content).toContain('pipedrive_tokens'); + expect(content).toContain('pipedriveCompanyId'); + expect(content).toContain('pipedriveUserId'); + expect(content).toContain('primaryKey'); + expect(content).toContain('pgTable'); + }); + + it('generates src/database/schema.ts for mysql', async () => { + const { generateDatabase } = await import('./database.js'); + await generateDatabase(tmpDir, mysqlOptions); + const content = await read('src/database/schema.ts'); + expect(content).toContain('mysqlTable'); + }); + + it('generates src/database/schema.ts for sqlite', async () => { + const { generateDatabase } = await import('./database.js'); + await generateDatabase(tmpDir, sqliteOptions); + const content = await read('src/database/schema.ts'); + expect(content).toContain('sqliteTable'); + }); }); -describe('generateDatabase', () => { - it('creates src/database/index.ts', async () => { +describe('generateDatabase — src/database/index.ts', () => { + it('postgres client uses postgres-js', async () => { const { generateDatabase } = await import('./database.js'); - await generateDatabase(tmpDir, options); - expect(await exists(join(tmpDir, 'src/database/index.ts'))).toBe(true); + await generateDatabase(tmpDir, pgOptions); + const content = await read('src/database/index.ts'); + expect(content).toContain('postgres'); + expect(content).toContain('drizzle-orm/postgres-js'); + expect(content).toContain('export'); }); - it('file is valid TypeScript (exports something)', async () => { + it('mysql client uses mysql2', async () => { + const { generateDatabase } = await import('./database.js'); + await generateDatabase(tmpDir, mysqlOptions); + const content = await read('src/database/index.ts'); + expect(content).toContain('mysql2'); + expect(content).toContain('drizzle-orm/mysql2'); + }); + + it('sqlite client uses @libsql/client', async () => { + const { generateDatabase } = await import('./database.js'); + await generateDatabase(tmpDir, sqliteOptions); + const content = await read('src/database/index.ts'); + expect(content).toContain('@libsql/client'); + expect(content).toContain('drizzle-orm/libsql'); + }); +}); + +describe('generateDatabase — migrate.ts', () => { + it('generates src/database/migrate.ts with runMigrations export', async () => { const { generateDatabase } = await import('./database.js'); - await generateDatabase(tmpDir, options); - const content = await readFile(join(tmpDir, 'src/database/index.ts'), 'utf-8'); + await generateDatabase(tmpDir, pgOptions); + expect(await exists(join(tmpDir, 'src/database/migrate.ts'))).toBe(true); + const content = await read('src/database/migrate.ts'); + expect(content).toContain('runMigrations'); expect(content).toContain('export'); }); + + it('postgres migrate imports from drizzle-orm/postgres-js/migrator', async () => { + const { generateDatabase } = await import('./database.js'); + await generateDatabase(tmpDir, pgOptions); + const content = await read('src/database/migrate.ts'); + expect(content).toContain('postgres-js/migrator'); + }); + + it('mysql migrate imports from drizzle-orm/mysql2/migrator', async () => { + const { generateDatabase } = await import('./database.js'); + await generateDatabase(tmpDir, mysqlOptions); + const content = await read('src/database/migrate.ts'); + expect(content).toContain('mysql2/migrator'); + }); + + it('sqlite migrate imports from drizzle-orm/libsql/migrator', async () => { + const { generateDatabase } = await import('./database.js'); + await generateDatabase(tmpDir, sqliteOptions); + const content = await read('src/database/migrate.ts'); + expect(content).toContain('libsql/migrator'); + }); +}); + +describe('generateDatabase — 0000_init.sql', () => { + it('generates migration file with CREATE TABLE', async () => { + const { generateDatabase } = await import('./database.js'); + await generateDatabase(tmpDir, pgOptions); + expect(await exists(join(tmpDir, 'src/database/migrations/0000_init.sql'))).toBe(true); + const content = await read('src/database/migrations/0000_init.sql'); + expect(content).toContain('CREATE TABLE'); + expect(content).toContain('pipedrive_tokens'); + expect(content).toContain('pipedrive_company_id'); + expect(content).toContain('pipedrive_user_id'); + }); + + it('postgres migration uses INTEGER and TIMESTAMP', async () => { + const { generateDatabase } = await import('./database.js'); + await generateDatabase(tmpDir, pgOptions); + const content = await read('src/database/migrations/0000_init.sql'); + expect(content).toContain('TIMESTAMP'); + expect(content).toContain('VARCHAR'); + }); + + it('sqlite migration uses INTEGER and TEXT', async () => { + const { generateDatabase } = await import('./database.js'); + await generateDatabase(tmpDir, sqliteOptions); + const content = await read('src/database/migrations/0000_init.sql'); + expect(content).toContain('INTEGER'); + expect(content).toContain('TEXT'); + }); +}); + +describe('generateDatabase — meta/_journal.json', () => { + it('generates journal with 0000_init entry', async () => { + const { generateDatabase } = await import('./database.js'); + await generateDatabase(tmpDir, pgOptions); + expect(await exists(join(tmpDir, 'src/database/migrations/meta/_journal.json'))).toBe(true); + const content = await read('src/database/migrations/meta/_journal.json'); + const journal = JSON.parse(content); + expect(journal.entries[0].tag).toBe('0000_init'); + expect(journal.entries[0].breakpoints).toBe(true); + }); + + it('postgres journal uses postgresql dialect', async () => { + const { generateDatabase } = await import('./database.js'); + await generateDatabase(tmpDir, pgOptions); + const journal = JSON.parse(await read('src/database/migrations/meta/_journal.json')); + expect(journal.dialect).toBe('postgresql'); + }); + + it('sqlite journal uses sqlite dialect', async () => { + const { generateDatabase } = await import('./database.js'); + await generateDatabase(tmpDir, sqliteOptions); + const journal = JSON.parse(await read('src/database/migrations/meta/_journal.json')); + expect(journal.dialect).toBe('sqlite'); + }); +}); + +describe('generateDatabase — drizzle.config.ts', () => { + it('generates drizzle.config.ts', async () => { + const { generateDatabase } = await import('./database.js'); + await generateDatabase(tmpDir, pgOptions); + expect(await exists(join(tmpDir, 'drizzle.config.ts'))).toBe(true); + const content = await read('drizzle.config.ts'); + expect(content).toContain('src/database/migrations'); + expect(content).toContain('src/database/schema.ts'); + }); + + it('postgres config uses postgresql dialect', async () => { + const { generateDatabase } = await import('./database.js'); + await generateDatabase(tmpDir, pgOptions); + const content = await read('drizzle.config.ts'); + expect(content).toContain('postgresql'); + }); + + it('mysql config uses mysql dialect', async () => { + const { generateDatabase } = await import('./database.js'); + await generateDatabase(tmpDir, mysqlOptions); + const content = await read('drizzle.config.ts'); + expect(content).toContain('mysql'); + }); + + it('sqlite config uses sqlite dialect', async () => { + const { generateDatabase } = await import('./database.js'); + await generateDatabase(tmpDir, sqliteOptions); + const content = await read('drizzle.config.ts'); + expect(content).toContain('sqlite'); + }); +}); + +describe('generateDatabase — docker-compose.yml', () => { + it('generates docker-compose.yml for postgres with healthcheck', async () => { + const { generateDatabase } = await import('./database.js'); + await generateDatabase(tmpDir, pgOptions); + expect(await exists(join(tmpDir, 'docker-compose.yml'))).toBe(true); + const content = await read('docker-compose.yml'); + expect(content).toContain('postgres:16'); + expect(content).toContain('pg_isready'); + expect(content).toContain('healthcheck'); + }); + + it('generates docker-compose.yml for mysql with healthcheck', async () => { + const { generateDatabase } = await import('./database.js'); + await generateDatabase(tmpDir, mysqlOptions); + const content = await read('docker-compose.yml'); + expect(content).toContain('mysql:8'); + expect(content).toContain('mysqladmin'); + expect(content).toContain('healthcheck'); + }); + + it('does not generate docker-compose.yml for sqlite', async () => { + const { generateDatabase } = await import('./database.js'); + await generateDatabase(tmpDir, sqliteOptions); + expect(await exists(join(tmpDir, 'docker-compose.yml'))).toBe(false); + }); }); diff --git a/src/generators/node/database.ts b/src/generators/node/database.ts index 9c6ee37..30211a6 100644 --- a/src/generators/node/database.ts +++ b/src/generators/node/database.ts @@ -1,7 +1,325 @@ +import dedent from 'dedent'; import { join } from 'path'; import { writeFile } from '../../utils/writeFile.js'; import type { GeneratorOptions } from '../interface.js'; -export async function generateDatabase(outputDir: string, _options: GeneratorOptions): Promise { - await writeFile(join(outputDir, 'src/database/index.ts'), `export {};\n`); +export async function generateDatabase(outputDir: string, options: GeneratorOptions): Promise { + await generateSchema(outputDir, options); + await generateDbClient(outputDir, options); + await generateMigrate(outputDir, options); + await generateMigrationSql(outputDir, options); + await generateMigrationJournal(outputDir, options); + await generateDrizzleConfig(outputDir, options); + if (options.database === 'postgres' || options.database === 'mysql') { + await generateDockerCompose(outputDir, options); + } +} + +async function generateSchema(outputDir: string, options: GeneratorOptions): Promise { + const content = schemaContent(options.database); + await writeFile(join(outputDir, 'src/database/schema.ts'), content); +} + +function schemaContent(database: GeneratorOptions['database']): string { + if (database === 'postgres') { + return dedent` + import { integer, pgTable, primaryKey, text, timestamp, varchar } from 'drizzle-orm/pg-core'; + + export const pipedriveTokens = pgTable( + 'pipedrive_tokens', + { + pipedriveCompanyId: integer('pipedrive_company_id').notNull(), + pipedriveUserId: integer('pipedrive_user_id').notNull(), + accessToken: varchar('access_token', { length: 768 }).notNull(), + refreshToken: varchar('refresh_token', { length: 768 }).notNull(), + tokenType: varchar('token_type', { length: 50 }).notNull().default('bearer'), + accessTokenExpiresAt: timestamp('access_token_expires_at').notNull(), + refreshTokenExpiresAt: timestamp('refresh_token_expires_at').notNull(), + scope: text('scope'), + apiDomain: varchar('api_domain', { length: 255 }).notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + pk: primaryKey({ columns: [table.pipedriveCompanyId, table.pipedriveUserId] }), + }), + ); + `; + } + + if (database === 'mysql') { + return dedent` + import { int, mysqlTable, primaryKey, text, timestamp, varchar } from 'drizzle-orm/mysql-core'; + + export const pipedriveTokens = mysqlTable( + 'pipedrive_tokens', + { + pipedriveCompanyId: int('pipedrive_company_id').notNull(), + pipedriveUserId: int('pipedrive_user_id').notNull(), + accessToken: varchar('access_token', { length: 768 }).notNull(), + refreshToken: varchar('refresh_token', { length: 768 }).notNull(), + tokenType: varchar('token_type', { length: 50 }).notNull().default('bearer'), + accessTokenExpiresAt: timestamp('access_token_expires_at').notNull(), + refreshTokenExpiresAt: timestamp('refresh_token_expires_at').notNull(), + scope: text('scope'), + apiDomain: varchar('api_domain', { length: 255 }).notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + pk: primaryKey({ columns: [table.pipedriveCompanyId, table.pipedriveUserId] }), + }), + ); + `; + } + + return dedent` + import { integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core'; + + export const pipedriveTokens = sqliteTable( + 'pipedrive_tokens', + { + pipedriveCompanyId: integer('pipedrive_company_id').notNull(), + pipedriveUserId: integer('pipedrive_user_id').notNull(), + accessToken: text('access_token').notNull(), + refreshToken: text('refresh_token').notNull(), + tokenType: text('token_type').notNull().default('bearer'), + accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp' }).notNull(), + refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp' }).notNull(), + scope: text('scope'), + apiDomain: text('api_domain').notNull(), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), + }, + (table) => ({ + pk: primaryKey({ columns: [table.pipedriveCompanyId, table.pipedriveUserId] }), + }), + ); + `; +} + +async function generateDbClient(outputDir: string, options: GeneratorOptions): Promise { + const content = dbClientContent(options.database); + await writeFile(join(outputDir, 'src/database/index.ts'), content); +} + +function dbClientContent(database: GeneratorOptions['database']): string { + if (database === 'postgres') { + return dedent` + import { drizzle } from 'drizzle-orm/postgres-js'; + import postgres from 'postgres'; + import * as schema from './schema.js'; + + const client = postgres(process.env.DATABASE_URL!); + export const db = drizzle(client, { schema }); + `; + } + + if (database === 'mysql') { + return dedent` + import { drizzle } from 'drizzle-orm/mysql2'; + import mysql from 'mysql2/promise'; + import * as schema from './schema.js'; + + const pool = mysql.createPool(process.env.DATABASE_URL!); + export const db = drizzle(pool, { schema }); + `; + } + + return dedent` + import { drizzle } from 'drizzle-orm/libsql'; + import { createClient } from '@libsql/client'; + import * as schema from './schema.js'; + + const client = createClient({ url: process.env.DATABASE_URL ?? 'file:./data.db' }); + export const db = drizzle(client, { schema }); + `; +} + +async function generateMigrate(outputDir: string, options: GeneratorOptions): Promise { + const content = migrateContent(options.database); + await writeFile(join(outputDir, 'src/database/migrate.ts'), content); +} + +function migrateContent(database: GeneratorOptions['database']): string { + if (database === 'postgres') { + return dedent` + import { migrate } from 'drizzle-orm/postgres-js/migrator'; + import { db } from './index.js'; + + export async function runMigrations(): Promise { + await migrate(db, { migrationsFolder: 'src/database/migrations' }); + } + `; + } + + if (database === 'mysql') { + return dedent` + import { migrate } from 'drizzle-orm/mysql2/migrator'; + import { db } from './index.js'; + + export async function runMigrations(): Promise { + await migrate(db, { migrationsFolder: 'src/database/migrations' }); + } + `; + } + + return dedent` + import { migrate } from 'drizzle-orm/libsql/migrator'; + import { db } from './index.js'; + + export async function runMigrations(): Promise { + await migrate(db, { migrationsFolder: 'src/database/migrations' }); + } + `; +} + +async function generateMigrationSql(outputDir: string, options: GeneratorOptions): Promise { + const content = migrationSqlContent(options.database); + await writeFile(join(outputDir, 'src/database/migrations/0000_init.sql'), content); +} + +function migrationSqlContent(database: GeneratorOptions['database']): string { + if (database === 'postgres') { + return dedent` + CREATE TABLE IF NOT EXISTS "pipedrive_tokens" ( + "pipedrive_company_id" INTEGER NOT NULL, + "pipedrive_user_id" INTEGER NOT NULL, + "access_token" VARCHAR(768) NOT NULL, + "refresh_token" VARCHAR(768) NOT NULL, + "token_type" VARCHAR(50) NOT NULL DEFAULT 'bearer', + "access_token_expires_at" TIMESTAMP NOT NULL, + "refresh_token_expires_at" TIMESTAMP NOT NULL, + "scope" TEXT, + "api_domain" VARCHAR(255) NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMP NOT NULL DEFAULT NOW(), + PRIMARY KEY ("pipedrive_company_id", "pipedrive_user_id") + ); + `; + } + + if (database === 'mysql') { + return dedent` + CREATE TABLE IF NOT EXISTS \`pipedrive_tokens\` ( + \`pipedrive_company_id\` INT NOT NULL, + \`pipedrive_user_id\` INT NOT NULL, + \`access_token\` VARCHAR(768) NOT NULL, + \`refresh_token\` VARCHAR(768) NOT NULL, + \`token_type\` VARCHAR(50) NOT NULL DEFAULT 'bearer', + \`access_token_expires_at\` TIMESTAMP NOT NULL, + \`refresh_token_expires_at\` TIMESTAMP NOT NULL, + \`scope\` TEXT, + \`api_domain\` VARCHAR(255) NOT NULL, + \`created_at\` TIMESTAMP NOT NULL DEFAULT NOW(), + \`updated_at\` TIMESTAMP NOT NULL DEFAULT NOW(), + PRIMARY KEY (\`pipedrive_company_id\`, \`pipedrive_user_id\`) + ); + `; + } + + return dedent` + CREATE TABLE IF NOT EXISTS "pipedrive_tokens" ( + "pipedrive_company_id" INTEGER NOT NULL, + "pipedrive_user_id" INTEGER NOT NULL, + "access_token" TEXT NOT NULL, + "refresh_token" TEXT NOT NULL, + "token_type" TEXT NOT NULL DEFAULT 'bearer', + "access_token_expires_at" INTEGER NOT NULL, + "refresh_token_expires_at" INTEGER NOT NULL, + "scope" TEXT, + "api_domain" TEXT NOT NULL, + "created_at" INTEGER NOT NULL DEFAULT (unixepoch()), + "updated_at" INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY ("pipedrive_company_id", "pipedrive_user_id") + ); + `; +} + +async function generateMigrationJournal(outputDir: string, options: GeneratorOptions): Promise { + const dialectMap: Record = { + postgres: 'postgresql', + mysql: 'mysql', + sqlite: 'sqlite', + }; + const journal = { + version: '6', + dialect: dialectMap[options.database], + entries: [{ idx: 0, version: '6', when: 0, tag: '0000_init', breakpoints: true }], + }; + await writeFile(join(outputDir, 'src/database/migrations/meta/_journal.json'), JSON.stringify(journal, null, 2)); +} + +async function generateDockerCompose(outputDir: string, options: GeneratorOptions): Promise { + const content = + options.database === 'postgres' + ? dedent` + services: + db: + image: postgres:16 + environment: + POSTGRES_USER: app + POSTGRES_PASSWORD: app + POSTGRES_DB: ${options.projectName} + ports: + - '5432:5432' + volumes: + - db_data:/var/lib/postgresql/data + healthcheck: + test: ['CMD', 'pg_isready', '-U', 'app'] + interval: 5s + timeout: 5s + retries: 5 + + volumes: + db_data: + ` + : dedent` + services: + db: + image: mysql:8 + environment: + MYSQL_ROOT_PASSWORD: app + MYSQL_DATABASE: ${options.projectName} + MYSQL_USER: app + MYSQL_PASSWORD: app + ports: + - '3306:3306' + volumes: + - db_data:/var/lib/mysql + healthcheck: + test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'app', '--password=app'] + interval: 5s + timeout: 5s + retries: 5 + + volumes: + db_data: + `; + await writeFile(join(outputDir, 'docker-compose.yml'), content); +} + +async function generateDrizzleConfig(outputDir: string, options: GeneratorOptions): Promise { + const dialectMap: Record = { + postgres: 'postgresql', + mysql: 'mysql', + sqlite: 'sqlite', + }; + const dialect = dialectMap[options.database]; + const url = + options.database === 'sqlite' ? `process.env.DATABASE_URL ?? 'file:./data.db'` : `process.env.DATABASE_URL!`; + + const content = dedent` + import { defineConfig } from 'drizzle-kit'; + + export default defineConfig({ + dialect: '${dialect}', + schema: './src/database/schema.ts', + out: './src/database/migrations', + dbCredentials: { + url: ${url}, + }, + }); + `; + await writeFile(join(outputDir, 'drizzle.config.ts'), content); } diff --git a/src/generators/node/index.test.ts b/src/generators/node/index.test.ts index 1ea9071..1e4a1e5 100644 --- a/src/generators/node/index.test.ts +++ b/src/generators/node/index.test.ts @@ -1,13 +1,17 @@ import { afterEach, describe, expect, it } from 'vitest'; import { access, rm } from 'node:fs/promises'; -import { join } from 'path'; -import { tmpdir } from 'os'; -import { execSync } from 'child_process'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execSync } from 'node:child_process'; import { nodeGenerator } from './index.js'; import type { GeneratorOptions } from '../interface.js'; const tmpDir = join(tmpdir(), 'cpa-e2e-test'); -const exists = (p: string) => access(p).then(() => true, () => false); +const exists = (p: string) => + access(p).then( + () => true, + () => false, + ); afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }); @@ -39,6 +43,7 @@ describe('nodeGenerator', () => { 'src/webhooks/index.ts', 'src/app-extensions/panel/index.ts', 'src/app-extensions/modal/index.ts', + 'src/pipedrive/client.ts', 'package.json', 'tsconfig.json', '.env.example', diff --git a/src/generators/node/index.ts b/src/generators/node/index.ts index 893881b..cb58f52 100644 --- a/src/generators/node/index.ts +++ b/src/generators/node/index.ts @@ -1,141 +1,19 @@ -import dedent from 'dedent'; -import { join } from 'path'; -import { writeFile } from '../../utils/writeFile.js'; import type { Generator, GeneratorOptions } from '../interface.js'; -import { generateApp } from './app.js'; -import { generateAppExtensions } from './appExtensions.js'; -import { generateDatabase } from './database.js'; -import { generateOauth } from './oauth.js'; -import { generateWebhooks } from './webhooks.js'; +import { NodeProjectBuilder } from './projectBuilder.js'; export const nodeGenerator: Generator = { async generate(outputDir: string, options: GeneratorOptions): Promise { - await generateOauth(outputDir, options); - await generateDatabase(outputDir, options); - await generateApp(outputDir, options); - - if (options.webhooks) { - await generateWebhooks(outputDir, options); - } - - if (options.appExtensions.length > 0) { - await generateAppExtensions(outputDir, options); - } - - await generateServerEntry(outputDir); - await generatePackageJson(outputDir, options); - await generateTsConfig(outputDir); - await generateEnvExample(outputDir); - - if (options.database === 'postgres' || options.database === 'mysql') { - await generateDockerCompose(outputDir, options); - } + await new NodeProjectBuilder(outputDir, options) + .addDatabase() + .addOAuth() + .addApp() + .when(options.webhooks, (b) => b.addWebhooks()) + .when(options.appExtensions.length > 0, (b) => b.addAppExtensions()) + .addPipedriveClient() + .addServerEntry() + .addPackageJson() + .addTsConfig() + .addEnvExample() + .build(); }, }; - -async function generateServerEntry(outputDir: string): Promise { - await writeFile( - join(outputDir, 'src/index.ts'), - dedent` - import app from './app.js'; - - const PORT = process.env.PORT ?? '3000'; - app.listen(PORT, () => { - console.log(\`Server running on port \${PORT}\`); - }); - `, - ); -} - -async function generatePackageJson(outputDir: string, options: GeneratorOptions): Promise { - const pkg = { - name: options.projectName, - version: '0.1.0', - type: 'module', - scripts: { - dev: 'tsx src/index.ts', - build: 'tsc', - typecheck: 'tsc --noEmit', - }, - dependencies: { - 'express': '^4.19.0', - 'drizzle-orm': '^0.30.0', - }, - devDependencies: { - 'typescript': '^5.4.0', - '@types/express': '^4.17.0', - '@types/node': '^20.0.0', - 'tsx': '^4.7.0', - }, - }; - await writeFile(join(outputDir, 'package.json'), JSON.stringify(pkg, null, 2)); -} - -async function generateTsConfig(outputDir: string): Promise { - const tsconfig = { - compilerOptions: { - target: 'ESNext', - module: 'ESNext', - moduleResolution: 'bundler', - outDir: 'dist', - rootDir: 'src', - strict: true, - esModuleInterop: true, - skipLibCheck: true, - }, - include: ['src'], - }; - await writeFile(join(outputDir, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2)); -} - -async function generateEnvExample(outputDir: string): Promise { - await writeFile( - join(outputDir, '.env.example'), - dedent` - PIPEDRIVE_CLIENT_ID= - PIPEDRIVE_CLIENT_SECRET= - PIPEDRIVE_REDIRECT_URI=http://localhost:3000/oauth/callback - DATABASE_URL= - PORT=3000 - `, - ); -} - -async function generateDockerCompose(outputDir: string, options: GeneratorOptions): Promise { - const isPostgres = options.database === 'postgres'; - const content = isPostgres - ? dedent` - services: - db: - image: postgres:16 - environment: - POSTGRES_USER: app - POSTGRES_PASSWORD: app - POSTGRES_DB: ${options.projectName} - ports: - - '5432:5432' - volumes: - - db_data:/var/lib/postgresql/data - - volumes: - db_data: - ` - : dedent` - services: - db: - image: mysql:8 - environment: - MYSQL_ROOT_PASSWORD: app - MYSQL_DATABASE: ${options.projectName} - MYSQL_USER: app - MYSQL_PASSWORD: app - ports: - - '3306:3306' - volumes: - - db_data:/var/lib/mysql - - volumes: - db_data: - `; - await writeFile(join(outputDir, 'docker-compose.yml'), content); -} diff --git a/src/generators/node/oauth.test.ts b/src/generators/node/oauth.test.ts index d2d1ee2..f4865aa 100644 --- a/src/generators/node/oauth.test.ts +++ b/src/generators/node/oauth.test.ts @@ -20,7 +20,12 @@ describe('generateOauth', () => { it('creates src/oauth/index.ts', async () => { const { generateOauth } = await import('./oauth.js'); await generateOauth(tmpDir, options); - expect(await access(join(tmpDir, 'src/oauth/index.ts')).then(() => true, () => false)).toBe(true); + expect( + await access(join(tmpDir, 'src/oauth/index.ts')).then( + () => true, + () => false, + ), + ).toBe(true); }); it('exports a default Express Router', async () => { diff --git a/src/generators/node/oauth.ts b/src/generators/node/oauth.ts index 5d069cd..4f5422c 100644 --- a/src/generators/node/oauth.ts +++ b/src/generators/node/oauth.ts @@ -1,14 +1,8 @@ -import dedent from 'dedent'; import { join } from 'path'; import { writeFile } from '../../utils/writeFile.js'; import type { GeneratorOptions } from '../interface.js'; +import { expressRouterFile } from '../../utils/templates.js'; export async function generateOauth(outputDir: string, _options: GeneratorOptions): Promise { - await writeFile( - join(outputDir, 'src/oauth/index.ts'), - dedent` - import { Router } from 'express'; - export default Router(); - `, - ); + await writeFile(join(outputDir, 'src/oauth/index.ts'), expressRouterFile()); } diff --git a/src/generators/node/pipedriveClient.test.ts b/src/generators/node/pipedriveClient.test.ts new file mode 100644 index 0000000..4dcf7d5 --- /dev/null +++ b/src/generators/node/pipedriveClient.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { access, readFile, rm } from 'node:fs/promises'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import type { GeneratorOptions } from '../interface.js'; + +const tmpDir = join(tmpdir(), 'cpa-pipedrive-client-test'); +const exists = (p: string) => + access(p).then( + () => true, + () => false, + ); +const options: GeneratorOptions = { + projectName: 'test-app', + database: 'postgres', + webhooks: false, + appExtensions: [], +}; + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +describe('generatePipedriveClient', () => { + it('creates src/pipedrive/client.ts', async () => { + const { generatePipedriveClient } = await import('./pipedriveClient.js'); + await generatePipedriveClient(tmpDir, options); + expect(await exists(join(tmpDir, 'src/pipedrive/client.ts'))).toBe(true); + }); + + it('exports getClient', async () => { + const { generatePipedriveClient } = await import('./pipedriveClient.js'); + await generatePipedriveClient(tmpDir, options); + const content = await readFile(join(tmpDir, 'src/pipedrive/client.ts'), 'utf-8'); + expect(content).toContain('export async function getClient'); + }); + + it('imports v2 and v1 namespaces from pipedrive', async () => { + const { generatePipedriveClient } = await import('./pipedriveClient.js'); + await generatePipedriveClient(tmpDir, options); + const content = await readFile(join(tmpDir, 'src/pipedrive/client.ts'), 'utf-8'); + expect(content).toContain("from 'pipedrive/v2'"); + expect(content).toContain("from 'pipedrive/v1'"); + expect(content).toContain('v2.DealsApi'); + expect(content).toContain('v2.PersonsApi'); + expect(content).toContain('v2.OrganizationsApi'); + expect(content).toContain('v1.NotesApi'); + }); + + it('contains getStoredToken and saveToken placeholder functions', async () => { + const { generatePipedriveClient } = await import('./pipedriveClient.js'); + await generatePipedriveClient(tmpDir, options); + const content = await readFile(join(tmpDir, 'src/pipedrive/client.ts'), 'utf-8'); + expect(content).toContain('getStoredToken'); + expect(content).toContain('saveToken'); + }); + + it('uses OAuth2Configuration with updateToken and onTokenUpdate', async () => { + const { generatePipedriveClient } = await import('./pipedriveClient.js'); + await generatePipedriveClient(tmpDir, options); + const content = await readFile(join(tmpDir, 'src/pipedrive/client.ts'), 'utf-8'); + expect(content).toContain('oauth2.updateToken'); + expect(content).toContain('oauth2.onTokenUpdate'); + expect(content).toContain('oauth2.getAccessToken'); + }); +}); diff --git a/src/generators/node/pipedriveClient.ts b/src/generators/node/pipedriveClient.ts new file mode 100644 index 0000000..bdb4c11 --- /dev/null +++ b/src/generators/node/pipedriveClient.ts @@ -0,0 +1,46 @@ +import dedent from 'dedent'; +import { join } from 'path'; +import { writeFile } from '../../utils/writeFile.js'; +import type { GeneratorOptions } from '../interface.js'; + +export async function generatePipedriveClient(outputDir: string, _options: GeneratorOptions): Promise { + await writeFile( + join(outputDir, 'src/pipedrive/client.ts'), + dedent` + import * as v2 from 'pipedrive/v2'; + import * as v1 from 'pipedrive/v1'; + + const oauth2 = new v2.OAuth2Configuration({ + clientId: process.env.PIPEDRIVE_CLIENT_ID ?? '', + clientSecret: process.env.PIPEDRIVE_CLIENT_SECRET ?? '', + redirectUri: process.env.PIPEDRIVE_REDIRECT_URI ?? '', + }); + + // TODO: replace with database module call + async function getStoredToken(_companyId: number): Promise { + throw new Error('getStoredToken not implemented — wire up database module'); + } + + // TODO: replace with database module call + async function saveToken(_companyId: number, _token: v2.TokenResponse): Promise { + throw new Error('saveToken not implemented — wire up database module'); + } + + export async function getClient(companyId: number) { + const storedToken = await getStoredToken(companyId); + oauth2.updateToken(storedToken); + oauth2.onTokenUpdate = (token) => saveToken(companyId, token); + + const accessToken = oauth2.getAccessToken; + const basePath = oauth2.basePath; + + return { + deals: new v2.DealsApi(new v2.Configuration({ accessToken, basePath })), + persons: new v2.PersonsApi(new v2.Configuration({ accessToken, basePath })), + organizations: new v2.OrganizationsApi(new v2.Configuration({ accessToken, basePath })), + notes: new v1.NotesApi(new v1.Configuration({ accessToken, basePath })), + }; + } + `, + ); +} diff --git a/src/generators/node/projectBuilder.test.ts b/src/generators/node/projectBuilder.test.ts new file mode 100644 index 0000000..49774e4 --- /dev/null +++ b/src/generators/node/projectBuilder.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import type { GeneratorOptions } from '../interface.js'; +import { NodeProjectBuilder } from './projectBuilder.js'; +import type { BuildStep } from './projectBuilder.js'; + +const options: GeneratorOptions = { + projectName: 'test-app', + database: 'postgres', + webhooks: false, + appExtensions: [], +}; + +function spyStep(tracker: string[], label: string): BuildStep { + return { + execute: async () => { + tracker.push(label); + }, + }; +} + +describe('NodeProjectBuilder', () => { + it('when(true) executes the added step', async () => { + const executed: string[] = []; + await new NodeProjectBuilder('/tmp', options) + .when(true, (b) => b.addStep(spyStep(executed, 'webhooks'))) + .build(); + expect(executed).toContain('webhooks'); + }); + + it('when(false) skips the step', async () => { + const executed: string[] = []; + await new NodeProjectBuilder('/tmp', options) + .when(false, (b) => b.addStep(spyStep(executed, 'webhooks'))) + .build(); + expect(executed).toHaveLength(0); + }); + + it('executes steps in insertion order', async () => { + const order: string[] = []; + await new NodeProjectBuilder('/tmp', options) + .addStep(spyStep(order, 'first')) + .addStep(spyStep(order, 'second')) + .addStep(spyStep(order, 'third')) + .build(); + expect(order).toEqual(['first', 'second', 'third']); + }); + + it('addStep and when return the builder instance for chaining', () => { + const builder = new NodeProjectBuilder('/tmp', options); + expect(builder.addStep(spyStep([], 'x'))).toBe(builder); + expect(builder.when(false, () => {})).toBe(builder); + }); +}); diff --git a/src/generators/node/projectBuilder.ts b/src/generators/node/projectBuilder.ts new file mode 100644 index 0000000..6ba8ef0 --- /dev/null +++ b/src/generators/node/projectBuilder.ts @@ -0,0 +1,203 @@ +import dedent from 'dedent'; +import { join } from 'path'; +import { writeFile } from '../../utils/writeFile.js'; +import type { GeneratorOptions } from '../interface.js'; +import { generateApp } from './app.js'; +import { generateAppExtensions } from './appExtensions.js'; +import { generateDatabase } from './database.js'; +import { generateOauth } from './oauth.js'; +import { generatePipedriveClient } from './pipedriveClient.js'; +import { generateWebhooks } from './webhooks.js'; +import { envVarAccess } from '../../utils/templates.js'; + +export interface BuildStep { + execute(outputDir: string, options: GeneratorOptions): Promise; +} + +class OAuthStep implements BuildStep { + async execute(outputDir: string, options: GeneratorOptions): Promise { + await generateOauth(outputDir, options); + } +} + +class DatabaseStep implements BuildStep { + async execute(outputDir: string, options: GeneratorOptions): Promise { + await generateDatabase(outputDir, options); + } +} + +class AppStep implements BuildStep { + async execute(outputDir: string, options: GeneratorOptions): Promise { + await generateApp(outputDir, options); + } +} + +class WebhooksStep implements BuildStep { + async execute(outputDir: string, options: GeneratorOptions): Promise { + await generateWebhooks(outputDir, options); + } +} + +class AppExtensionsStep implements BuildStep { + async execute(outputDir: string, options: GeneratorOptions): Promise { + await generateAppExtensions(outputDir, options); + } +} + +class PipedriveClientStep implements BuildStep { + async execute(outputDir: string, options: GeneratorOptions): Promise { + await generatePipedriveClient(outputDir, options); + } +} + +class ServerEntryStep implements BuildStep { + async execute(outputDir: string, _options: GeneratorOptions): Promise { + await writeFile( + join(outputDir, 'src/index.ts'), + dedent` + import { runMigrations } from './database/migrate.js'; + import app from './app.js'; + + const PORT = ${envVarAccess('PORT', '3000')}; + + await runMigrations(); + app.listen(PORT, () => { + console.log(\`Server running on port \${PORT}\`); + }); + `, + ); + } +} + +class PackageJsonStep implements BuildStep { + async execute(outputDir: string, options: GeneratorOptions): Promise { + const dbDrivers: Record> = { + postgres: { postgres: '^3.4.0' }, + mysql: { mysql2: '^3.9.0' }, + sqlite: { '@libsql/client': '^0.14.0' }, + }; + + const dbDevDrivers: Record> = { + postgres: {}, + mysql: {}, + sqlite: {}, + }; + + const pkg = { + name: options.projectName, + version: '0.1.0', + type: 'module', + scripts: { + 'dev': 'tsx src/index.ts', + 'build': 'tsc', + 'typecheck': 'tsc --noEmit', + 'db:migrate': 'drizzle-kit migrate', + }, + dependencies: { + 'express': '^4.19.0', + 'drizzle-orm': '^0.30.0', + 'pipedrive': '^32.0.0', + ...dbDrivers[options.database], + }, + devDependencies: { + 'typescript': '^5.4.0', + '@types/express': '^4.17.0', + '@types/node': '^20.0.0', + 'tsx': '^4.7.0', + 'drizzle-kit': '^0.21.0', + ...dbDevDrivers[options.database], + }, + }; + await writeFile(join(outputDir, 'package.json'), JSON.stringify(pkg, null, 2)); + } +} + +class TsConfigStep implements BuildStep { + async execute(outputDir: string, _options: GeneratorOptions): Promise { + const tsconfig = { + compilerOptions: { + target: 'ESNext', + module: 'ESNext', + moduleResolution: 'bundler', + outDir: 'dist', + rootDir: 'src', + strict: true, + esModuleInterop: true, + skipLibCheck: true, + }, + include: ['src'], + }; + await writeFile(join(outputDir, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2)); + } +} + +class EnvExampleStep implements BuildStep { + async execute(outputDir: string, _options: GeneratorOptions): Promise { + await writeFile( + join(outputDir, '.env.example'), + dedent` + PIPEDRIVE_CLIENT_ID= + PIPEDRIVE_CLIENT_SECRET= + PIPEDRIVE_REDIRECT_URI=http://localhost:3000/oauth/callback + DATABASE_URL= + PORT=3000 + `, + ); + } +} + +export class NodeProjectBuilder { + private steps: BuildStep[] = []; + + constructor( + private outputDir: string, + private options: GeneratorOptions, + ) {} + + addStep(step: BuildStep): this { + this.steps.push(step); + return this; + } + + addOAuth(): this { + return this.addStep(new OAuthStep()); + } + addDatabase(): this { + return this.addStep(new DatabaseStep()); + } + addApp(): this { + return this.addStep(new AppStep()); + } + addWebhooks(): this { + return this.addStep(new WebhooksStep()); + } + addAppExtensions(): this { + return this.addStep(new AppExtensionsStep()); + } + addPipedriveClient(): this { + return this.addStep(new PipedriveClientStep()); + } + addServerEntry(): this { + return this.addStep(new ServerEntryStep()); + } + addPackageJson(): this { + return this.addStep(new PackageJsonStep()); + } + addTsConfig(): this { + return this.addStep(new TsConfigStep()); + } + addEnvExample(): this { + return this.addStep(new EnvExampleStep()); + } + + when(condition: boolean, fn: (b: this) => void): this { + if (condition) fn(this); + return this; + } + + async build(): Promise { + for (const step of this.steps) { + await step.execute(this.outputDir, this.options); + } + } +} diff --git a/src/generators/node/webhooks.test.ts b/src/generators/node/webhooks.test.ts index 1b4e178..54008d7 100644 --- a/src/generators/node/webhooks.test.ts +++ b/src/generators/node/webhooks.test.ts @@ -5,7 +5,11 @@ import { tmpdir } from 'os'; import type { GeneratorOptions } from '../interface.js'; const tmpDir = join(tmpdir(), 'cpa-webhooks-test'); -const exists = (p: string) => access(p).then(() => true, () => false); +const exists = (p: string) => + access(p).then( + () => true, + () => false, + ); afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }); diff --git a/src/utils/sourceFileBuilder.test.ts b/src/utils/sourceFileBuilder.test.ts new file mode 100644 index 0000000..c309abb --- /dev/null +++ b/src/utils/sourceFileBuilder.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { SourceFileBuilder } from './sourceFileBuilder.js'; + +describe('SourceFileBuilder', () => { + it('emits a named import', () => { + const out = new SourceFileBuilder().import('express', ['Router']).build(); + expect(out).toContain("import { Router } from 'express';"); + }); + + it('emits a default import', () => { + const out = new SourceFileBuilder().importDefault('./app.js', 'app').build(); + expect(out).toContain("import app from './app.js';"); + }); + + it('deduplicates named imports from the same source', () => { + const out = new SourceFileBuilder() + .import('express', ['Router']) + .import('express', ['Router', 'Request']) + .build(); + expect((out.match(/from 'express'/g) ?? []).length).toBe(1); + expect(out).toContain('Router'); + expect(out).toContain('Request'); + }); + + it('merges default and named imports from the same source into one line', () => { + const out = new SourceFileBuilder().importDefault('express', 'express').import('express', ['Router']).build(); + expect((out.match(/from 'express'/g) ?? []).length).toBe(1); + expect(out).toContain('express'); + expect(out).toContain('Router'); + }); + + it('importIf skips when condition is false', () => { + const out = new SourceFileBuilder().importIf(false, 'express', ['Router']).build(); + expect(out).not.toContain('express'); + }); + + it('importIf adds import when condition is true', () => { + const out = new SourceFileBuilder().importIf(true, 'express', ['Router']).build(); + expect(out).toContain("import { Router } from 'express';"); + }); + + it('importDefaultIf skips when condition is false', () => { + const out = new SourceFileBuilder().importDefaultIf(false, './webhooks.js', 'webhooksRouter').build(); + expect(out).not.toContain('webhooks'); + }); + + it('importDefaultIf adds import when condition is true', () => { + const out = new SourceFileBuilder().importDefaultIf(true, './app.js', 'app').build(); + expect(out).toContain("import app from './app.js';"); + }); + + it('addBlock adds body content', () => { + const out = new SourceFileBuilder().addBlock('const x = 1;').build(); + expect(out).toContain('const x = 1;'); + }); + + it('addBlockIf skips when condition is false', () => { + const out = new SourceFileBuilder().addBlockIf(false, 'const x = 1;').build(); + expect(out).not.toContain('const x'); + }); + + it('addBlockIf adds block when condition is true', () => { + const out = new SourceFileBuilder().addBlockIf(true, 'const x = 1;').build(); + expect(out).toContain('const x = 1;'); + }); + + it('exportDefault appends export statement', () => { + const out = new SourceFileBuilder().addBlock('const app = {};').exportDefault('app').build(); + expect(out).toContain('export default app;'); + }); + + it('exportDefault throws if called twice', () => { + expect(() => new SourceFileBuilder().exportDefault('a').exportDefault('b')).toThrow( + 'exportDefault called more than once', + ); + }); + + it('importDefault throws if called twice with different names for same source', () => { + expect(() => + new SourceFileBuilder().importDefault('./app.js', 'app').importDefault('./app.js', 'app2'), + ).toThrow("importDefault called twice for './app.js'"); + }); + + it('build output order: imports → body → export default', () => { + const out = new SourceFileBuilder() + .importDefault('express', 'express') + .addBlock('const app = express();') + .exportDefault('app') + .build(); + const importPos = out.indexOf('import express'); + const bodyPos = out.indexOf('const app'); + const exportPos = out.indexOf('export default'); + expect(importPos).toBeLessThan(bodyPos); + expect(bodyPos).toBeLessThan(exportPos); + }); +}); diff --git a/src/utils/sourceFileBuilder.ts b/src/utils/sourceFileBuilder.ts new file mode 100644 index 0000000..b7f4e15 --- /dev/null +++ b/src/utils/sourceFileBuilder.ts @@ -0,0 +1,79 @@ +interface ImportEntry { + defaultName?: string; + names: string[]; +} + +export class SourceFileBuilder { + private imports: Map = new Map(); + private blocks: string[] = []; + private defaultExport?: string; + + import(from: string, names: string[]): this { + const existing = this.imports.get(from); + if (existing) { + existing.names = [...new Set([...existing.names, ...names])]; + } else { + this.imports.set(from, { names }); + } + return this; + } + + importDefault(from: string, name: string): this { + const existing = this.imports.get(from); + if (existing) { + if (existing.defaultName !== undefined && existing.defaultName !== name) { + throw new Error(`importDefault called twice for '${from}'`); + } + existing.defaultName = name; + } else { + this.imports.set(from, { defaultName: name, names: [] }); + } + return this; + } + + importIf(condition: boolean, from: string, names: string[]): this { + if (condition) this.import(from, names); + return this; + } + + importDefaultIf(condition: boolean, from: string, name: string): this { + if (condition) this.importDefault(from, name); + return this; + } + + addBlock(code: string): this { + this.blocks.push(code); + return this; + } + + addBlockIf(condition: boolean, code: string): this { + if (condition) this.addBlock(code); + return this; + } + + exportDefault(name: string): this { + if (this.defaultExport !== undefined) { + throw new Error('exportDefault called more than once'); + } + this.defaultExport = name; + return this; + } + + build(): string { + const importLines = Array.from(this.imports.entries()).map(([from, entry]) => { + const parts: string[] = []; + if (entry.defaultName) parts.push(entry.defaultName); + if (entry.names.length > 0) parts.push(`{ ${[...entry.names].sort().join(', ')} }`); + return `import ${parts.join(', ')} from '${from}';`; + }); + + const sections: string[] = []; + if (importLines.length > 0) sections.push(importLines.join('\n')); + sections.push(...this.blocks); + if (this.defaultExport !== undefined) { + sections.push(`export default ${this.defaultExport};`); + } + + return sections.join('\n\n'); + } +} diff --git a/src/utils/templates.test.ts b/src/utils/templates.test.ts new file mode 100644 index 0000000..5e9efdd --- /dev/null +++ b/src/utils/templates.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { expressRouterFile, routerMount, envVarAccess, RouterMountBuilder } from './templates.js'; + +describe('expressRouterFile', () => { + it('returns an express Router import and default export', () => { + const out = expressRouterFile(); + expect(out).toContain("from 'express'"); + expect(out).toContain('Router()'); + expect(out).toContain('export default'); + }); +}); + +describe('routerMount', () => { + it('returns an app.use() call with the given path and router name', () => { + expect(routerMount('/oauth', 'oauthRouter')).toBe("app.use('/oauth', oauthRouter);"); + }); +}); + +describe('envVarAccess', () => { + it('returns process.env.KEY without fallback', () => { + expect(envVarAccess('PORT')).toBe('process.env.PORT'); + }); + + it('returns process.env.KEY ?? fallback with fallback', () => { + expect(envVarAccess('PORT', '3000')).toBe("process.env.PORT ?? '3000'"); + }); +}); + +describe('RouterMountBuilder', () => { + it('builds mount statements in insertion order', () => { + const out = new RouterMountBuilder().add('/oauth', 'oauthRouter').add('/webhooks', 'webhooksRouter').build(); + expect(out).toBe("app.use('/oauth', oauthRouter);\napp.use('/webhooks', webhooksRouter);"); + }); + + it('addIf(true) includes the mount', () => { + const out = new RouterMountBuilder().addIf(true, '/webhooks', 'webhooksRouter').build(); + expect(out).toContain("app.use('/webhooks', webhooksRouter);"); + }); + + it('addIf(false) excludes the mount', () => { + const out = new RouterMountBuilder().addIf(false, '/webhooks', 'webhooksRouter').build(); + expect(out).toBe(''); + }); +}); diff --git a/src/utils/templates.ts b/src/utils/templates.ts new file mode 100644 index 0000000..2800a0a --- /dev/null +++ b/src/utils/templates.ts @@ -0,0 +1,29 @@ +export function expressRouterFile(): string { + return `import { Router } from 'express';\n\nexport default Router();`; +} + +export function routerMount(path: string, routerName: string): string { + return `app.use('${path}', ${routerName});`; +} + +export function envVarAccess(key: string, fallback?: string): string { + return fallback ? `process.env.${key} ?? '${fallback}'` : `process.env.${key}`; +} + +export class RouterMountBuilder { + private mounts: string[] = []; + + add(path: string, routerName: string): this { + this.mounts.push(routerMount(path, routerName)); + return this; + } + + addIf(condition: boolean, path: string, routerName: string): this { + if (condition) this.mounts.push(routerMount(path, routerName)); + return this; + } + + build(): string { + return this.mounts.join('\n'); + } +} diff --git a/src/utils/writeFile.test.ts b/src/utils/writeFile.test.ts index 93c53cb..b55f0a4 100644 --- a/src/utils/writeFile.test.ts +++ b/src/utils/writeFile.test.ts @@ -14,7 +14,12 @@ describe('writeFile', () => { const { writeFile } = await import('./writeFile.js'); const filePath = join(tmpDir, 'nested/dir/file.ts'); await writeFile(filePath, 'export const x = 1;'); - expect(await access(filePath).then(() => true, () => false)).toBe(true); + expect( + await access(filePath).then( + () => true, + () => false, + ), + ).toBe(true); }); it('formats TypeScript content with prettier', async () => {