From 10fff082e9304159dac566185123cbbe12289975 Mon Sep 17 00:00:00 2001 From: Youssef Date: Mon, 11 May 2026 13:03:21 +0300 Subject: [PATCH 01/38] AINATIVEM-44 update CLAUDE.md and README with commands, architecture, and usage docs --- CLAUDE.md | 44 ++++++++++++++++++++++++++++++++++++-------- README.md | 52 +++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b60b25c..455546f 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 +npx tsx src/cli.ts app # generate test project in app/ (gitignored via *-app/) +``` + ## 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,12 @@ 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 + ## MVP Scope The initial implementation targets: @@ -49,7 +81,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 +96,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) From 0d936af23a07d05a02f692cccb14e9f8329241a5 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 11 May 2026 13:37:34 +0300 Subject: [PATCH 02/38] Add database layer design spec for AINATIVEM-43 --- .../specs/2026-05-11-database-layer-design.md | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 docs/specs/2026-05-11-database-layer-design.md diff --git a/docs/specs/2026-05-11-database-layer-design.md b/docs/specs/2026-05-11-database-layer-design.md new file mode 100644 index 0000000..587f04c --- /dev/null +++ b/docs/specs/2026-05-11-database-layer-design.md @@ -0,0 +1,104 @@ +# Database Layer Generator — Design Spec + +**Jira:** AINATIVEM-43 +**Date:** 2026-05-11 +**Status:** Approved + +## Overview + +Flesh out `src/generators/node/database.ts` from its current stub into a full database layer generator. The generator produces a working Drizzle ORM setup for the scaffolded app, supporting Postgres, MySQL, and SQLite. Migrations run automatically on server startup (idempotent via Drizzle's migration table) and can also be triggered manually via `npm run db:migrate`. + +## Generated File Structure + +For all database choices: + +``` +src/database/ + index.ts # Drizzle client + driver setup + schema.ts # Drizzle table definitions (pipedrive_tokens) + migrate.ts # runMigrations() — programmatic Drizzle migrate API + migrations/ + 0000_init.sql # Pre-generated initial SQL (hardcoded in generator) +drizzle.config.ts # Drizzle kit config (for npm run db:migrate + drizzle-kit tooling) +``` + +For Postgres and MySQL only (not SQLite): + +``` +docker-compose.yml # Moved from index.ts generator, gains healthchecks +``` + +## Schema + +Table name: `pipedrive_tokens` + +| Column | Type | Notes | +|---|---|---| +| `id` | serial PK | | +| `company_id` | integer UNIQUE NOT NULL | from Pipedrive `/users/me` | +| `access_token` | varchar(768) NOT NULL | Pipedrive recommends 768 min | +| `refresh_token` | varchar(768) NOT NULL | | +| `token_type` | varchar(50) NOT NULL DEFAULT 'bearer' | | +| `access_token_expires_at` | timestamp NOT NULL | derived from `expires_in` | +| `refresh_token_expires_at` | timestamp NOT NULL | 60 days from last refresh | +| `scope` | text | nullable | +| `api_domain` | varchar(255) NOT NULL | company-specific Pipedrive API base URL | +| `created_at` | timestamp NOT NULL DEFAULT now() | | +| `updated_at` | timestamp NOT NULL DEFAULT now() | | + +### Installation status + +No separate installations table. A company is considered "installed" if a row exists in `pipedrive_tokens` with a non-expired `refresh_token_expires_at`. If the refresh token is absent or expired, the app is not installed. + +## Driver Mapping + +| Database | Driver | Drizzle adapter | +|---|---|---| +| Postgres | `postgres-js` | `drizzle-orm/postgres-js` | +| MySQL | `mysql2` | `drizzle-orm/mysql2` | +| SQLite | `better-sqlite3` | `drizzle-orm/better-sqlite3` | + +`src/database/index.ts` branches on the chosen database to import the correct driver and initialise the Drizzle client. All three use `DATABASE_URL` from env (SQLite treats it as the local file path, e.g. `./data.db`). + +## Migration Flow + +### On startup (primary path) +`src/index.ts` calls `await runMigrations()` before `app.listen()`. `runMigrations()` is exported from `src/database/migrate.ts` and calls Drizzle's programmatic `migrate(db, { migrationsFolder: 'src/database/migrations' })`. Drizzle tracks applied migrations in `__drizzle_migrations`, making repeated calls safe. + +### Standalone / CI (secondary path) +`package.json` includes `"db:migrate": "drizzle-kit migrate"` for deployment pipelines or manual runs. + +### Pre-generated migration +`0000_init.sql` is a hardcoded string inside the generator — developers get a working migration immediately without running `drizzle-kit generate`. + +## Generator Changes + +### `src/generators/node/database.ts` (primary work) +Generates all files above. Branches on `options.database` to produce the correct `src/database/index.ts` driver setup, the correct SQL dialect in `0000_init.sql`, and `docker-compose.yml` (Postgres/MySQL only). Docker Compose gains healthchecks: +- Postgres: `pg_isready -U app` +- MySQL: `mysqladmin ping -h localhost -u app --password=app` + +### `src/generators/node/index.ts` (minor changes) +- Remove `generateDockerCompose` function (moved to database module) +- `generateServerEntry` gains `import { runMigrations } from './database/migrate.js'` and `await runMigrations()` before the listen call +- `generatePackageJson` adds: correct DB driver in `dependencies`, `drizzle-kit` in `devDependencies`, `db:migrate` script + +### `src/generators/node/database.test.ts` (updated) +Tests per database choice: +- `src/database/index.ts` exists and exports a Drizzle client +- `src/database/schema.ts` exists and references `pipedrive_tokens` +- `src/database/migrate.ts` exists and exports `runMigrations` +- `src/database/migrations/0000_init.sql` exists and contains `CREATE TABLE` +- `drizzle.config.ts` exists +- `docker-compose.yml` is generated for postgres and mysql, not for sqlite +- `docker-compose.yml` contains a healthcheck for postgres and mysql + +## Acceptance Criteria + +- Generated project connects to the chosen database via the correct Drizzle driver +- `runMigrations()` is called in `src/index.ts` before the server starts listening +- `npm run db:migrate` runs `drizzle-kit migrate` as a standalone command +- SQLite uses a local file (`./data.db`), no Docker required +- Postgres and MySQL include `docker-compose.yml` with healthchecks +- `pipedrive_tokens` table is created by the initial migration +- Installation status is determined by the presence of a non-expired `refresh_token_expires_at` row — no separate installations table From 2f5d60aba322724686058e0432ca6ecdae71702f Mon Sep 17 00:00:00 2001 From: Youssef Date: Mon, 11 May 2026 13:38:58 +0300 Subject: [PATCH 03/38] AINATIVEM-44 add design spec for pipedrive client and builder pattern refactor --- ...6-05-11-pipedrive-client-builder-design.md | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 docs/specs/2026-05-11-pipedrive-client-builder-design.md diff --git a/docs/specs/2026-05-11-pipedrive-client-builder-design.md b/docs/specs/2026-05-11-pipedrive-client-builder-design.md new file mode 100644 index 0000000..a18c0ae --- /dev/null +++ b/docs/specs/2026-05-11-pipedrive-client-builder-design.md @@ -0,0 +1,220 @@ +# Pipedrive Client + Builder Pattern Refactor + +**Date:** 2026-05-11 +**Jira:** AINATIVEM-44 +**Branch:** AINATIVEM-44 + +## Summary + +Two changes in one task: + +1. **Refactor the Node.js generator to use a `NodeProjectBuilder` + `BuildStep` pattern** — replaces the imperative function calls in `nodeGenerator/index.ts` with a fluent, extensible builder. +2. **Add a `PipedriveClientStep`** — generates `src/pipedrive/client.ts` in the scaffolded project, wrapping the official `pipedrive` npm SDK with proactive token refresh (Option B). + +--- + +## New Files + +| Path | Purpose | +|------|---------| +| `src/utils/sourceFileBuilder.ts` | Fluent builder for TypeScript file content | +| `src/utils/templates.ts` | Pure helper functions for recurring code snippets | +| `src/generators/node/projectBuilder.ts` | `NodeProjectBuilder` + `BuildStep` interface + all step classes | +| `src/generators/node/pipedriveClient.ts` | `PipedriveClientStep` generator | +| `src/utils/sourceFileBuilder.test.ts` | Unit tests for `SourceFileBuilder` | +| `src/generators/node/projectBuilder.test.ts` | Tests for builder and `when()` | +| `src/generators/node/pipedriveClient.test.ts` | Tests for generated client file | + +## Modified Files + +| Path | Change | +|------|--------| +| `src/generators/node/index.ts` | Replace imperative calls with `NodeProjectBuilder` chain | +| `src/generators/node/app.ts` | Refactor to use `SourceFileBuilder` | +| `src/generators/node/oauth.ts` | Refactor to use `SourceFileBuilder` | + +--- + +## Architecture + +### `SourceFileBuilder` (`src/utils/sourceFileBuilder.ts`) + +Constructs TypeScript file content declaratively. Eliminates conditional blank lines from string interpolation. + +```ts +interface ImportEntry { from: string; names: string[]; isDefault: boolean; } + +class SourceFileBuilder { + import(from: string, names: string[]): this + importDefault(from: string, name: string): this + importIf(condition: boolean, from: string, names: string[]): this + importDefaultIf(condition: boolean, from: string, name: string): this + addBlock(code: string): this + addBlockIf(condition: boolean, code: string): this + exportDefault(name: string): this + build(): string // imports (grouped) → blank line → body blocks → export default +} +``` + +Imports are deduplicated. Named and default imports from the same source are merged. `build()` emits a string ready to pass to `writeFile()`. + +### `templates.ts` (`src/utils/templates.ts`) + +Pure functions for code snippets shared across generators. No side effects. + +```ts +export function expressRouterFile(): string +// → "import { Router } from 'express';\nexport default Router();" + +export function routerMount(path: string, routerName: string): string +// → "app.use('${path}', ${routerName});" + +export function envVarAccess(key: string, fallback?: string): string +// → "process.env.KEY" or "process.env.KEY ?? 'fallback'" +``` + +### `BuildStep` + `NodeProjectBuilder` (`src/generators/node/projectBuilder.ts`) + +```ts +interface BuildStep { + execute(outputDir: string, options: GeneratorOptions): Promise; +} +``` + +Each feature is a `BuildStep` class. The builder accumulates steps and `build()` executes them in sequence: + +```ts +class NodeProjectBuilder { + constructor(outputDir: string, options: GeneratorOptions) + + // Always-add methods (unconditional): + addOAuth(): this + addDatabase(): this + addApp(): this + addPipedriveClient(): this + addServerEntry(): this + addPackageJson(): this + addTsConfig(): this + addEnvExample(): this + + // Feature methods (called conditionally by orchestrator via when()): + addWebhooks(): this + addPostgres(): this + addMySQL(): this + addAppExtensions(): this + + // Conditional combinator: + when(condition: boolean, fn: (b: this) => void): this + + async build(): Promise +} +``` + +Orchestrator usage in `index.ts`: + +```ts +export const nodeGenerator: Generator = { + async generate(outputDir, options) { + await new NodeProjectBuilder(outputDir, options) + .addOAuth() + .addDatabase() + .addApp() + .when(options.webhooks, b => b.addWebhooks()) + .when(options.database === 'postgres', b => b.addPostgres()) + .when(options.database === 'mysql', b => b.addMySQL()) + .when(options.appExtensions.length > 0, b => b.addAppExtensions()) + .addPipedriveClient() + .addServerEntry() + .addPackageJson() + .addTsConfig() + .addEnvExample() + .build(); + } +}; +``` + +Adding a new feature = write a `BuildStep` class + one `.when(...)` line in the chain. + +--- + +## Pipedrive Client Generator + +### Generated file: `src/pipedrive/client.ts` + +Uses Option B: proactive expiry check before every call — no retry needed. + +```ts +import { Configuration, DealsApi, PersonsApi, OrganizationsApi } from 'pipedrive'; + +interface TokenRecord { + accessToken: string; + expiresAt: Date; +} + +// 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 oauth module call +async function refreshStoredToken(_companyId: number): Promise { + throw new Error('refreshStoredToken not implemented — wire up oauth module'); +} + +async function getValidToken(companyId: number): Promise { + let token = await getStoredToken(companyId); + if (token.expiresAt <= new Date()) { + token = await refreshStoredToken(companyId); + } + return token.accessToken; +} + +export async function getClient(companyId: number) { + const accessToken = await getValidToken(companyId); + const config = new Configuration({ accessToken }); + return { + deals: new DealsApi(config), + persons: new PersonsApi(config), + organizations: new OrganizationsApi(config), + }; +} +``` + +**Why placeholder functions instead of imports:** `database/index.ts` and `oauth/index.ts` are stubs being implemented separately (parallel task). The client must compile immediately. Placeholder functions with clear `// TODO` messages give the other developer a typed interface to implement against. + +**Why proactive expiry check:** The token record includes `expiresAt`. Checking before the call avoids a round-trip to the Pipedrive API for an expired token. Clock skew is acceptable for a scaffold — production apps can tighten the margin. + +### Generated `package.json` additions + +`pipedrive` is added to `dependencies` in `generatePackageJson` (inside `PackageJsonStep`). + +--- + +## Error Handling + +- Each `BuildStep.execute()` propagates errors — `cli.ts` catches and formats them with `clack.log.error` +- `SourceFileBuilder` throws if `.exportDefault()` is called more than once +- Placeholder functions throw `Error('not implemented')` — visible immediately at runtime, safe at compile time +- No silent failures: if any step throws, `build()` stops + +--- + +## Testing + +### `sourceFileBuilder.test.ts` +- Import deduplication (same source, multiple calls) +- Named + default imports from same source are merged +- `addBlockIf(false, ...)` adds nothing +- `build()` output: imports first, blank line, body, export default +- `exportDefault` called twice throws + +### `projectBuilder.test.ts` +- `when(true, ...)` adds the step; `when(false, ...)` does not +- Steps execute in insertion order +- Builder is chainable (returns `this`) + +### `pipedriveClient.test.ts` +- `src/pipedrive/client.ts` is created +- File exports `getClient` +- File imports `Configuration`, `DealsApi`, `PersonsApi`, `OrganizationsApi` from `'pipedrive'` +- File contains `getStoredToken` and `refreshStoredToken` placeholders From 6f48c5229f8293a39a1ba5a27e75a6e7955be794 Mon Sep 17 00:00:00 2001 From: Youssef Date: Mon, 11 May 2026 13:51:17 +0300 Subject: [PATCH 04/38] AINATIVEM-44 add implementation plan for pipedrive client and builder refactor --- .../2026-05-11-pipedrive-client-builder.md | 1036 +++++++++++++++++ 1 file changed, 1036 insertions(+) create mode 100644 docs/plans/2026-05-11-pipedrive-client-builder.md diff --git a/docs/plans/2026-05-11-pipedrive-client-builder.md b/docs/plans/2026-05-11-pipedrive-client-builder.md new file mode 100644 index 0000000..8859f32 --- /dev/null +++ b/docs/plans/2026-05-11-pipedrive-client-builder.md @@ -0,0 +1,1036 @@ +# Pipedrive Client + Builder Pattern Refactor — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Refactor the Node.js generator to use a `NodeProjectBuilder` + `BuildStep` pattern and add a `PipedriveClientStep` that generates a preconfigured `src/pipedrive/client.ts` with proactive token refresh. + +**Architecture:** Introduce two utilities (`SourceFileBuilder`, `templates.ts`) that generators use to build file content declaratively. Wrap each generator in a `BuildStep` class inside `projectBuilder.ts`. `NodeProjectBuilder` accumulates steps and a `when()` combinator handles conditional inclusion. `index.ts` becomes a clean fluent chain. + +**Tech Stack:** TypeScript, Vitest, dedent, Node.js `fs/promises` + +--- + +## File Map + +| Action | Path | Responsibility | +|--------|------|---------------| +| Create | `src/utils/sourceFileBuilder.ts` | Fluent TS file content builder (imports, body, export) | +| Create | `src/utils/sourceFileBuilder.test.ts` | Unit tests for SourceFileBuilder | +| Create | `src/utils/templates.ts` | Pure functions for recurring code snippets | +| Create | `src/utils/templates.test.ts` | Unit tests for template helpers | +| Create | `src/generators/node/pipedriveClient.ts` | Generator for `src/pipedrive/client.ts` | +| Create | `src/generators/node/pipedriveClient.test.ts` | Tests for generated client file | +| Create | `src/generators/node/projectBuilder.ts` | `BuildStep` interface + all step classes + `NodeProjectBuilder` | +| Create | `src/generators/node/projectBuilder.test.ts` | Tests for builder `when()` and step ordering | +| Modify | `src/generators/node/app.ts` | Refactor to use `SourceFileBuilder` + `templates` | +| Modify | `src/generators/node/oauth.ts` | Refactor to use `templates.expressRouterFile()` | +| Modify | `src/generators/node/index.ts` | Replace imperative calls with `NodeProjectBuilder` chain | +| Modify | `src/generators/node/index.test.ts` | Add `src/pipedrive/client.ts` to expected files list | + +--- + +## Task 1: `SourceFileBuilder` + +**Files:** +- Create: `src/utils/sourceFileBuilder.ts` +- Create: `src/utils/sourceFileBuilder.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `src/utils/sourceFileBuilder.test.ts`: + +```ts +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('importDefaultIf skips when condition is false', () => { + const out = new SourceFileBuilder() + .importDefaultIf(false, './webhooks.js', 'webhooksRouter') + .build(); + expect(out).not.toContain('webhooks'); + }); + + 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('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('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); + }); +}); +``` + +- [ ] **Step 2: Run tests and confirm they fail** + +```bash +npx vitest run src/utils/sourceFileBuilder.test.ts +``` + +Expected: all tests fail with `Cannot find module './sourceFileBuilder.js'` + +- [ ] **Step 3: Implement `SourceFileBuilder`** + +Create `src/utils/sourceFileBuilder.ts`: + +```ts +interface ImportEntry { + from: string; + 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, { from, names }); + } + return this; + } + + importDefault(from: string, name: string): this { + const existing = this.imports.get(from); + if (existing) { + existing.defaultName = name; + } else { + this.imports.set(from, { 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.values()).map((entry) => { + const parts: string[] = []; + if (entry.defaultName) parts.push(entry.defaultName); + if (entry.names.length > 0) parts.push(`{ ${entry.names.join(', ')} }`); + return `import ${parts.join(', ')} from '${entry.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'); + } +} +``` + +- [ ] **Step 4: Run tests and confirm they pass** + +```bash +npx vitest run src/utils/sourceFileBuilder.test.ts +``` + +Expected: all 11 tests pass + +- [ ] **Step 5: Commit** + +```bash +git add src/utils/sourceFileBuilder.ts src/utils/sourceFileBuilder.test.ts +git commit -m "AINATIVEM-44 add SourceFileBuilder utility" +``` + +--- + +## Task 2: `templates.ts` + +**Files:** +- Create: `src/utils/templates.ts` +- Create: `src/utils/templates.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `src/utils/templates.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { expressRouterFile, routerMount, envVarAccess } 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'"); + }); +}); +``` + +- [ ] **Step 2: Run tests and confirm they fail** + +```bash +npx vitest run src/utils/templates.test.ts +``` + +Expected: all tests fail with `Cannot find module './templates.js'` + +- [ ] **Step 3: Implement `templates.ts`** + +Create `src/utils/templates.ts`: + +```ts +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}`; +} +``` + +- [ ] **Step 4: Run tests and confirm they pass** + +```bash +npx vitest run src/utils/templates.test.ts +``` + +Expected: all 4 tests pass + +- [ ] **Step 5: Commit** + +```bash +git add src/utils/templates.ts src/utils/templates.test.ts +git commit -m "AINATIVEM-44 add template helper functions" +``` + +--- + +## Task 3: `pipedriveClient.ts` generator + +**Files:** +- Create: `src/generators/node/pipedriveClient.ts` +- Create: `src/generators/node/pipedriveClient.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `src/generators/node/pipedriveClient.test.ts`: + +```ts +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 Configuration, DealsApi, PersonsApi, OrganizationsApi 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'"); + expect(content).toContain('Configuration'); + expect(content).toContain('DealsApi'); + expect(content).toContain('PersonsApi'); + expect(content).toContain('OrganizationsApi'); + }); + + it('contains getStoredToken and refreshStoredToken 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('refreshStoredToken'); + }); + + it('checks token expiry before returning client', 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('expiresAt'); + expect(content).toContain('new Date()'); + }); +}); +``` + +- [ ] **Step 2: Run tests and confirm they fail** + +```bash +npx vitest run src/generators/node/pipedriveClient.test.ts +``` + +Expected: all tests fail with `Cannot find module './pipedriveClient.js'` + +- [ ] **Step 3: Implement `pipedriveClient.ts`** + +Create `src/generators/node/pipedriveClient.ts`: + +```ts +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 { Configuration, DealsApi, PersonsApi, OrganizationsApi } from 'pipedrive'; + + interface TokenRecord { + accessToken: string; + expiresAt: Date; + } + + // 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 oauth module call + async function refreshStoredToken(_companyId: number): Promise { + throw new Error('refreshStoredToken not implemented — wire up oauth module'); + } + + async function getValidToken(companyId: number): Promise { + let token = await getStoredToken(companyId); + if (token.expiresAt <= new Date()) { + token = await refreshStoredToken(companyId); + } + return token.accessToken; + } + + export async function getClient(companyId: number) { + const accessToken = await getValidToken(companyId); + const config = new Configuration({ accessToken }); + return { + deals: new DealsApi(config), + persons: new PersonsApi(config), + organizations: new OrganizationsApi(config), + }; + } + `, + ); +} +``` + +- [ ] **Step 4: Run tests and confirm they pass** + +```bash +npx vitest run src/generators/node/pipedriveClient.test.ts +``` + +Expected: all 5 tests pass + +- [ ] **Step 5: Commit** + +```bash +git add src/generators/node/pipedriveClient.ts src/generators/node/pipedriveClient.test.ts +git commit -m "AINATIVEM-44 add pipedriveClient generator" +``` + +--- + +## Task 4: Refactor `oauth.ts` to use `templates` + +**Files:** +- Modify: `src/generators/node/oauth.ts` + +The existing `oauth.test.ts` assertions must still pass after this refactor. + +- [ ] **Step 1: Run existing tests to establish baseline** + +```bash +npx vitest run src/generators/node/oauth.test.ts +``` + +Expected: 2 tests pass + +- [ ] **Step 2: Refactor `oauth.ts`** + +Replace the contents of `src/generators/node/oauth.ts`: + +```ts +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'), expressRouterFile()); +} +``` + +- [ ] **Step 3: Run tests and confirm they still pass** + +```bash +npx vitest run src/generators/node/oauth.test.ts +``` + +Expected: 2 tests pass (same as before) + +- [ ] **Step 4: Commit** + +```bash +git add src/generators/node/oauth.ts +git commit -m "AINATIVEM-44 refactor oauth generator to use templates" +``` + +--- + +## Task 5: Refactor `app.ts` to use `SourceFileBuilder` + `templates` + +**Files:** +- Modify: `src/generators/node/app.ts` + +All 6 existing `app.test.ts` assertions must still pass. + +- [ ] **Step 1: Run existing tests to establish baseline** + +```bash +npx vitest run src/generators/node/app.test.ts +``` + +Expected: 6 tests pass + +- [ ] **Step 2: Refactor `app.ts`** + +Replace the contents of `src/generators/node/app.ts`: + +```ts +import { join } from 'path'; +import { writeFile } from '../../utils/writeFile.js'; +import type { GeneratorOptions } from '../interface.js'; +import { SourceFileBuilder } from '../../utils/sourceFileBuilder.js'; +import { routerMount } from '../../utils/templates.js'; + +export async function generateApp(outputDir: string, options: GeneratorOptions): Promise { + const hasPanel = options.appExtensions.includes('custom-panel'); + const hasModal = options.appExtensions.includes('custom-modal'); + + const mounts = [ + routerMount('/oauth', 'oauthRouter'), + ...(options.webhooks ? [routerMount('/webhooks', 'webhooksRouter')] : []), + ...(hasPanel ? [routerMount('/extensions/panel', 'panelRouter')] : []), + ...(hasModal ? [routerMount('/extensions/modal', 'modalRouter')] : []), + ].join('\n'); + + 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();\n\n${mounts}`) + .exportDefault('app') + .build(); + + await writeFile(join(outputDir, 'src/app.ts'), content); +} +``` + +- [ ] **Step 3: Run tests and confirm they still pass** + +```bash +npx vitest run src/generators/node/app.test.ts +``` + +Expected: 6 tests pass (same as before) + +- [ ] **Step 4: Commit** + +```bash +git add src/generators/node/app.ts +git commit -m "AINATIVEM-44 refactor app generator to use SourceFileBuilder" +``` + +--- + +## Task 6: `projectBuilder.ts` + +**Files:** +- Create: `src/generators/node/projectBuilder.ts` +- Create: `src/generators/node/projectBuilder.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `src/generators/node/projectBuilder.test.ts`: + +```ts +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('addOAuth returns the builder instance for chaining', () => { + const builder = new NodeProjectBuilder('/tmp', options); + expect(builder.addStep(spyStep([], 'x'))).toBe(builder); + expect(builder.when(false, () => {})).toBe(builder); + }); +}); +``` + +- [ ] **Step 2: Run tests and confirm they fail** + +```bash +npx vitest run src/generators/node/projectBuilder.test.ts +``` + +Expected: all tests fail with `Cannot find module './projectBuilder.js'` + +- [ ] **Step 3: Implement `projectBuilder.ts`** + +Create `src/generators/node/projectBuilder.ts`: + +```ts +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 app from './app.js'; + + const PORT = ${envVarAccess('PORT', '3000')}; + app.listen(PORT, () => { + console.log(\`Server running on port \${PORT}\`); + }); + `, + ); + } +} + +class PackageJsonStep implements BuildStep { + async execute(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', + pipedrive: '^21.0.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)); + } +} + +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 + `, + ); + } +} + +class PostgresDockerStep implements BuildStep { + async execute(outputDir: string, options: GeneratorOptions): Promise { + await writeFile( + join(outputDir, 'docker-compose.yml'), + 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: + `, + ); + } +} + +class MySQLDockerStep implements BuildStep { + async execute(outputDir: string, options: GeneratorOptions): Promise { + await writeFile( + join(outputDir, 'docker-compose.yml'), + 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: + `, + ); + } +} + +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()); } + addPostgres(): this { return this.addStep(new PostgresDockerStep()); } + addMySQL(): this { return this.addStep(new MySQLDockerStep()); } + 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); + } + } +} +``` + +- [ ] **Step 4: Run tests and confirm they pass** + +```bash +npx vitest run src/generators/node/projectBuilder.test.ts +``` + +Expected: all 4 tests pass + +- [ ] **Step 5: Commit** + +```bash +git add src/generators/node/projectBuilder.ts src/generators/node/projectBuilder.test.ts +git commit -m "AINATIVEM-44 add NodeProjectBuilder with BuildStep pattern" +``` + +--- + +## Task 7: Refactor `index.ts` + update integration test + +**Files:** +- Modify: `src/generators/node/index.ts` +- Modify: `src/generators/node/index.test.ts` + +- [ ] **Step 1: Run existing integration tests to establish baseline** + +```bash +npx vitest run src/generators/node/index.test.ts +``` + +Expected: 2 tests pass (the `tsc --noEmit` test is skipped here — it runs separately) + +- [ ] **Step 2: Update `index.test.ts` to expect `src/pipedrive/client.ts`** + +In `src/generators/node/index.test.ts`, add `'src/pipedrive/client.ts'` to the `expectedFiles` array: + +```ts +const expectedFiles = [ + 'src/index.ts', + 'src/app.ts', + 'src/oauth/index.ts', + 'src/database/index.ts', + 'src/webhooks/index.ts', + 'src/app-extensions/panel/index.ts', + 'src/app-extensions/modal/index.ts', + 'src/pipedrive/client.ts', // ← add this line + 'package.json', + 'tsconfig.json', + '.env.example', + 'docker-compose.yml', +]; +``` + +- [ ] **Step 3: Run tests to confirm the new assertion fails** + +```bash +npx vitest run src/generators/node/index.test.ts +``` + +Expected: `generates all expected files for full options` fails with `Missing: src/pipedrive/client.ts` + +- [ ] **Step 4: Replace `index.ts` with `NodeProjectBuilder` chain** + +Replace the entire contents of `src/generators/node/index.ts`: + +```ts +import type { Generator, GeneratorOptions } from '../interface.js'; +import { NodeProjectBuilder } from './projectBuilder.js'; + +export const nodeGenerator: Generator = { + async generate(outputDir: string, options: GeneratorOptions): Promise { + await new NodeProjectBuilder(outputDir, options) + .addOAuth() + .addDatabase() + .addApp() + .when(options.webhooks, b => b.addWebhooks()) + .when(options.database === 'postgres', b => b.addPostgres()) + .when(options.database === 'mysql', b => b.addMySQL()) + .when(options.appExtensions.length > 0, b => b.addAppExtensions()) + .addPipedriveClient() + .addServerEntry() + .addPackageJson() + .addTsConfig() + .addEnvExample() + .build(); + }, +}; +``` + +- [ ] **Step 5: Run all tests and confirm they pass** + +```bash +npx vitest run +``` + +Expected: all tests pass across all test files. Output similar to: + +``` +✓ src/utils/sourceFileBuilder.test.ts (11) +✓ src/utils/templates.test.ts (4) +✓ src/utils/writeFile.test.ts +✓ src/generators/node/oauth.test.ts (2) +✓ src/generators/node/database.test.ts (2) +✓ src/generators/node/app.test.ts (6) +✓ src/generators/node/webhooks.test.ts +✓ src/generators/node/appExtensions.test.ts +✓ src/generators/node/pipedriveClient.test.ts (5) +✓ src/generators/node/projectBuilder.test.ts (4) +✓ src/generators/node/index.test.ts (2) +✓ src/prompts/... +``` + +- [ ] **Step 6: Run typecheck** + +```bash +npm run typecheck +``` + +Expected: no errors + +- [ ] **Step 7: Commit** + +```bash +git add src/generators/node/index.ts src/generators/node/index.test.ts +git commit -m "AINATIVEM-44 refactor index to use NodeProjectBuilder" +``` + +--- + +## Self-Review + +**Spec coverage check:** + +| Spec requirement | Task | +|-----------------|------| +| `SourceFileBuilder` with import deduplication, `addBlockIf`, `exportDefault` | Task 1 | +| `templates.ts` — `expressRouterFile`, `routerMount`, `envVarAccess` | Task 2 | +| `generatePipedriveClient` → `src/pipedrive/client.ts` | Task 3 | +| `pipedrive` added to generated `package.json` | Task 6 (`PackageJsonStep`) | +| `getClient(companyId)` with proactive expiry check | Task 3 | +| `getStoredToken` / `refreshStoredToken` placeholders | Task 3 | +| `BuildStep` interface + all step classes | Task 6 | +| `NodeProjectBuilder` with named `.add*()` methods | Task 6 | +| `when(condition, fn)` combinator | Task 6 | +| `app.ts` refactored to use `SourceFileBuilder` | Task 5 | +| `oauth.ts` refactored to use `templates` | Task 4 | +| `index.ts` replaced with builder chain | Task 7 | +| `exportDefault` guard throws if called twice | Task 1 | +| All existing tests continue to pass | Tasks 4, 5, 7 | + +**Placeholder scan:** No TBDs or incomplete steps. All code blocks are complete. + +**Type consistency:** +- `BuildStep` exported from `projectBuilder.ts`, used in `projectBuilder.test.ts` ✓ +- `generatePipedriveClient` matches import in `projectBuilder.ts` ✓ +- `envVarAccess` from `templates.ts` used in `ServerEntryStep` ✓ +- `addStep(step: BuildStep)` exposed on builder, used in tests ✓ From 82d613ac5f2c102c6441c3cb93a14f5f201e2503 Mon Sep 17 00:00:00 2001 From: Youssef Date: Mon, 11 May 2026 13:59:34 +0300 Subject: [PATCH 05/38] AINATIVEM-44 add SourceFileBuilder utility --- src/utils/sourceFileBuilder.test.ts | 83 +++++++++++++++++++++++++++++ src/utils/sourceFileBuilder.ts | 77 ++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 src/utils/sourceFileBuilder.test.ts create mode 100644 src/utils/sourceFileBuilder.ts diff --git a/src/utils/sourceFileBuilder.test.ts b/src/utils/sourceFileBuilder.test.ts new file mode 100644 index 0000000..53f57d7 --- /dev/null +++ b/src/utils/sourceFileBuilder.test.ts @@ -0,0 +1,83 @@ +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('importDefaultIf skips when condition is false', () => { + const out = new SourceFileBuilder() + .importDefaultIf(false, './webhooks.js', 'webhooksRouter') + .build(); + expect(out).not.toContain('webhooks'); + }); + + 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('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('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..d8dc8c1 --- /dev/null +++ b/src/utils/sourceFileBuilder.ts @@ -0,0 +1,77 @@ +interface ImportEntry { + from: string; + 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, { from, names }); + } + return this; + } + + importDefault(from: string, name: string): this { + const existing = this.imports.get(from); + if (existing) { + existing.defaultName = name; + } else { + this.imports.set(from, { 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.values()).map((entry) => { + const parts: string[] = []; + if (entry.defaultName) parts.push(entry.defaultName); + if (entry.names.length > 0) parts.push(`{ ${entry.names.join(', ')} }`); + return `import ${parts.join(', ')} from '${entry.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'); + } +} From c94a08a17008b849abbf41ca8451a60e6be07791 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 11 May 2026 14:03:38 +0300 Subject: [PATCH 06/38] test(database): add full test suite for database layer generator --- src/generators/node/database.test.ts | 216 ++++++++++++++++++++++++--- 1 file changed, 196 insertions(+), 20 deletions(-) diff --git a/src/generators/node/database.test.ts b/src/generators/node/database.test.ts index db11045..7dbaade 100644 --- a/src/generators/node/database.test.ts +++ b/src/generators/node/database.test.ts @@ -6,28 +6,204 @@ 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 = { - projectName: 'test-app', - database: 'postgres', - webhooks: false, - appExtensions: [], -}; +const read = (p: string) => readFile(join(tmpDir, p), 'utf-8'); afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); + await rm(tmpDir, { recursive: true, force: true }); +}); + +const pgOptions: GeneratorOptions = { + projectName: 'test-app', + database: 'postgres', + webhooks: false, + appExtensions: [], +}; + +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 — src/database/index.ts', () => { + it('postgres client uses postgres-js', async () => { + const { generateDatabase } = await import('./database.js'); + 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('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 better-sqlite3', async () => { + const { generateDatabase } = await import('./database.js'); + await generateDatabase(tmpDir, sqliteOptions); + const content = await read('src/database/index.ts'); + expect(content).toContain('better-sqlite3'); + expect(content).toContain('drizzle-orm/better-sqlite3'); + }); }); -describe('generateDatabase', () => { - it('creates src/database/index.ts', async () => { - const { generateDatabase } = await import('./database.js'); - await generateDatabase(tmpDir, options); - expect(await exists(join(tmpDir, 'src/database/index.ts'))).toBe(true); - }); - - it('file is valid TypeScript (exports something)', async () => { - const { generateDatabase } = await import('./database.js'); - await generateDatabase(tmpDir, options); - const content = await readFile(join(tmpDir, 'src/database/index.ts'), 'utf-8'); - expect(content).toContain('export'); - }); +describe('generateDatabase — migrate.ts', () => { + it('generates src/database/migrate.ts with runMigrations export', async () => { + const { generateDatabase } = await import('./database.js'); + 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/better-sqlite3/migrator', async () => { + const { generateDatabase } = await import('./database.js'); + await generateDatabase(tmpDir, sqliteOptions); + const content = await read('src/database/migrate.ts'); + expect(content).toContain('better-sqlite3/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 SERIAL 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 — 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('healthcheck'); + expect(content).toContain('pg_isready'); + }); + + 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('healthcheck'); + expect(content).toContain('mysqladmin'); + }); + + 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); + }); }); From eccf9b797c08e6296ea0279e2dea033ef9d03cc5 Mon Sep 17 00:00:00 2001 From: Youssef Date: Mon, 11 May 2026 14:04:00 +0300 Subject: [PATCH 07/38] AINATIVEM-44 fix SourceFileBuilder quality issues --- src/utils/sourceFileBuilder.test.ts | 21 +++++++++++++++++++++ src/utils/sourceFileBuilder.ts | 14 ++++++++------ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/utils/sourceFileBuilder.test.ts b/src/utils/sourceFileBuilder.test.ts index 53f57d7..0af95a9 100644 --- a/src/utils/sourceFileBuilder.test.ts +++ b/src/utils/sourceFileBuilder.test.ts @@ -37,6 +37,11 @@ describe('SourceFileBuilder', () => { 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') @@ -44,6 +49,11 @@ describe('SourceFileBuilder', () => { 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;'); @@ -54,6 +64,11 @@ describe('SourceFileBuilder', () => { 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 = {};') @@ -68,6 +83,12 @@ describe('SourceFileBuilder', () => { ).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') diff --git a/src/utils/sourceFileBuilder.ts b/src/utils/sourceFileBuilder.ts index d8dc8c1..b7f4e15 100644 --- a/src/utils/sourceFileBuilder.ts +++ b/src/utils/sourceFileBuilder.ts @@ -1,5 +1,4 @@ interface ImportEntry { - from: string; defaultName?: string; names: string[]; } @@ -14,7 +13,7 @@ export class SourceFileBuilder { if (existing) { existing.names = [...new Set([...existing.names, ...names])]; } else { - this.imports.set(from, { from, names }); + this.imports.set(from, { names }); } return this; } @@ -22,9 +21,12 @@ export class SourceFileBuilder { 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, { from, defaultName: name, names: [] }); + this.imports.set(from, { defaultName: name, names: [] }); } return this; } @@ -58,11 +60,11 @@ export class SourceFileBuilder { } build(): string { - const importLines = Array.from(this.imports.values()).map((entry) => { + 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.join(', ')} }`); - return `import ${parts.join(', ')} from '${entry.from}';`; + if (entry.names.length > 0) parts.push(`{ ${[...entry.names].sort().join(', ')} }`); + return `import ${parts.join(', ')} from '${from}';`; }); const sections: string[] = []; From c5aacba77991a31cc51900f65dc92a27bd921874 Mon Sep 17 00:00:00 2001 From: Youssef Date: Mon, 11 May 2026 14:05:15 +0300 Subject: [PATCH 08/38] AINATIVEM-44 add template helper functions --- src/utils/templates.test.ts | 27 +++++++++++++++++++++++++++ src/utils/templates.ts | 11 +++++++++++ 2 files changed, 38 insertions(+) create mode 100644 src/utils/templates.test.ts create mode 100644 src/utils/templates.ts diff --git a/src/utils/templates.test.ts b/src/utils/templates.test.ts new file mode 100644 index 0000000..5db8749 --- /dev/null +++ b/src/utils/templates.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { expressRouterFile, routerMount, envVarAccess } 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'"); + }); +}); diff --git a/src/utils/templates.ts b/src/utils/templates.ts new file mode 100644 index 0000000..0303bd3 --- /dev/null +++ b/src/utils/templates.ts @@ -0,0 +1,11 @@ +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}`; +} From 64cf64feb8e8154955a475b522c242d9e0d726bf Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 11 May 2026 14:06:16 +0300 Subject: [PATCH 09/38] style(database): fix indentation to use tabs in test file --- src/generators/node/database.test.ts | 316 +++++++++++++-------------- 1 file changed, 158 insertions(+), 158 deletions(-) diff --git a/src/generators/node/database.test.ts b/src/generators/node/database.test.ts index 7dbaade..3e53252 100644 --- a/src/generators/node/database.test.ts +++ b/src/generators/node/database.test.ts @@ -9,7 +9,7 @@ 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 }); + await rm(tmpDir, { recursive: true, force: true }); }); const pgOptions: GeneratorOptions = { @@ -34,176 +34,176 @@ const sqliteOptions: GeneratorOptions = { }; 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'); - }); + 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 — src/database/index.ts', () => { - it('postgres client uses postgres-js', async () => { - const { generateDatabase } = await import('./database.js'); - 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('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 better-sqlite3', async () => { - const { generateDatabase } = await import('./database.js'); - await generateDatabase(tmpDir, sqliteOptions); - const content = await read('src/database/index.ts'); - expect(content).toContain('better-sqlite3'); - expect(content).toContain('drizzle-orm/better-sqlite3'); - }); + it('postgres client uses postgres-js', async () => { + const { generateDatabase } = await import('./database.js'); + 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('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 better-sqlite3', async () => { + const { generateDatabase } = await import('./database.js'); + await generateDatabase(tmpDir, sqliteOptions); + const content = await read('src/database/index.ts'); + expect(content).toContain('better-sqlite3'); + expect(content).toContain('drizzle-orm/better-sqlite3'); + }); }); describe('generateDatabase — migrate.ts', () => { - it('generates src/database/migrate.ts with runMigrations export', async () => { - const { generateDatabase } = await import('./database.js'); - 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/better-sqlite3/migrator', async () => { - const { generateDatabase } = await import('./database.js'); - await generateDatabase(tmpDir, sqliteOptions); - const content = await read('src/database/migrate.ts'); - expect(content).toContain('better-sqlite3/migrator'); - }); + it('generates src/database/migrate.ts with runMigrations export', async () => { + const { generateDatabase } = await import('./database.js'); + 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/better-sqlite3/migrator', async () => { + const { generateDatabase } = await import('./database.js'); + await generateDatabase(tmpDir, sqliteOptions); + const content = await read('src/database/migrate.ts'); + expect(content).toContain('better-sqlite3/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 SERIAL 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'); - }); + 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 SERIAL 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 — 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'); - }); + 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('healthcheck'); - expect(content).toContain('pg_isready'); - }); - - 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('healthcheck'); - expect(content).toContain('mysqladmin'); - }); - - 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); - }); + 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('healthcheck'); + expect(content).toContain('pg_isready'); + }); + + 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('healthcheck'); + expect(content).toContain('mysqladmin'); + }); + + 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); + }); }); From 8711110d5e2e50eb6568062834bd4e7ae3dce522 Mon Sep 17 00:00:00 2001 From: Youssef Date: Mon, 11 May 2026 14:06:52 +0300 Subject: [PATCH 10/38] AINATIVEM-44 add pipedriveClient generator --- src/generators/node/pipedriveClient.test.ts | 60 +++++++++++++++++++++ src/generators/node/pipedriveClient.ts | 49 +++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 src/generators/node/pipedriveClient.test.ts create mode 100644 src/generators/node/pipedriveClient.ts diff --git a/src/generators/node/pipedriveClient.test.ts b/src/generators/node/pipedriveClient.test.ts new file mode 100644 index 0000000..9c60107 --- /dev/null +++ b/src/generators/node/pipedriveClient.test.ts @@ -0,0 +1,60 @@ +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 Configuration, DealsApi, PersonsApi, OrganizationsApi 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'"); + expect(content).toContain('Configuration'); + expect(content).toContain('DealsApi'); + expect(content).toContain('PersonsApi'); + expect(content).toContain('OrganizationsApi'); + }); + + it('contains getStoredToken and refreshStoredToken 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('refreshStoredToken'); + }); + + it('checks token expiry before returning client', 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('expiresAt'); + expect(content).toContain('new Date()'); + }); +}); diff --git a/src/generators/node/pipedriveClient.ts b/src/generators/node/pipedriveClient.ts new file mode 100644 index 0000000..27bb8f2 --- /dev/null +++ b/src/generators/node/pipedriveClient.ts @@ -0,0 +1,49 @@ +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 { Configuration, DealsApi, PersonsApi, OrganizationsApi } from 'pipedrive'; + + interface TokenRecord { + accessToken: string; + expiresAt: Date; + } + + // 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 oauth module call + async function refreshStoredToken(_companyId: number): Promise { + throw new Error('refreshStoredToken not implemented — wire up oauth module'); + } + + async function getValidToken(companyId: number): Promise { + let token = await getStoredToken(companyId); + if (token.expiresAt <= new Date()) { + token = await refreshStoredToken(companyId); + } + return token.accessToken; + } + + export async function getClient(companyId: number) { + const accessToken = await getValidToken(companyId); + const config = new Configuration({ accessToken }); + return { + deals: new DealsApi(config), + persons: new PersonsApi(config), + organizations: new OrganizationsApi(config), + }; + } + `, + ); +} From 4e715e24e8660573416e96b32d7128e191223361 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 11 May 2026 14:07:14 +0300 Subject: [PATCH 11/38] feat(database): generate schema.ts with pipedrive_tokens table --- src/generators/node/database.ts | 82 ++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/src/generators/node/database.ts b/src/generators/node/database.ts index 9c6ee37..66aa1f2 100644 --- a/src/generators/node/database.ts +++ b/src/generators/node/database.ts @@ -1,7 +1,85 @@ +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); +} + +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) => [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) => [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) => [primaryKey({ columns: [table.pipedriveCompanyId, table.pipedriveUserId] })], + ); + `; } From a057f2993c20055f6c84f9ed5f76f0632bc54685 Mon Sep 17 00:00:00 2001 From: Youssef Date: Mon, 11 May 2026 14:09:14 +0300 Subject: [PATCH 12/38] AINATIVEM-44 refactor app generator to use SourceFileBuilder --- src/generators/node/app.ts | 50 +++++++++++++++----------------------- 1 file changed, 19 insertions(+), 31 deletions(-) diff --git a/src/generators/node/app.ts b/src/generators/node/app.ts index 90642a6..8df2e71 100644 --- a/src/generators/node/app.ts +++ b/src/generators/node/app.ts @@ -1,41 +1,29 @@ -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 { routerMount } 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 = [ + routerMount('/oauth', 'oauthRouter'), + ...(options.webhooks ? [routerMount('/webhooks', 'webhooksRouter')] : []), + ...(hasPanel ? [routerMount('/extensions/panel', 'panelRouter')] : []), + ...(hasModal ? [routerMount('/extensions/modal', 'modalRouter')] : []), + ].join('\n'); - 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();\n\n${mounts}`) + .exportDefault('app') + .build(); await writeFile(join(outputDir, 'src/app.ts'), content); } From 828008641005649c181cf59480d5f49cb37f2b66 Mon Sep 17 00:00:00 2001 From: Youssef Date: Mon, 11 May 2026 14:11:16 +0300 Subject: [PATCH 13/38] AINATIVEM-44 refactor oauth generator to use templates --- src/generators/node/oauth.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) 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()); } From 1619ad029ceefd4a40924ff463420b8d47e59846 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 11 May 2026 14:15:12 +0300 Subject: [PATCH 14/38] feat(database): generate src/database/index.ts Drizzle client --- src/generators/node/database.ts | 39 +++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/generators/node/database.ts b/src/generators/node/database.ts index 66aa1f2..054725a 100644 --- a/src/generators/node/database.ts +++ b/src/generators/node/database.ts @@ -5,6 +5,7 @@ import type { GeneratorOptions } from '../interface.js'; export async function generateDatabase(outputDir: string, options: GeneratorOptions): Promise { await generateSchema(outputDir, options); + await generateDbClient(outputDir, options); } async function generateSchema(outputDir: string, options: GeneratorOptions): Promise { @@ -83,3 +84,41 @@ function schemaContent(database: GeneratorOptions['database']): string { ); `; } + +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 connection = await mysql.createConnection(process.env.DATABASE_URL!); + export const db = drizzle(connection, { schema }); + `; + } + + return dedent` + import { drizzle } from 'drizzle-orm/better-sqlite3'; + import Database from 'better-sqlite3'; + import * as schema from './schema.js'; + + const sqlite = new Database(process.env.DATABASE_URL ?? './data.db'); + export const db = drizzle(sqlite, { schema }); + `; +} From 6835523de1a6781e143e7a0ea5eeea9d9d7e741b Mon Sep 17 00:00:00 2001 From: Youssef Date: Mon, 11 May 2026 14:16:20 +0300 Subject: [PATCH 15/38] AINATIVEM-44 add NodeProjectBuilder with BuildStep pattern --- src/generators/node/projectBuilder.test.ts | 49 +++++ src/generators/node/projectBuilder.ts | 215 +++++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 src/generators/node/projectBuilder.test.ts create mode 100644 src/generators/node/projectBuilder.ts diff --git a/src/generators/node/projectBuilder.test.ts b/src/generators/node/projectBuilder.test.ts new file mode 100644 index 0000000..4166b85 --- /dev/null +++ b/src/generators/node/projectBuilder.test.ts @@ -0,0 +1,49 @@ +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('addOAuth returns 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..6dbb047 --- /dev/null +++ b/src/generators/node/projectBuilder.ts @@ -0,0 +1,215 @@ +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 app from './app.js'; + + const PORT = ${envVarAccess('PORT', '3000')}; + app.listen(PORT, () => { + console.log(\`Server running on port \${PORT}\`); + }); + `, + ); + } +} + +class PackageJsonStep implements BuildStep { + async execute(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', + pipedrive: '^21.0.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)); + } +} + +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 + `, + ); + } +} + +class PostgresDockerStep implements BuildStep { + async execute(outputDir: string, options: GeneratorOptions): Promise { + await writeFile( + join(outputDir, 'docker-compose.yml'), + 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: + `, + ); + } +} + +class MySQLDockerStep implements BuildStep { + async execute(outputDir: string, options: GeneratorOptions): Promise { + await writeFile( + join(outputDir, 'docker-compose.yml'), + 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: + `, + ); + } +} + +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()); } + addPostgres(): this { return this.addStep(new PostgresDockerStep()); } + addMySQL(): this { return this.addStep(new MySQLDockerStep()); } + 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); + } + } +} From 6ea4643af2ba914fccd868220d60ed3bbd51de22 Mon Sep 17 00:00:00 2001 From: Youssef Date: Mon, 11 May 2026 14:18:30 +0300 Subject: [PATCH 16/38] AINATIVEM-44 fix misleading test name in projectBuilder --- src/generators/node/projectBuilder.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/generators/node/projectBuilder.test.ts b/src/generators/node/projectBuilder.test.ts index 4166b85..7d89b54 100644 --- a/src/generators/node/projectBuilder.test.ts +++ b/src/generators/node/projectBuilder.test.ts @@ -41,7 +41,7 @@ describe('NodeProjectBuilder', () => { expect(order).toEqual(['first', 'second', 'third']); }); - it('addOAuth returns the builder instance for chaining', () => { + 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); From 16cd62abeeea71db28d9ecfd4f96430570b6610a Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 11 May 2026 14:21:29 +0300 Subject: [PATCH 17/38] feat(database): generate migrate.ts with runMigrations --- src/generators/node/database.ts | 39 +++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/generators/node/database.ts b/src/generators/node/database.ts index 054725a..3b48af0 100644 --- a/src/generators/node/database.ts +++ b/src/generators/node/database.ts @@ -6,6 +6,7 @@ import type { GeneratorOptions } from '../interface.js'; export async function generateDatabase(outputDir: string, options: GeneratorOptions): Promise { await generateSchema(outputDir, options); await generateDbClient(outputDir, options); + await generateMigrate(outputDir, options); } async function generateSchema(outputDir: string, options: GeneratorOptions): Promise { @@ -122,3 +123,41 @@ function dbClientContent(database: GeneratorOptions['database']): string { export const db = drizzle(sqlite, { 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/better-sqlite3/migrator'; + import { db } from './index.js'; + + export function runMigrations(): void { + migrate(db, { migrationsFolder: 'src/database/migrations' }); + } + `; +} From 09a3534d1773fc15d5ddd89bab5a543057f08f35 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 11 May 2026 14:24:05 +0300 Subject: [PATCH 18/38] feat(database): generate 0000_init.sql for all three dialects --- src/generators/node/database.ts | 63 +++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/generators/node/database.ts b/src/generators/node/database.ts index 3b48af0..7bd0f88 100644 --- a/src/generators/node/database.ts +++ b/src/generators/node/database.ts @@ -7,6 +7,7 @@ export async function generateDatabase(outputDir: string, options: GeneratorOpti await generateSchema(outputDir, options); await generateDbClient(outputDir, options); await generateMigrate(outputDir, options); + await generateMigrationSql(outputDir, options); } async function generateSchema(outputDir: string, options: GeneratorOptions): Promise { @@ -161,3 +162,65 @@ function migrateContent(database: GeneratorOptions['database']): string { } `; } + +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") + ); + `; +} From af18b210dfd6ba192e7bcac8d4e96dee6ee75f34 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 11 May 2026 14:25:54 +0300 Subject: [PATCH 19/38] feat(database): generate drizzle.config.ts --- src/generators/node/database.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/generators/node/database.ts b/src/generators/node/database.ts index 7bd0f88..42ba302 100644 --- a/src/generators/node/database.ts +++ b/src/generators/node/database.ts @@ -8,6 +8,7 @@ export async function generateDatabase(outputDir: string, options: GeneratorOpti await generateDbClient(outputDir, options); await generateMigrate(outputDir, options); await generateMigrationSql(outputDir, options); + await generateDrizzleConfig(outputDir, options); } async function generateSchema(outputDir: string, options: GeneratorOptions): Promise { @@ -224,3 +225,25 @@ function migrationSqlContent(database: GeneratorOptions['database']): string { ); `; } + +async function generateDrizzleConfig(outputDir: string, options: GeneratorOptions): Promise { + const dialect = { postgres: 'postgresql', mysql: 'mysql', sqlite: 'sqlite' }[options.database]; + const url = + options.database === 'sqlite' + ? `process.env.DATABASE_URL ?? './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); +} From 4b7e83c17ff58d9cc31409d6a766091049d1f881 Mon Sep 17 00:00:00 2001 From: Youssef Date: Mon, 11 May 2026 14:26:43 +0300 Subject: [PATCH 20/38] AINATIVEM-44 refactor index to use NodeProjectBuilder --- src/generators/node/index.test.ts | 1 + src/generators/node/index.ts | 150 +++---------------------- src/generators/node/pipedriveClient.ts | 6 + 3 files changed, 22 insertions(+), 135 deletions(-) diff --git a/src/generators/node/index.test.ts b/src/generators/node/index.test.ts index 1ea9071..3d9c0ca 100644 --- a/src/generators/node/index.test.ts +++ b/src/generators/node/index.test.ts @@ -39,6 +39,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..048fd1a 100644 --- a/src/generators/node/index.ts +++ b/src/generators/node/index.ts @@ -1,141 +1,21 @@ -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) + .addOAuth() + .addDatabase() + .addApp() + .when(options.webhooks, b => b.addWebhooks()) + .when(options.database === 'postgres', b => b.addPostgres()) + .when(options.database === 'mysql', b => b.addMySQL()) + .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/pipedriveClient.ts b/src/generators/node/pipedriveClient.ts index 27bb8f2..70a325c 100644 --- a/src/generators/node/pipedriveClient.ts +++ b/src/generators/node/pipedriveClient.ts @@ -7,6 +7,12 @@ export async function generatePipedriveClient( outputDir: string, _options: GeneratorOptions, ): Promise { + // pipedrive v21 ships no .d.ts files; this shim satisfies tsc + await writeFile( + join(outputDir, 'src/pipedrive/pipedrive.d.ts'), + `declare module 'pipedrive';\n`, + ); + await writeFile( join(outputDir, 'src/pipedrive/client.ts'), dedent` From f31640c4392c014dad168da709727a940658faaf Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 11 May 2026 14:27:15 +0300 Subject: [PATCH 21/38] refactor(database): use typed Record for dialect mapping in drizzle config generator --- src/generators/node/database.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/generators/node/database.ts b/src/generators/node/database.ts index 42ba302..f9bc2a7 100644 --- a/src/generators/node/database.ts +++ b/src/generators/node/database.ts @@ -227,7 +227,12 @@ function migrationSqlContent(database: GeneratorOptions['database']): string { } async function generateDrizzleConfig(outputDir: string, options: GeneratorOptions): Promise { - const dialect = { postgres: 'postgresql', mysql: 'mysql', sqlite: 'sqlite' }[options.database]; + const dialectMap: Record = { + postgres: 'postgresql', + mysql: 'mysql', + sqlite: 'sqlite', + }; + const dialect = dialectMap[options.database]; const url = options.database === 'sqlite' ? `process.env.DATABASE_URL ?? './data.db'` From ca2d00ee5cf047358fdf4312ac3fb70486dde97a Mon Sep 17 00:00:00 2001 From: Youssef Date: Mon, 11 May 2026 14:32:14 +0300 Subject: [PATCH 22/38] AINATIVEM-44 fix node: protocol prefix in index.test.ts imports --- src/generators/node/index.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/generators/node/index.test.ts b/src/generators/node/index.test.ts index 3d9c0ca..b3126e9 100644 --- a/src/generators/node/index.test.ts +++ b/src/generators/node/index.test.ts @@ -1,8 +1,8 @@ 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'; From e43854f9f9476d10928a16580fd89484cb769b94 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 11 May 2026 14:42:35 +0300 Subject: [PATCH 23/38] feat(database): move docker-compose to database generator, add healthchecks --- src/generators/node/database.ts | 56 +++++++++++++++++++++++++++++++++ src/generators/node/index.ts | 43 ------------------------- 2 files changed, 56 insertions(+), 43 deletions(-) diff --git a/src/generators/node/database.ts b/src/generators/node/database.ts index f9bc2a7..610594a 100644 --- a/src/generators/node/database.ts +++ b/src/generators/node/database.ts @@ -9,6 +9,7 @@ export async function generateDatabase(outputDir: string, options: GeneratorOpti await generateMigrate(outputDir, options); await generateMigrationSql(outputDir, options); await generateDrizzleConfig(outputDir, options); + await generateDockerCompose(outputDir, options); } async function generateSchema(outputDir: string, options: GeneratorOptions): Promise { @@ -252,3 +253,58 @@ async function generateDrizzleConfig(outputDir: string, options: GeneratorOption `; await writeFile(join(outputDir, 'drizzle.config.ts'), content); } + +async function generateDockerCompose(outputDir: string, options: GeneratorOptions): Promise { + if (options.database === 'sqlite') return; + const content = dockerComposeContent(options); + await writeFile(join(outputDir, 'docker-compose.yml'), content); +} + +function dockerComposeContent(options: GeneratorOptions): string { + if (options.database === 'postgres') { + return 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: + `; + } + + return 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: + `; +} diff --git a/src/generators/node/index.ts b/src/generators/node/index.ts index 893881b..009838f 100644 --- a/src/generators/node/index.ts +++ b/src/generators/node/index.ts @@ -26,10 +26,6 @@ export const nodeGenerator: Generator = { await generatePackageJson(outputDir, options); await generateTsConfig(outputDir); await generateEnvExample(outputDir); - - if (options.database === 'postgres' || options.database === 'mysql') { - await generateDockerCompose(outputDir, options); - } }, }; @@ -100,42 +96,3 @@ async function generateEnvExample(outputDir: string): Promise { `, ); } - -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); -} From f3d79acec577a3de851c547ae2d8b3d86d3ea416 Mon Sep 17 00:00:00 2001 From: Youssef Date: Mon, 11 May 2026 14:49:47 +0300 Subject: [PATCH 24/38] AINATIVEM-44 add RouterMountBuilder, generate script, and deps install prompt --- .gitignore | 3 + CLAUDE.md | 2 +- .../2026-05-11-pipedrive-client-builder.md | 1036 ----------------- ...6-05-11-pipedrive-client-builder-design.md | 220 ---- package.json | 3 +- src/cli.ts | 18 +- src/generators/node/app.ts | 17 +- src/utils/templates.test.ts | 22 +- src/utils/templates.ts | 18 + 9 files changed, 70 insertions(+), 1269 deletions(-) delete mode 100644 docs/plans/2026-05-11-pipedrive-client-builder.md delete mode 100644 docs/specs/2026-05-11-pipedrive-client-builder-design.md 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/CLAUDE.md b/CLAUDE.md index 455546f..d2496b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ 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 -npx tsx src/cli.ts app # generate test project in app/ (gitignored via *-app/) +npm run generate # generate test project in apps/test-app/ (gitignored) ``` ## Architecture diff --git a/docs/plans/2026-05-11-pipedrive-client-builder.md b/docs/plans/2026-05-11-pipedrive-client-builder.md deleted file mode 100644 index 8859f32..0000000 --- a/docs/plans/2026-05-11-pipedrive-client-builder.md +++ /dev/null @@ -1,1036 +0,0 @@ -# Pipedrive Client + Builder Pattern Refactor — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Refactor the Node.js generator to use a `NodeProjectBuilder` + `BuildStep` pattern and add a `PipedriveClientStep` that generates a preconfigured `src/pipedrive/client.ts` with proactive token refresh. - -**Architecture:** Introduce two utilities (`SourceFileBuilder`, `templates.ts`) that generators use to build file content declaratively. Wrap each generator in a `BuildStep` class inside `projectBuilder.ts`. `NodeProjectBuilder` accumulates steps and a `when()` combinator handles conditional inclusion. `index.ts` becomes a clean fluent chain. - -**Tech Stack:** TypeScript, Vitest, dedent, Node.js `fs/promises` - ---- - -## File Map - -| Action | Path | Responsibility | -|--------|------|---------------| -| Create | `src/utils/sourceFileBuilder.ts` | Fluent TS file content builder (imports, body, export) | -| Create | `src/utils/sourceFileBuilder.test.ts` | Unit tests for SourceFileBuilder | -| Create | `src/utils/templates.ts` | Pure functions for recurring code snippets | -| Create | `src/utils/templates.test.ts` | Unit tests for template helpers | -| Create | `src/generators/node/pipedriveClient.ts` | Generator for `src/pipedrive/client.ts` | -| Create | `src/generators/node/pipedriveClient.test.ts` | Tests for generated client file | -| Create | `src/generators/node/projectBuilder.ts` | `BuildStep` interface + all step classes + `NodeProjectBuilder` | -| Create | `src/generators/node/projectBuilder.test.ts` | Tests for builder `when()` and step ordering | -| Modify | `src/generators/node/app.ts` | Refactor to use `SourceFileBuilder` + `templates` | -| Modify | `src/generators/node/oauth.ts` | Refactor to use `templates.expressRouterFile()` | -| Modify | `src/generators/node/index.ts` | Replace imperative calls with `NodeProjectBuilder` chain | -| Modify | `src/generators/node/index.test.ts` | Add `src/pipedrive/client.ts` to expected files list | - ---- - -## Task 1: `SourceFileBuilder` - -**Files:** -- Create: `src/utils/sourceFileBuilder.ts` -- Create: `src/utils/sourceFileBuilder.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Create `src/utils/sourceFileBuilder.test.ts`: - -```ts -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('importDefaultIf skips when condition is false', () => { - const out = new SourceFileBuilder() - .importDefaultIf(false, './webhooks.js', 'webhooksRouter') - .build(); - expect(out).not.toContain('webhooks'); - }); - - 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('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('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); - }); -}); -``` - -- [ ] **Step 2: Run tests and confirm they fail** - -```bash -npx vitest run src/utils/sourceFileBuilder.test.ts -``` - -Expected: all tests fail with `Cannot find module './sourceFileBuilder.js'` - -- [ ] **Step 3: Implement `SourceFileBuilder`** - -Create `src/utils/sourceFileBuilder.ts`: - -```ts -interface ImportEntry { - from: string; - 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, { from, names }); - } - return this; - } - - importDefault(from: string, name: string): this { - const existing = this.imports.get(from); - if (existing) { - existing.defaultName = name; - } else { - this.imports.set(from, { 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.values()).map((entry) => { - const parts: string[] = []; - if (entry.defaultName) parts.push(entry.defaultName); - if (entry.names.length > 0) parts.push(`{ ${entry.names.join(', ')} }`); - return `import ${parts.join(', ')} from '${entry.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'); - } -} -``` - -- [ ] **Step 4: Run tests and confirm they pass** - -```bash -npx vitest run src/utils/sourceFileBuilder.test.ts -``` - -Expected: all 11 tests pass - -- [ ] **Step 5: Commit** - -```bash -git add src/utils/sourceFileBuilder.ts src/utils/sourceFileBuilder.test.ts -git commit -m "AINATIVEM-44 add SourceFileBuilder utility" -``` - ---- - -## Task 2: `templates.ts` - -**Files:** -- Create: `src/utils/templates.ts` -- Create: `src/utils/templates.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Create `src/utils/templates.test.ts`: - -```ts -import { describe, expect, it } from 'vitest'; -import { expressRouterFile, routerMount, envVarAccess } 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'"); - }); -}); -``` - -- [ ] **Step 2: Run tests and confirm they fail** - -```bash -npx vitest run src/utils/templates.test.ts -``` - -Expected: all tests fail with `Cannot find module './templates.js'` - -- [ ] **Step 3: Implement `templates.ts`** - -Create `src/utils/templates.ts`: - -```ts -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}`; -} -``` - -- [ ] **Step 4: Run tests and confirm they pass** - -```bash -npx vitest run src/utils/templates.test.ts -``` - -Expected: all 4 tests pass - -- [ ] **Step 5: Commit** - -```bash -git add src/utils/templates.ts src/utils/templates.test.ts -git commit -m "AINATIVEM-44 add template helper functions" -``` - ---- - -## Task 3: `pipedriveClient.ts` generator - -**Files:** -- Create: `src/generators/node/pipedriveClient.ts` -- Create: `src/generators/node/pipedriveClient.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Create `src/generators/node/pipedriveClient.test.ts`: - -```ts -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 Configuration, DealsApi, PersonsApi, OrganizationsApi 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'"); - expect(content).toContain('Configuration'); - expect(content).toContain('DealsApi'); - expect(content).toContain('PersonsApi'); - expect(content).toContain('OrganizationsApi'); - }); - - it('contains getStoredToken and refreshStoredToken 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('refreshStoredToken'); - }); - - it('checks token expiry before returning client', 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('expiresAt'); - expect(content).toContain('new Date()'); - }); -}); -``` - -- [ ] **Step 2: Run tests and confirm they fail** - -```bash -npx vitest run src/generators/node/pipedriveClient.test.ts -``` - -Expected: all tests fail with `Cannot find module './pipedriveClient.js'` - -- [ ] **Step 3: Implement `pipedriveClient.ts`** - -Create `src/generators/node/pipedriveClient.ts`: - -```ts -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 { Configuration, DealsApi, PersonsApi, OrganizationsApi } from 'pipedrive'; - - interface TokenRecord { - accessToken: string; - expiresAt: Date; - } - - // 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 oauth module call - async function refreshStoredToken(_companyId: number): Promise { - throw new Error('refreshStoredToken not implemented — wire up oauth module'); - } - - async function getValidToken(companyId: number): Promise { - let token = await getStoredToken(companyId); - if (token.expiresAt <= new Date()) { - token = await refreshStoredToken(companyId); - } - return token.accessToken; - } - - export async function getClient(companyId: number) { - const accessToken = await getValidToken(companyId); - const config = new Configuration({ accessToken }); - return { - deals: new DealsApi(config), - persons: new PersonsApi(config), - organizations: new OrganizationsApi(config), - }; - } - `, - ); -} -``` - -- [ ] **Step 4: Run tests and confirm they pass** - -```bash -npx vitest run src/generators/node/pipedriveClient.test.ts -``` - -Expected: all 5 tests pass - -- [ ] **Step 5: Commit** - -```bash -git add src/generators/node/pipedriveClient.ts src/generators/node/pipedriveClient.test.ts -git commit -m "AINATIVEM-44 add pipedriveClient generator" -``` - ---- - -## Task 4: Refactor `oauth.ts` to use `templates` - -**Files:** -- Modify: `src/generators/node/oauth.ts` - -The existing `oauth.test.ts` assertions must still pass after this refactor. - -- [ ] **Step 1: Run existing tests to establish baseline** - -```bash -npx vitest run src/generators/node/oauth.test.ts -``` - -Expected: 2 tests pass - -- [ ] **Step 2: Refactor `oauth.ts`** - -Replace the contents of `src/generators/node/oauth.ts`: - -```ts -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'), expressRouterFile()); -} -``` - -- [ ] **Step 3: Run tests and confirm they still pass** - -```bash -npx vitest run src/generators/node/oauth.test.ts -``` - -Expected: 2 tests pass (same as before) - -- [ ] **Step 4: Commit** - -```bash -git add src/generators/node/oauth.ts -git commit -m "AINATIVEM-44 refactor oauth generator to use templates" -``` - ---- - -## Task 5: Refactor `app.ts` to use `SourceFileBuilder` + `templates` - -**Files:** -- Modify: `src/generators/node/app.ts` - -All 6 existing `app.test.ts` assertions must still pass. - -- [ ] **Step 1: Run existing tests to establish baseline** - -```bash -npx vitest run src/generators/node/app.test.ts -``` - -Expected: 6 tests pass - -- [ ] **Step 2: Refactor `app.ts`** - -Replace the contents of `src/generators/node/app.ts`: - -```ts -import { join } from 'path'; -import { writeFile } from '../../utils/writeFile.js'; -import type { GeneratorOptions } from '../interface.js'; -import { SourceFileBuilder } from '../../utils/sourceFileBuilder.js'; -import { routerMount } from '../../utils/templates.js'; - -export async function generateApp(outputDir: string, options: GeneratorOptions): Promise { - const hasPanel = options.appExtensions.includes('custom-panel'); - const hasModal = options.appExtensions.includes('custom-modal'); - - const mounts = [ - routerMount('/oauth', 'oauthRouter'), - ...(options.webhooks ? [routerMount('/webhooks', 'webhooksRouter')] : []), - ...(hasPanel ? [routerMount('/extensions/panel', 'panelRouter')] : []), - ...(hasModal ? [routerMount('/extensions/modal', 'modalRouter')] : []), - ].join('\n'); - - 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();\n\n${mounts}`) - .exportDefault('app') - .build(); - - await writeFile(join(outputDir, 'src/app.ts'), content); -} -``` - -- [ ] **Step 3: Run tests and confirm they still pass** - -```bash -npx vitest run src/generators/node/app.test.ts -``` - -Expected: 6 tests pass (same as before) - -- [ ] **Step 4: Commit** - -```bash -git add src/generators/node/app.ts -git commit -m "AINATIVEM-44 refactor app generator to use SourceFileBuilder" -``` - ---- - -## Task 6: `projectBuilder.ts` - -**Files:** -- Create: `src/generators/node/projectBuilder.ts` -- Create: `src/generators/node/projectBuilder.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Create `src/generators/node/projectBuilder.test.ts`: - -```ts -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('addOAuth returns the builder instance for chaining', () => { - const builder = new NodeProjectBuilder('/tmp', options); - expect(builder.addStep(spyStep([], 'x'))).toBe(builder); - expect(builder.when(false, () => {})).toBe(builder); - }); -}); -``` - -- [ ] **Step 2: Run tests and confirm they fail** - -```bash -npx vitest run src/generators/node/projectBuilder.test.ts -``` - -Expected: all tests fail with `Cannot find module './projectBuilder.js'` - -- [ ] **Step 3: Implement `projectBuilder.ts`** - -Create `src/generators/node/projectBuilder.ts`: - -```ts -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 app from './app.js'; - - const PORT = ${envVarAccess('PORT', '3000')}; - app.listen(PORT, () => { - console.log(\`Server running on port \${PORT}\`); - }); - `, - ); - } -} - -class PackageJsonStep implements BuildStep { - async execute(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', - pipedrive: '^21.0.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)); - } -} - -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 - `, - ); - } -} - -class PostgresDockerStep implements BuildStep { - async execute(outputDir: string, options: GeneratorOptions): Promise { - await writeFile( - join(outputDir, 'docker-compose.yml'), - 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: - `, - ); - } -} - -class MySQLDockerStep implements BuildStep { - async execute(outputDir: string, options: GeneratorOptions): Promise { - await writeFile( - join(outputDir, 'docker-compose.yml'), - 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: - `, - ); - } -} - -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()); } - addPostgres(): this { return this.addStep(new PostgresDockerStep()); } - addMySQL(): this { return this.addStep(new MySQLDockerStep()); } - 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); - } - } -} -``` - -- [ ] **Step 4: Run tests and confirm they pass** - -```bash -npx vitest run src/generators/node/projectBuilder.test.ts -``` - -Expected: all 4 tests pass - -- [ ] **Step 5: Commit** - -```bash -git add src/generators/node/projectBuilder.ts src/generators/node/projectBuilder.test.ts -git commit -m "AINATIVEM-44 add NodeProjectBuilder with BuildStep pattern" -``` - ---- - -## Task 7: Refactor `index.ts` + update integration test - -**Files:** -- Modify: `src/generators/node/index.ts` -- Modify: `src/generators/node/index.test.ts` - -- [ ] **Step 1: Run existing integration tests to establish baseline** - -```bash -npx vitest run src/generators/node/index.test.ts -``` - -Expected: 2 tests pass (the `tsc --noEmit` test is skipped here — it runs separately) - -- [ ] **Step 2: Update `index.test.ts` to expect `src/pipedrive/client.ts`** - -In `src/generators/node/index.test.ts`, add `'src/pipedrive/client.ts'` to the `expectedFiles` array: - -```ts -const expectedFiles = [ - 'src/index.ts', - 'src/app.ts', - 'src/oauth/index.ts', - 'src/database/index.ts', - 'src/webhooks/index.ts', - 'src/app-extensions/panel/index.ts', - 'src/app-extensions/modal/index.ts', - 'src/pipedrive/client.ts', // ← add this line - 'package.json', - 'tsconfig.json', - '.env.example', - 'docker-compose.yml', -]; -``` - -- [ ] **Step 3: Run tests to confirm the new assertion fails** - -```bash -npx vitest run src/generators/node/index.test.ts -``` - -Expected: `generates all expected files for full options` fails with `Missing: src/pipedrive/client.ts` - -- [ ] **Step 4: Replace `index.ts` with `NodeProjectBuilder` chain** - -Replace the entire contents of `src/generators/node/index.ts`: - -```ts -import type { Generator, GeneratorOptions } from '../interface.js'; -import { NodeProjectBuilder } from './projectBuilder.js'; - -export const nodeGenerator: Generator = { - async generate(outputDir: string, options: GeneratorOptions): Promise { - await new NodeProjectBuilder(outputDir, options) - .addOAuth() - .addDatabase() - .addApp() - .when(options.webhooks, b => b.addWebhooks()) - .when(options.database === 'postgres', b => b.addPostgres()) - .when(options.database === 'mysql', b => b.addMySQL()) - .when(options.appExtensions.length > 0, b => b.addAppExtensions()) - .addPipedriveClient() - .addServerEntry() - .addPackageJson() - .addTsConfig() - .addEnvExample() - .build(); - }, -}; -``` - -- [ ] **Step 5: Run all tests and confirm they pass** - -```bash -npx vitest run -``` - -Expected: all tests pass across all test files. Output similar to: - -``` -✓ src/utils/sourceFileBuilder.test.ts (11) -✓ src/utils/templates.test.ts (4) -✓ src/utils/writeFile.test.ts -✓ src/generators/node/oauth.test.ts (2) -✓ src/generators/node/database.test.ts (2) -✓ src/generators/node/app.test.ts (6) -✓ src/generators/node/webhooks.test.ts -✓ src/generators/node/appExtensions.test.ts -✓ src/generators/node/pipedriveClient.test.ts (5) -✓ src/generators/node/projectBuilder.test.ts (4) -✓ src/generators/node/index.test.ts (2) -✓ src/prompts/... -``` - -- [ ] **Step 6: Run typecheck** - -```bash -npm run typecheck -``` - -Expected: no errors - -- [ ] **Step 7: Commit** - -```bash -git add src/generators/node/index.ts src/generators/node/index.test.ts -git commit -m "AINATIVEM-44 refactor index to use NodeProjectBuilder" -``` - ---- - -## Self-Review - -**Spec coverage check:** - -| Spec requirement | Task | -|-----------------|------| -| `SourceFileBuilder` with import deduplication, `addBlockIf`, `exportDefault` | Task 1 | -| `templates.ts` — `expressRouterFile`, `routerMount`, `envVarAccess` | Task 2 | -| `generatePipedriveClient` → `src/pipedrive/client.ts` | Task 3 | -| `pipedrive` added to generated `package.json` | Task 6 (`PackageJsonStep`) | -| `getClient(companyId)` with proactive expiry check | Task 3 | -| `getStoredToken` / `refreshStoredToken` placeholders | Task 3 | -| `BuildStep` interface + all step classes | Task 6 | -| `NodeProjectBuilder` with named `.add*()` methods | Task 6 | -| `when(condition, fn)` combinator | Task 6 | -| `app.ts` refactored to use `SourceFileBuilder` | Task 5 | -| `oauth.ts` refactored to use `templates` | Task 4 | -| `index.ts` replaced with builder chain | Task 7 | -| `exportDefault` guard throws if called twice | Task 1 | -| All existing tests continue to pass | Tasks 4, 5, 7 | - -**Placeholder scan:** No TBDs or incomplete steps. All code blocks are complete. - -**Type consistency:** -- `BuildStep` exported from `projectBuilder.ts`, used in `projectBuilder.test.ts` ✓ -- `generatePipedriveClient` matches import in `projectBuilder.ts` ✓ -- `envVarAccess` from `templates.ts` used in `ServerEntryStep` ✓ -- `addStep(step: BuildStep)` exposed on builder, used in tests ✓ diff --git a/docs/specs/2026-05-11-pipedrive-client-builder-design.md b/docs/specs/2026-05-11-pipedrive-client-builder-design.md deleted file mode 100644 index a18c0ae..0000000 --- a/docs/specs/2026-05-11-pipedrive-client-builder-design.md +++ /dev/null @@ -1,220 +0,0 @@ -# Pipedrive Client + Builder Pattern Refactor - -**Date:** 2026-05-11 -**Jira:** AINATIVEM-44 -**Branch:** AINATIVEM-44 - -## Summary - -Two changes in one task: - -1. **Refactor the Node.js generator to use a `NodeProjectBuilder` + `BuildStep` pattern** — replaces the imperative function calls in `nodeGenerator/index.ts` with a fluent, extensible builder. -2. **Add a `PipedriveClientStep`** — generates `src/pipedrive/client.ts` in the scaffolded project, wrapping the official `pipedrive` npm SDK with proactive token refresh (Option B). - ---- - -## New Files - -| Path | Purpose | -|------|---------| -| `src/utils/sourceFileBuilder.ts` | Fluent builder for TypeScript file content | -| `src/utils/templates.ts` | Pure helper functions for recurring code snippets | -| `src/generators/node/projectBuilder.ts` | `NodeProjectBuilder` + `BuildStep` interface + all step classes | -| `src/generators/node/pipedriveClient.ts` | `PipedriveClientStep` generator | -| `src/utils/sourceFileBuilder.test.ts` | Unit tests for `SourceFileBuilder` | -| `src/generators/node/projectBuilder.test.ts` | Tests for builder and `when()` | -| `src/generators/node/pipedriveClient.test.ts` | Tests for generated client file | - -## Modified Files - -| Path | Change | -|------|--------| -| `src/generators/node/index.ts` | Replace imperative calls with `NodeProjectBuilder` chain | -| `src/generators/node/app.ts` | Refactor to use `SourceFileBuilder` | -| `src/generators/node/oauth.ts` | Refactor to use `SourceFileBuilder` | - ---- - -## Architecture - -### `SourceFileBuilder` (`src/utils/sourceFileBuilder.ts`) - -Constructs TypeScript file content declaratively. Eliminates conditional blank lines from string interpolation. - -```ts -interface ImportEntry { from: string; names: string[]; isDefault: boolean; } - -class SourceFileBuilder { - import(from: string, names: string[]): this - importDefault(from: string, name: string): this - importIf(condition: boolean, from: string, names: string[]): this - importDefaultIf(condition: boolean, from: string, name: string): this - addBlock(code: string): this - addBlockIf(condition: boolean, code: string): this - exportDefault(name: string): this - build(): string // imports (grouped) → blank line → body blocks → export default -} -``` - -Imports are deduplicated. Named and default imports from the same source are merged. `build()` emits a string ready to pass to `writeFile()`. - -### `templates.ts` (`src/utils/templates.ts`) - -Pure functions for code snippets shared across generators. No side effects. - -```ts -export function expressRouterFile(): string -// → "import { Router } from 'express';\nexport default Router();" - -export function routerMount(path: string, routerName: string): string -// → "app.use('${path}', ${routerName});" - -export function envVarAccess(key: string, fallback?: string): string -// → "process.env.KEY" or "process.env.KEY ?? 'fallback'" -``` - -### `BuildStep` + `NodeProjectBuilder` (`src/generators/node/projectBuilder.ts`) - -```ts -interface BuildStep { - execute(outputDir: string, options: GeneratorOptions): Promise; -} -``` - -Each feature is a `BuildStep` class. The builder accumulates steps and `build()` executes them in sequence: - -```ts -class NodeProjectBuilder { - constructor(outputDir: string, options: GeneratorOptions) - - // Always-add methods (unconditional): - addOAuth(): this - addDatabase(): this - addApp(): this - addPipedriveClient(): this - addServerEntry(): this - addPackageJson(): this - addTsConfig(): this - addEnvExample(): this - - // Feature methods (called conditionally by orchestrator via when()): - addWebhooks(): this - addPostgres(): this - addMySQL(): this - addAppExtensions(): this - - // Conditional combinator: - when(condition: boolean, fn: (b: this) => void): this - - async build(): Promise -} -``` - -Orchestrator usage in `index.ts`: - -```ts -export const nodeGenerator: Generator = { - async generate(outputDir, options) { - await new NodeProjectBuilder(outputDir, options) - .addOAuth() - .addDatabase() - .addApp() - .when(options.webhooks, b => b.addWebhooks()) - .when(options.database === 'postgres', b => b.addPostgres()) - .when(options.database === 'mysql', b => b.addMySQL()) - .when(options.appExtensions.length > 0, b => b.addAppExtensions()) - .addPipedriveClient() - .addServerEntry() - .addPackageJson() - .addTsConfig() - .addEnvExample() - .build(); - } -}; -``` - -Adding a new feature = write a `BuildStep` class + one `.when(...)` line in the chain. - ---- - -## Pipedrive Client Generator - -### Generated file: `src/pipedrive/client.ts` - -Uses Option B: proactive expiry check before every call — no retry needed. - -```ts -import { Configuration, DealsApi, PersonsApi, OrganizationsApi } from 'pipedrive'; - -interface TokenRecord { - accessToken: string; - expiresAt: Date; -} - -// 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 oauth module call -async function refreshStoredToken(_companyId: number): Promise { - throw new Error('refreshStoredToken not implemented — wire up oauth module'); -} - -async function getValidToken(companyId: number): Promise { - let token = await getStoredToken(companyId); - if (token.expiresAt <= new Date()) { - token = await refreshStoredToken(companyId); - } - return token.accessToken; -} - -export async function getClient(companyId: number) { - const accessToken = await getValidToken(companyId); - const config = new Configuration({ accessToken }); - return { - deals: new DealsApi(config), - persons: new PersonsApi(config), - organizations: new OrganizationsApi(config), - }; -} -``` - -**Why placeholder functions instead of imports:** `database/index.ts` and `oauth/index.ts` are stubs being implemented separately (parallel task). The client must compile immediately. Placeholder functions with clear `// TODO` messages give the other developer a typed interface to implement against. - -**Why proactive expiry check:** The token record includes `expiresAt`. Checking before the call avoids a round-trip to the Pipedrive API for an expired token. Clock skew is acceptable for a scaffold — production apps can tighten the margin. - -### Generated `package.json` additions - -`pipedrive` is added to `dependencies` in `generatePackageJson` (inside `PackageJsonStep`). - ---- - -## Error Handling - -- Each `BuildStep.execute()` propagates errors — `cli.ts` catches and formats them with `clack.log.error` -- `SourceFileBuilder` throws if `.exportDefault()` is called more than once -- Placeholder functions throw `Error('not implemented')` — visible immediately at runtime, safe at compile time -- No silent failures: if any step throws, `build()` stops - ---- - -## Testing - -### `sourceFileBuilder.test.ts` -- Import deduplication (same source, multiple calls) -- Named + default imports from same source are merged -- `addBlockIf(false, ...)` adds nothing -- `build()` output: imports first, blank line, body, export default -- `exportDefault` called twice throws - -### `projectBuilder.test.ts` -- `when(true, ...)` adds the step; `when(false, ...)` does not -- Steps execute in insertion order -- Builder is chainable (returns `this`) - -### `pipedriveClient.test.ts` -- `src/pipedrive/client.ts` is created -- File exports `getClient` -- File imports `Configuration`, `DealsApi`, `PersonsApi`, `OrganizationsApi` from `'pipedrive'` -- File contains `getStoredToken` and `refreshStoredToken` placeholders diff --git a/package.json b/package.json index a2bf837..bda3bf9 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "format": "prettier --write src", "lint": "eslint src", "test": "vitest run", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "generate": "tsx src/cli.ts apps/test-app" }, "dependencies": { "@clack/prompts": "^0.9.0", diff --git a/src/cli.ts b/src/cli.ts index 8270c25..ad24fc5 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 8df2e71..d1fb86c 100644 --- a/src/generators/node/app.ts +++ b/src/generators/node/app.ts @@ -2,18 +2,18 @@ import { join } from 'path'; import { writeFile } from '../../utils/writeFile.js'; import type { GeneratorOptions } from '../interface.js'; import { SourceFileBuilder } from '../../utils/sourceFileBuilder.js'; -import { routerMount } from '../../utils/templates.js'; +import { RouterMountBuilder } from '../../utils/templates.js'; export async function generateApp(outputDir: string, options: GeneratorOptions): Promise { const hasPanel = options.appExtensions.includes('custom-panel'); const hasModal = options.appExtensions.includes('custom-modal'); - const mounts = [ - routerMount('/oauth', 'oauthRouter'), - ...(options.webhooks ? [routerMount('/webhooks', 'webhooksRouter')] : []), - ...(hasPanel ? [routerMount('/extensions/panel', 'panelRouter')] : []), - ...(hasModal ? [routerMount('/extensions/modal', 'modalRouter')] : []), - ].join('\n'); + 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 = new SourceFileBuilder() .importDefault('express', 'express') @@ -21,7 +21,8 @@ export async function generateApp(outputDir: string, options: GeneratorOptions): .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();\n\n${mounts}`) + .addBlock('const app = express();') + .addBlock(mounts) .exportDefault('app') .build(); diff --git a/src/utils/templates.test.ts b/src/utils/templates.test.ts index 5db8749..b0c5f07 100644 --- a/src/utils/templates.test.ts +++ b/src/utils/templates.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { expressRouterFile, routerMount, envVarAccess } from './templates.js'; +import { expressRouterFile, routerMount, envVarAccess, RouterMountBuilder } from './templates.js'; describe('expressRouterFile', () => { it('returns an express Router import and default export', () => { @@ -25,3 +25,23 @@ describe('envVarAccess', () => { 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 index 0303bd3..2800a0a 100644 --- a/src/utils/templates.ts +++ b/src/utils/templates.ts @@ -9,3 +9,21 @@ export function routerMount(path: string, routerName: string): string { 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'); + } +} From dbe1d1e96b6d4c19ca611f000fa70f07624c59a9 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 11 May 2026 14:54:40 +0300 Subject: [PATCH 25/38] feat(database): add DB driver, drizzle-kit, and db:migrate to generated package.json - Update generatePackageJson to include database-specific drivers (postgres, mysql2, better-sqlite3) - Add sqlite type definitions for better-sqlite3 - Add drizzle-kit to devDependencies - Add db:migrate script to run drizzle-kit migrate - Fix schema.ts primaryKey syntax to use object callback instead of array --- src/generators/node/database.ts | 12 +++++++++--- src/generators/node/index.ts | 22 +++++++++++++++++++--- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/generators/node/database.ts b/src/generators/node/database.ts index 610594a..b15780a 100644 --- a/src/generators/node/database.ts +++ b/src/generators/node/database.ts @@ -37,7 +37,9 @@ function schemaContent(database: GeneratorOptions['database']): string { createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }, - (table) => [primaryKey({ columns: [table.pipedriveCompanyId, table.pipedriveUserId] })], + (table) => ({ + pk: primaryKey({ columns: [table.pipedriveCompanyId, table.pipedriveUserId] }), + }), ); `; } @@ -61,7 +63,9 @@ function schemaContent(database: GeneratorOptions['database']): string { createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }, - (table) => [primaryKey({ columns: [table.pipedriveCompanyId, table.pipedriveUserId] })], + (table) => ({ + pk: primaryKey({ columns: [table.pipedriveCompanyId, table.pipedriveUserId] }), + }), ); `; } @@ -84,7 +88,9 @@ function schemaContent(database: GeneratorOptions['database']): string { createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), }, - (table) => [primaryKey({ columns: [table.pipedriveCompanyId, table.pipedriveUserId] })], + (table) => ({ + pk: primaryKey({ columns: [table.pipedriveCompanyId, table.pipedriveUserId] }), + }), ); `; } diff --git a/src/generators/node/index.ts b/src/generators/node/index.ts index 009838f..d7b4210 100644 --- a/src/generators/node/index.ts +++ b/src/generators/node/index.ts @@ -44,6 +44,18 @@ async function generateServerEntry(outputDir: string): Promise { } async function generatePackageJson(outputDir: string, options: GeneratorOptions): Promise { + const dbDrivers: Record> = { + postgres: { postgres: '^3.4.0' }, + mysql: { mysql2: '^3.9.0' }, + sqlite: { 'better-sqlite3': '^9.4.0' }, + }; + + const dbDevDrivers: Record> = { + postgres: {}, + mysql: {}, + sqlite: { '@types/better-sqlite3': '^7.6.0' }, + }; + const pkg = { name: options.projectName, version: '0.1.0', @@ -52,16 +64,20 @@ async function generatePackageJson(outputDir: string, options: GeneratorOptions) dev: 'tsx src/index.ts', build: 'tsc', typecheck: 'tsc --noEmit', + 'db:migrate': 'drizzle-kit migrate', }, dependencies: { - 'express': '^4.19.0', + express: '^4.19.0', 'drizzle-orm': '^0.30.0', + ...dbDrivers[options.database], }, devDependencies: { - 'typescript': '^5.4.0', + typescript: '^5.4.0', '@types/express': '^4.17.0', '@types/node': '^20.0.0', - 'tsx': '^4.7.0', + tsx: '^4.7.0', + 'drizzle-kit': '^0.20.0', + ...dbDevDrivers[options.database], }, }; await writeFile(join(outputDir, 'package.json'), JSON.stringify(pkg, null, 2)); From 6cab58440733a0846843a6e31ad5011de87af947 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 11 May 2026 15:00:47 +0300 Subject: [PATCH 26/38] fix(database): update drizzle-kit to ^0.21.0 to match drizzle-orm ^0.30.0 --- src/generators/node/index.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/generators/node/index.ts b/src/generators/node/index.ts index d7b4210..70bf842 100644 --- a/src/generators/node/index.ts +++ b/src/generators/node/index.ts @@ -61,22 +61,22 @@ async function generatePackageJson(outputDir: string, options: GeneratorOptions) version: '0.1.0', type: 'module', scripts: { - dev: 'tsx src/index.ts', - build: 'tsc', - typecheck: 'tsc --noEmit', + 'dev': 'tsx src/index.ts', + 'build': 'tsc', + 'typecheck': 'tsc --noEmit', 'db:migrate': 'drizzle-kit migrate', }, dependencies: { - express: '^4.19.0', + 'express': '^4.19.0', 'drizzle-orm': '^0.30.0', ...dbDrivers[options.database], }, devDependencies: { - typescript: '^5.4.0', + 'typescript': '^5.4.0', '@types/express': '^4.17.0', '@types/node': '^20.0.0', - tsx: '^4.7.0', - 'drizzle-kit': '^0.20.0', + 'tsx': '^4.7.0', + 'drizzle-kit': '^0.21.0', ...dbDevDrivers[options.database], }, }; From 5c268e3000a7897b8b31dfffa52c13ee443a0d6e Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 11 May 2026 15:01:43 +0300 Subject: [PATCH 27/38] feat(database): call runMigrations() in generated server entry before listen --- src/generators/node/index.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/generators/node/index.ts b/src/generators/node/index.ts index 70bf842..cf8d0f7 100644 --- a/src/generators/node/index.ts +++ b/src/generators/node/index.ts @@ -33,13 +33,16 @@ async function generateServerEntry(outputDir: string): Promise { await writeFile( join(outputDir, 'src/index.ts'), dedent` - import app from './app.js'; + import { runMigrations } from './database/migrate.js'; + import app from './app.js'; - const PORT = process.env.PORT ?? '3000'; - app.listen(PORT, () => { - console.log(\`Server running on port \${PORT}\`); - }); - `, + const PORT = process.env.PORT ?? '3000'; + + await runMigrations(); + app.listen(PORT, () => { + console.log(\`Server running on port \${PORT}\`); + }); + `, ); } From cafed17d7c101fd773a294e4c019b4dd38b85539 Mon Sep 17 00:00:00 2001 From: Youssef Date: Mon, 11 May 2026 15:06:50 +0300 Subject: [PATCH 28/38] AINATIVEM-44 add husky pre-commit hook to run prettier --- .husky/pre-commit | 1 + package-lock.json | 17 +++++++ package.json | 4 +- src/cli.ts | 4 +- src/generators/node/appExtensions.test.ts | 6 ++- src/generators/node/database.test.ts | 6 ++- src/generators/node/index.test.ts | 6 ++- src/generators/node/index.ts | 8 +-- src/generators/node/oauth.test.ts | 7 ++- src/generators/node/pipedriveClient.test.ts | 6 ++- src/generators/node/pipedriveClient.ts | 10 +--- src/generators/node/projectBuilder.test.ts | 10 ++-- src/generators/node/projectBuilder.ts | 56 +++++++++++++++------ src/generators/node/webhooks.test.ts | 6 ++- src/utils/sourceFileBuilder.test.ts | 20 +++----- src/utils/templates.test.ts | 7 +-- src/utils/writeFile.test.ts | 7 ++- 17 files changed, 121 insertions(+), 60 deletions(-) create mode 100644 .husky/pre-commit 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/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 bda3bf9..753cb20 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "lint": "eslint src", "test": "vitest run", "typecheck": "tsc --noEmit", - "generate": "tsx src/cli.ts apps/test-app" + "generate": "tsx src/cli.ts apps/test-app", + "prepare": "husky" }, "dependencies": { "@clack/prompts": "^0.9.0", @@ -24,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 ad24fc5..74e98f3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -33,9 +33,9 @@ async function main(): Promise { if (installDeps) { const spinner = clack.spinner(); spinner.start('Installing dependencies'); - const ok = await new Promise(resolve => { + const ok = await new Promise((resolve) => { const child = spawn('npm', ['install'], { cwd: outputDir, stdio: 'ignore' }); - child.on('close', code => resolve(code === 0)); + child.on('close', (code) => resolve(code === 0)); }); spinner.stop(ok ? 'Dependencies installed' : 'npm install failed — run it manually'); } 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..531441e 100644 --- a/src/generators/node/database.test.ts +++ b/src/generators/node/database.test.ts @@ -5,7 +5,11 @@ 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 exists = (p: string) => + access(p).then( + () => true, + () => false, + ); const options: GeneratorOptions = { projectName: 'test-app', database: 'postgres', diff --git a/src/generators/node/index.test.ts b/src/generators/node/index.test.ts index b3126e9..1e4a1e5 100644 --- a/src/generators/node/index.test.ts +++ b/src/generators/node/index.test.ts @@ -7,7 +7,11 @@ 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 }); diff --git a/src/generators/node/index.ts b/src/generators/node/index.ts index 048fd1a..70b1c9f 100644 --- a/src/generators/node/index.ts +++ b/src/generators/node/index.ts @@ -7,10 +7,10 @@ export const nodeGenerator: Generator = { .addOAuth() .addDatabase() .addApp() - .when(options.webhooks, b => b.addWebhooks()) - .when(options.database === 'postgres', b => b.addPostgres()) - .when(options.database === 'mysql', b => b.addMySQL()) - .when(options.appExtensions.length > 0, b => b.addAppExtensions()) + .when(options.webhooks, (b) => b.addWebhooks()) + .when(options.database === 'postgres', (b) => b.addPostgres()) + .when(options.database === 'mysql', (b) => b.addMySQL()) + .when(options.appExtensions.length > 0, (b) => b.addAppExtensions()) .addPipedriveClient() .addServerEntry() .addPackageJson() 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/pipedriveClient.test.ts b/src/generators/node/pipedriveClient.test.ts index 9c60107..ae96ad6 100644 --- a/src/generators/node/pipedriveClient.test.ts +++ b/src/generators/node/pipedriveClient.test.ts @@ -5,7 +5,11 @@ 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 exists = (p: string) => + access(p).then( + () => true, + () => false, + ); const options: GeneratorOptions = { projectName: 'test-app', database: 'postgres', diff --git a/src/generators/node/pipedriveClient.ts b/src/generators/node/pipedriveClient.ts index 70a325c..44cb92a 100644 --- a/src/generators/node/pipedriveClient.ts +++ b/src/generators/node/pipedriveClient.ts @@ -3,15 +3,9 @@ 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 { +export async function generatePipedriveClient(outputDir: string, _options: GeneratorOptions): Promise { // pipedrive v21 ships no .d.ts files; this shim satisfies tsc - await writeFile( - join(outputDir, 'src/pipedrive/pipedrive.d.ts'), - `declare module 'pipedrive';\n`, - ); + await writeFile(join(outputDir, 'src/pipedrive/pipedrive.d.ts'), `declare module 'pipedrive';\n`); await writeFile( join(outputDir, 'src/pipedrive/client.ts'), diff --git a/src/generators/node/projectBuilder.test.ts b/src/generators/node/projectBuilder.test.ts index 7d89b54..49774e4 100644 --- a/src/generators/node/projectBuilder.test.ts +++ b/src/generators/node/projectBuilder.test.ts @@ -11,14 +11,18 @@ const options: GeneratorOptions = { }; function spyStep(tracker: string[], label: string): BuildStep { - return { execute: async () => { tracker.push(label); } }; + 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'))) + .when(true, (b) => b.addStep(spyStep(executed, 'webhooks'))) .build(); expect(executed).toContain('webhooks'); }); @@ -26,7 +30,7 @@ describe('NodeProjectBuilder', () => { it('when(false) skips the step', async () => { const executed: string[] = []; await new NodeProjectBuilder('/tmp', options) - .when(false, b => b.addStep(spyStep(executed, 'webhooks'))) + .when(false, (b) => b.addStep(spyStep(executed, 'webhooks'))) .build(); expect(executed).toHaveLength(0); }); diff --git a/src/generators/node/projectBuilder.ts b/src/generators/node/projectBuilder.ts index 6dbb047..9342e9e 100644 --- a/src/generators/node/projectBuilder.ts +++ b/src/generators/node/projectBuilder.ts @@ -78,15 +78,15 @@ class PackageJsonStep implements BuildStep { typecheck: 'tsc --noEmit', }, dependencies: { - express: '^4.19.0', + 'express': '^4.19.0', 'drizzle-orm': '^0.30.0', - pipedrive: '^21.0.0', + 'pipedrive': '^21.0.0', }, devDependencies: { - typescript: '^5.4.0', + 'typescript': '^5.4.0', '@types/express': '^4.17.0', '@types/node': '^20.0.0', - tsx: '^4.7.0', + 'tsx': '^4.7.0', }, }; await writeFile(join(outputDir, 'package.json'), JSON.stringify(pkg, null, 2)); @@ -189,18 +189,42 @@ export class NodeProjectBuilder { 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()); } - addPostgres(): this { return this.addStep(new PostgresDockerStep()); } - addMySQL(): this { return this.addStep(new MySQLDockerStep()); } - 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()); } + 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()); + } + addPostgres(): this { + return this.addStep(new PostgresDockerStep()); + } + addMySQL(): this { + return this.addStep(new MySQLDockerStep()); + } + 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); 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 index 0af95a9..c309abb 100644 --- a/src/utils/sourceFileBuilder.test.ts +++ b/src/utils/sourceFileBuilder.test.ts @@ -23,10 +23,7 @@ describe('SourceFileBuilder', () => { }); it('merges default and named imports from the same source into one line', () => { - const out = new SourceFileBuilder() - .importDefault('express', 'express') - .import('express', ['Router']) - .build(); + 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'); @@ -43,9 +40,7 @@ describe('SourceFileBuilder', () => { }); it('importDefaultIf skips when condition is false', () => { - const out = new SourceFileBuilder() - .importDefaultIf(false, './webhooks.js', 'webhooksRouter') - .build(); + const out = new SourceFileBuilder().importDefaultIf(false, './webhooks.js', 'webhooksRouter').build(); expect(out).not.toContain('webhooks'); }); @@ -70,17 +65,14 @@ describe('SourceFileBuilder', () => { }); it('exportDefault appends export statement', () => { - const out = new SourceFileBuilder() - .addBlock('const app = {};') - .exportDefault('app') - .build(); + 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'); + 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', () => { diff --git a/src/utils/templates.test.ts b/src/utils/templates.test.ts index b0c5f07..5e9efdd 100644 --- a/src/utils/templates.test.ts +++ b/src/utils/templates.test.ts @@ -18,7 +18,7 @@ describe('routerMount', () => { describe('envVarAccess', () => { it('returns process.env.KEY without fallback', () => { - expect(envVarAccess('PORT')).toBe("process.env.PORT"); + expect(envVarAccess('PORT')).toBe('process.env.PORT'); }); it('returns process.env.KEY ?? fallback with fallback', () => { @@ -28,10 +28,7 @@ describe('envVarAccess', () => { describe('RouterMountBuilder', () => { it('builds mount statements in insertion order', () => { - const out = new RouterMountBuilder() - .add('/oauth', 'oauthRouter') - .add('/webhooks', 'webhooksRouter') - .build(); + const out = new RouterMountBuilder().add('/oauth', 'oauthRouter').add('/webhooks', 'webhooksRouter').build(); expect(out).toBe("app.use('/oauth', oauthRouter);\napp.use('/webhooks', webhooksRouter);"); }); 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 () => { From 540361946d4c4fc117ce31e62a60431d7688da00 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 11 May 2026 15:13:37 +0300 Subject: [PATCH 29/38] fix(database): async SQLite runMigrations, MySQL createPool, fix test description --- src/generators/node/database.test.ts | 32 ++++++++++++++++------------ src/generators/node/database.ts | 11 ++++------ 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/generators/node/database.test.ts b/src/generators/node/database.test.ts index 3e53252..9d82082 100644 --- a/src/generators/node/database.test.ts +++ b/src/generators/node/database.test.ts @@ -5,7 +5,11 @@ 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 exists = (p: string) => + access(p).then( + () => true, + () => false, + ); const read = (p: string) => readFile(join(tmpDir, p), 'utf-8'); afterEach(async () => { @@ -13,24 +17,24 @@ afterEach(async () => { }); const pgOptions: GeneratorOptions = { - projectName: 'test-app', - database: 'postgres', - webhooks: false, - appExtensions: [], + projectName: 'test-app', + database: 'postgres', + webhooks: false, + appExtensions: [], }; const mysqlOptions: GeneratorOptions = { - projectName: 'test-app', - database: 'mysql', - webhooks: false, - appExtensions: [], + projectName: 'test-app', + database: 'mysql', + webhooks: false, + appExtensions: [], }; const sqliteOptions: GeneratorOptions = { - projectName: 'test-app', - database: 'sqlite', - webhooks: false, - appExtensions: [], + projectName: 'test-app', + database: 'sqlite', + webhooks: false, + appExtensions: [], }; describe('generateDatabase — schema.ts', () => { @@ -132,7 +136,7 @@ describe('generateDatabase — 0000_init.sql', () => { expect(content).toContain('pipedrive_user_id'); }); - it('postgres migration uses SERIAL and TIMESTAMP', async () => { + 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'); diff --git a/src/generators/node/database.ts b/src/generators/node/database.ts index b15780a..174d9a7 100644 --- a/src/generators/node/database.ts +++ b/src/generators/node/database.ts @@ -118,8 +118,8 @@ function dbClientContent(database: GeneratorOptions['database']): string { import mysql from 'mysql2/promise'; import * as schema from './schema.js'; - const connection = await mysql.createConnection(process.env.DATABASE_URL!); - export const db = drizzle(connection, { schema }); + const pool = mysql.createPool(process.env.DATABASE_URL!); + export const db = drizzle(pool, { schema }); `; } @@ -165,7 +165,7 @@ function migrateContent(database: GeneratorOptions['database']): string { import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; import { db } from './index.js'; - export function runMigrations(): void { + export async function runMigrations(): Promise { migrate(db, { migrationsFolder: 'src/database/migrations' }); } `; @@ -240,10 +240,7 @@ async function generateDrizzleConfig(outputDir: string, options: GeneratorOption sqlite: 'sqlite', }; const dialect = dialectMap[options.database]; - const url = - options.database === 'sqlite' - ? `process.env.DATABASE_URL ?? './data.db'` - : `process.env.DATABASE_URL!`; + const url = options.database === 'sqlite' ? `process.env.DATABASE_URL ?? './data.db'` : `process.env.DATABASE_URL!`; const content = dedent` import { defineConfig } from 'drizzle-kit'; From e6a9e524d00eb1ee8d8e040ac02e1b49eda37ba2 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 11 May 2026 15:17:49 +0300 Subject: [PATCH 30/38] style: apply prettier formatting to test files and spec doc --- docs/specs/2026-05-11-database-layer-design.md | 17 ++++++++++++----- src/generators/node/appExtensions.test.ts | 6 +++++- src/generators/node/index.test.ts | 6 +++++- src/generators/node/oauth.test.ts | 7 ++++++- src/generators/node/webhooks.test.ts | 6 +++++- src/utils/writeFile.test.ts | 7 ++++++- 6 files changed, 39 insertions(+), 10 deletions(-) diff --git a/docs/specs/2026-05-11-database-layer-design.md b/docs/specs/2026-05-11-database-layer-design.md index 587f04c..a76a8a8 100644 --- a/docs/specs/2026-05-11-database-layer-design.md +++ b/docs/specs/2026-05-11-database-layer-design.md @@ -34,8 +34,8 @@ Table name: `pipedrive_tokens` | Column | Type | Notes | |---|---|---| -| `id` | serial PK | | -| `company_id` | integer UNIQUE NOT NULL | from Pipedrive `/users/me` | +| `pipedrive_company_id` | integer NOT NULL | from `/users/me`, part of composite PK | +| `pipedrive_user_id` | integer NOT NULL | from `/users/me`, part of composite PK | | `access_token` | varchar(768) NOT NULL | Pipedrive recommends 768 min | | `refresh_token` | varchar(768) NOT NULL | | | `token_type` | varchar(50) NOT NULL DEFAULT 'bearer' | | @@ -46,9 +46,16 @@ Table name: `pipedrive_tokens` | `created_at` | timestamp NOT NULL DEFAULT now() | | | `updated_at` | timestamp NOT NULL DEFAULT now() | | +**Primary key:** composite on `(pipedrive_company_id, pipedrive_user_id)` — each Pipedrive user within a company gets their own token row. + +In Drizzle: +```ts +(table) => [primaryKey({ columns: [table.pipedriveCompanyId, table.pipedriveUserId] })] +``` + ### Installation status -No separate installations table. A company is considered "installed" if a row exists in `pipedrive_tokens` with a non-expired `refresh_token_expires_at`. If the refresh token is absent or expired, the app is not installed. +No separate installations table. A user+company combination is considered "installed" if a row exists in `pipedrive_tokens` with a non-expired `refresh_token_expires_at`. If the row is absent or the refresh token is expired, the app is not installed for that user. ## Driver Mapping @@ -100,5 +107,5 @@ Tests per database choice: - `npm run db:migrate` runs `drizzle-kit migrate` as a standalone command - SQLite uses a local file (`./data.db`), no Docker required - Postgres and MySQL include `docker-compose.yml` with healthchecks -- `pipedrive_tokens` table is created by the initial migration -- Installation status is determined by the presence of a non-expired `refresh_token_expires_at` row — no separate installations table +- `pipedrive_tokens` table is created by the initial migration with composite PK on `(pipedrive_company_id, pipedrive_user_id)` +- Installation status is determined by the presence of a non-expired `refresh_token_expires_at` row for the given company+user pair — no separate installations table 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/index.test.ts b/src/generators/node/index.test.ts index 1ea9071..c7440c8 100644 --- a/src/generators/node/index.test.ts +++ b/src/generators/node/index.test.ts @@ -7,7 +7,11 @@ 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 }); 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/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/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 () => { From 8d80e4f1e0821395c8312705cde9370e9d5646fa Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 11 May 2026 15:23:43 +0300 Subject: [PATCH 31/38] style: apply prettier formatting to source and test files --- src/cli.ts | 4 +- src/generators/node/database.ts | 1 - src/generators/node/pipedriveClient.test.ts | 6 +- src/generators/node/pipedriveClient.ts | 10 +--- src/generators/node/projectBuilder.test.ts | 16 ++++-- src/generators/node/projectBuilder.ts | 62 ++++++++++++++------- src/utils/sourceFileBuilder.test.ts | 20 ++----- src/utils/templates.test.ts | 7 +-- 8 files changed, 72 insertions(+), 54 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index ad24fc5..74e98f3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -33,9 +33,9 @@ async function main(): Promise { if (installDeps) { const spinner = clack.spinner(); spinner.start('Installing dependencies'); - const ok = await new Promise(resolve => { + const ok = await new Promise((resolve) => { const child = spawn('npm', ['install'], { cwd: outputDir, stdio: 'ignore' }); - child.on('close', code => resolve(code === 0)); + child.on('close', (code) => resolve(code === 0)); }); spinner.stop(ok ? 'Dependencies installed' : 'npm install failed — run it manually'); } diff --git a/src/generators/node/database.ts b/src/generators/node/database.ts index 4fa3b90..529c20f 100644 --- a/src/generators/node/database.ts +++ b/src/generators/node/database.ts @@ -255,4 +255,3 @@ async function generateDrizzleConfig(outputDir: string, options: GeneratorOption `; await writeFile(join(outputDir, 'drizzle.config.ts'), content); } - diff --git a/src/generators/node/pipedriveClient.test.ts b/src/generators/node/pipedriveClient.test.ts index 9c60107..ae96ad6 100644 --- a/src/generators/node/pipedriveClient.test.ts +++ b/src/generators/node/pipedriveClient.test.ts @@ -5,7 +5,11 @@ 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 exists = (p: string) => + access(p).then( + () => true, + () => false, + ); const options: GeneratorOptions = { projectName: 'test-app', database: 'postgres', diff --git a/src/generators/node/pipedriveClient.ts b/src/generators/node/pipedriveClient.ts index 70a325c..44cb92a 100644 --- a/src/generators/node/pipedriveClient.ts +++ b/src/generators/node/pipedriveClient.ts @@ -3,15 +3,9 @@ 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 { +export async function generatePipedriveClient(outputDir: string, _options: GeneratorOptions): Promise { // pipedrive v21 ships no .d.ts files; this shim satisfies tsc - await writeFile( - join(outputDir, 'src/pipedrive/pipedrive.d.ts'), - `declare module 'pipedrive';\n`, - ); + await writeFile(join(outputDir, 'src/pipedrive/pipedrive.d.ts'), `declare module 'pipedrive';\n`); await writeFile( join(outputDir, 'src/pipedrive/client.ts'), diff --git a/src/generators/node/projectBuilder.test.ts b/src/generators/node/projectBuilder.test.ts index cf7ca71..d7adb23 100644 --- a/src/generators/node/projectBuilder.test.ts +++ b/src/generators/node/projectBuilder.test.ts @@ -7,7 +7,11 @@ import { NodeProjectBuilder } from './projectBuilder.js'; import type { BuildStep } from './projectBuilder.js'; const tmpDir = join(tmpdir(), 'cpa-projectbuilder-test'); -const exists = (p: string) => access(p).then(() => true, () => false); +const exists = (p: string) => + access(p).then( + () => true, + () => false, + ); const read = (p: string) => readFile(join(tmpDir, p), 'utf-8'); afterEach(async () => { @@ -22,14 +26,18 @@ const options: GeneratorOptions = { }; function spyStep(tracker: string[], label: string): BuildStep { - return { execute: async () => { tracker.push(label); } }; + 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'))) + .when(true, (b) => b.addStep(spyStep(executed, 'webhooks'))) .build(); expect(executed).toContain('webhooks'); }); @@ -37,7 +45,7 @@ describe('NodeProjectBuilder', () => { it('when(false) skips the step', async () => { const executed: string[] = []; await new NodeProjectBuilder('/tmp', options) - .when(false, b => b.addStep(spyStep(executed, 'webhooks'))) + .when(false, (b) => b.addStep(spyStep(executed, 'webhooks'))) .build(); expect(executed).toHaveLength(0); }); diff --git a/src/generators/node/projectBuilder.ts b/src/generators/node/projectBuilder.ts index 6e4f7bc..b6376bf 100644 --- a/src/generators/node/projectBuilder.ts +++ b/src/generators/node/projectBuilder.ts @@ -88,22 +88,22 @@ class PackageJsonStep implements BuildStep { version: '0.1.0', type: 'module', scripts: { - dev: 'tsx src/index.ts', - build: 'tsc', - typecheck: 'tsc --noEmit', + 'dev': 'tsx src/index.ts', + 'build': 'tsc', + 'typecheck': 'tsc --noEmit', 'db:migrate': 'drizzle-kit migrate', }, dependencies: { - express: '^4.19.0', + 'express': '^4.19.0', 'drizzle-orm': '^0.30.0', - pipedrive: '^21.0.0', + 'pipedrive': '^21.0.0', ...dbDrivers[options.database], }, devDependencies: { - typescript: '^5.4.0', + 'typescript': '^5.4.0', '@types/express': '^4.17.0', '@types/node': '^20.0.0', - tsx: '^4.7.0', + 'tsx': '^4.7.0', 'drizzle-kit': '^0.21.0', ...dbDevDrivers[options.database], }, @@ -218,18 +218,42 @@ export class NodeProjectBuilder { 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()); } - addPostgres(): this { return this.addStep(new PostgresDockerStep()); } - addMySQL(): this { return this.addStep(new MySQLDockerStep()); } - 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()); } + 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()); + } + addPostgres(): this { + return this.addStep(new PostgresDockerStep()); + } + addMySQL(): this { + return this.addStep(new MySQLDockerStep()); + } + 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); diff --git a/src/utils/sourceFileBuilder.test.ts b/src/utils/sourceFileBuilder.test.ts index 0af95a9..c309abb 100644 --- a/src/utils/sourceFileBuilder.test.ts +++ b/src/utils/sourceFileBuilder.test.ts @@ -23,10 +23,7 @@ describe('SourceFileBuilder', () => { }); it('merges default and named imports from the same source into one line', () => { - const out = new SourceFileBuilder() - .importDefault('express', 'express') - .import('express', ['Router']) - .build(); + 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'); @@ -43,9 +40,7 @@ describe('SourceFileBuilder', () => { }); it('importDefaultIf skips when condition is false', () => { - const out = new SourceFileBuilder() - .importDefaultIf(false, './webhooks.js', 'webhooksRouter') - .build(); + const out = new SourceFileBuilder().importDefaultIf(false, './webhooks.js', 'webhooksRouter').build(); expect(out).not.toContain('webhooks'); }); @@ -70,17 +65,14 @@ describe('SourceFileBuilder', () => { }); it('exportDefault appends export statement', () => { - const out = new SourceFileBuilder() - .addBlock('const app = {};') - .exportDefault('app') - .build(); + 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'); + 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', () => { diff --git a/src/utils/templates.test.ts b/src/utils/templates.test.ts index b0c5f07..5e9efdd 100644 --- a/src/utils/templates.test.ts +++ b/src/utils/templates.test.ts @@ -18,7 +18,7 @@ describe('routerMount', () => { describe('envVarAccess', () => { it('returns process.env.KEY without fallback', () => { - expect(envVarAccess('PORT')).toBe("process.env.PORT"); + expect(envVarAccess('PORT')).toBe('process.env.PORT'); }); it('returns process.env.KEY ?? fallback with fallback', () => { @@ -28,10 +28,7 @@ describe('envVarAccess', () => { describe('RouterMountBuilder', () => { it('builds mount statements in insertion order', () => { - const out = new RouterMountBuilder() - .add('/oauth', 'oauthRouter') - .add('/webhooks', 'webhooksRouter') - .build(); + const out = new RouterMountBuilder().add('/oauth', 'oauthRouter').add('/webhooks', 'webhooksRouter').build(); expect(out).toBe("app.use('/oauth', oauthRouter);\napp.use('/webhooks', webhooksRouter);"); }); From a08dbfe00136ffe3430e4107048bf6749dc1c839 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 11 May 2026 15:24:49 +0300 Subject: [PATCH 32/38] chore: remove database layer design spec from implementation branch --- .../specs/2026-05-11-database-layer-design.md | 111 ------------------ 1 file changed, 111 deletions(-) delete mode 100644 docs/specs/2026-05-11-database-layer-design.md diff --git a/docs/specs/2026-05-11-database-layer-design.md b/docs/specs/2026-05-11-database-layer-design.md deleted file mode 100644 index a76a8a8..0000000 --- a/docs/specs/2026-05-11-database-layer-design.md +++ /dev/null @@ -1,111 +0,0 @@ -# Database Layer Generator — Design Spec - -**Jira:** AINATIVEM-43 -**Date:** 2026-05-11 -**Status:** Approved - -## Overview - -Flesh out `src/generators/node/database.ts` from its current stub into a full database layer generator. The generator produces a working Drizzle ORM setup for the scaffolded app, supporting Postgres, MySQL, and SQLite. Migrations run automatically on server startup (idempotent via Drizzle's migration table) and can also be triggered manually via `npm run db:migrate`. - -## Generated File Structure - -For all database choices: - -``` -src/database/ - index.ts # Drizzle client + driver setup - schema.ts # Drizzle table definitions (pipedrive_tokens) - migrate.ts # runMigrations() — programmatic Drizzle migrate API - migrations/ - 0000_init.sql # Pre-generated initial SQL (hardcoded in generator) -drizzle.config.ts # Drizzle kit config (for npm run db:migrate + drizzle-kit tooling) -``` - -For Postgres and MySQL only (not SQLite): - -``` -docker-compose.yml # Moved from index.ts generator, gains healthchecks -``` - -## Schema - -Table name: `pipedrive_tokens` - -| Column | Type | Notes | -|---|---|---| -| `pipedrive_company_id` | integer NOT NULL | from `/users/me`, part of composite PK | -| `pipedrive_user_id` | integer NOT NULL | from `/users/me`, part of composite PK | -| `access_token` | varchar(768) NOT NULL | Pipedrive recommends 768 min | -| `refresh_token` | varchar(768) NOT NULL | | -| `token_type` | varchar(50) NOT NULL DEFAULT 'bearer' | | -| `access_token_expires_at` | timestamp NOT NULL | derived from `expires_in` | -| `refresh_token_expires_at` | timestamp NOT NULL | 60 days from last refresh | -| `scope` | text | nullable | -| `api_domain` | varchar(255) NOT NULL | company-specific Pipedrive API base URL | -| `created_at` | timestamp NOT NULL DEFAULT now() | | -| `updated_at` | timestamp NOT NULL DEFAULT now() | | - -**Primary key:** composite on `(pipedrive_company_id, pipedrive_user_id)` — each Pipedrive user within a company gets their own token row. - -In Drizzle: -```ts -(table) => [primaryKey({ columns: [table.pipedriveCompanyId, table.pipedriveUserId] })] -``` - -### Installation status - -No separate installations table. A user+company combination is considered "installed" if a row exists in `pipedrive_tokens` with a non-expired `refresh_token_expires_at`. If the row is absent or the refresh token is expired, the app is not installed for that user. - -## Driver Mapping - -| Database | Driver | Drizzle adapter | -|---|---|---| -| Postgres | `postgres-js` | `drizzle-orm/postgres-js` | -| MySQL | `mysql2` | `drizzle-orm/mysql2` | -| SQLite | `better-sqlite3` | `drizzle-orm/better-sqlite3` | - -`src/database/index.ts` branches on the chosen database to import the correct driver and initialise the Drizzle client. All three use `DATABASE_URL` from env (SQLite treats it as the local file path, e.g. `./data.db`). - -## Migration Flow - -### On startup (primary path) -`src/index.ts` calls `await runMigrations()` before `app.listen()`. `runMigrations()` is exported from `src/database/migrate.ts` and calls Drizzle's programmatic `migrate(db, { migrationsFolder: 'src/database/migrations' })`. Drizzle tracks applied migrations in `__drizzle_migrations`, making repeated calls safe. - -### Standalone / CI (secondary path) -`package.json` includes `"db:migrate": "drizzle-kit migrate"` for deployment pipelines or manual runs. - -### Pre-generated migration -`0000_init.sql` is a hardcoded string inside the generator — developers get a working migration immediately without running `drizzle-kit generate`. - -## Generator Changes - -### `src/generators/node/database.ts` (primary work) -Generates all files above. Branches on `options.database` to produce the correct `src/database/index.ts` driver setup, the correct SQL dialect in `0000_init.sql`, and `docker-compose.yml` (Postgres/MySQL only). Docker Compose gains healthchecks: -- Postgres: `pg_isready -U app` -- MySQL: `mysqladmin ping -h localhost -u app --password=app` - -### `src/generators/node/index.ts` (minor changes) -- Remove `generateDockerCompose` function (moved to database module) -- `generateServerEntry` gains `import { runMigrations } from './database/migrate.js'` and `await runMigrations()` before the listen call -- `generatePackageJson` adds: correct DB driver in `dependencies`, `drizzle-kit` in `devDependencies`, `db:migrate` script - -### `src/generators/node/database.test.ts` (updated) -Tests per database choice: -- `src/database/index.ts` exists and exports a Drizzle client -- `src/database/schema.ts` exists and references `pipedrive_tokens` -- `src/database/migrate.ts` exists and exports `runMigrations` -- `src/database/migrations/0000_init.sql` exists and contains `CREATE TABLE` -- `drizzle.config.ts` exists -- `docker-compose.yml` is generated for postgres and mysql, not for sqlite -- `docker-compose.yml` contains a healthcheck for postgres and mysql - -## Acceptance Criteria - -- Generated project connects to the chosen database via the correct Drizzle driver -- `runMigrations()` is called in `src/index.ts` before the server starts listening -- `npm run db:migrate` runs `drizzle-kit migrate` as a standalone command -- SQLite uses a local file (`./data.db`), no Docker required -- Postgres and MySQL include `docker-compose.yml` with healthchecks -- `pipedrive_tokens` table is created by the initial migration with composite PK on `(pipedrive_company_id, pipedrive_user_id)` -- Installation status is determined by the presence of a non-expired `refresh_token_expires_at` row for the given company+user pair — no separate installations table From 42e26ddf20102f3cc3c5adf4d77b845d0ad30984 Mon Sep 17 00:00:00 2001 From: Youssef Date: Mon, 11 May 2026 15:49:10 +0300 Subject: [PATCH 33/38] AINATIVEM-44 update pipedrive client to v32 with v2/v1 namespace imports --- src/generators/node/pipedriveClient.test.ts | 24 ++++++----- src/generators/node/pipedriveClient.ts | 47 ++++++++++----------- src/generators/node/projectBuilder.ts | 2 +- 3 files changed, 36 insertions(+), 37 deletions(-) diff --git a/src/generators/node/pipedriveClient.test.ts b/src/generators/node/pipedriveClient.test.ts index ae96ad6..4dcf7d5 100644 --- a/src/generators/node/pipedriveClient.test.ts +++ b/src/generators/node/pipedriveClient.test.ts @@ -35,30 +35,32 @@ describe('generatePipedriveClient', () => { expect(content).toContain('export async function getClient'); }); - it('imports Configuration, DealsApi, PersonsApi, OrganizationsApi from pipedrive', async () => { + 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'"); - expect(content).toContain('Configuration'); - expect(content).toContain('DealsApi'); - expect(content).toContain('PersonsApi'); - expect(content).toContain('OrganizationsApi'); + 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 refreshStoredToken placeholder functions', async () => { + 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('refreshStoredToken'); + expect(content).toContain('saveToken'); }); - it('checks token expiry before returning client', async () => { + 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('expiresAt'); - expect(content).toContain('new Date()'); + 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 index 44cb92a..bdb4c11 100644 --- a/src/generators/node/pipedriveClient.ts +++ b/src/generators/node/pipedriveClient.ts @@ -4,44 +4,41 @@ import { writeFile } from '../../utils/writeFile.js'; import type { GeneratorOptions } from '../interface.js'; export async function generatePipedriveClient(outputDir: string, _options: GeneratorOptions): Promise { - // pipedrive v21 ships no .d.ts files; this shim satisfies tsc - await writeFile(join(outputDir, 'src/pipedrive/pipedrive.d.ts'), `declare module 'pipedrive';\n`); - await writeFile( join(outputDir, 'src/pipedrive/client.ts'), dedent` - import { Configuration, DealsApi, PersonsApi, OrganizationsApi } from 'pipedrive'; + import * as v2 from 'pipedrive/v2'; + import * as v1 from 'pipedrive/v1'; - interface TokenRecord { - accessToken: string; - expiresAt: Date; - } + 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 { + async function getStoredToken(_companyId: number): Promise { throw new Error('getStoredToken not implemented — wire up database module'); } - // TODO: replace with oauth module call - async function refreshStoredToken(_companyId: number): Promise { - throw new Error('refreshStoredToken not implemented — wire up oauth module'); - } - - async function getValidToken(companyId: number): Promise { - let token = await getStoredToken(companyId); - if (token.expiresAt <= new Date()) { - token = await refreshStoredToken(companyId); - } - return token.accessToken; + // 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 accessToken = await getValidToken(companyId); - const config = new Configuration({ accessToken }); + 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 DealsApi(config), - persons: new PersonsApi(config), - organizations: new OrganizationsApi(config), + 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.ts b/src/generators/node/projectBuilder.ts index 9342e9e..d7d5f14 100644 --- a/src/generators/node/projectBuilder.ts +++ b/src/generators/node/projectBuilder.ts @@ -80,7 +80,7 @@ class PackageJsonStep implements BuildStep { dependencies: { 'express': '^4.19.0', 'drizzle-orm': '^0.30.0', - 'pipedrive': '^21.0.0', + 'pipedrive': '^32.0.0', }, devDependencies: { 'typescript': '^5.4.0', From 134d378125cad81553e2c14fa967ed1dadff5373 Mon Sep 17 00:00:00 2001 From: Youssef Date: Mon, 11 May 2026 15:55:44 +0300 Subject: [PATCH 34/38] AINATIVEM-44 add .nvmrc pinned to Node.js 22 --- .nvmrc | 1 + 1 file changed, 1 insertion(+) create mode 100644 .nvmrc diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 From c57a580a8fefd3c5aa87f9dcccb7041e6a50af66 Mon Sep 17 00:00:00 2001 From: Youssef Date: Mon, 11 May 2026 15:55:44 +0300 Subject: [PATCH 35/38] AINATIVEM-44 add .nvmrc pinned to Node.js 22 --- .nvmrc | 1 + 1 file changed, 1 insertion(+) create mode 100644 .nvmrc diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 From 5c38decbf156e0c48cdaef55bb0475eeb7ccf06b Mon Sep 17 00:00:00 2001 From: Youssef Date: Mon, 11 May 2026 16:14:17 +0300 Subject: [PATCH 36/38] AINATIVEM-44 move docker-compose into database generator, add builder pattern docs --- CLAUDE.md | 26 +++++++++ src/generators/node/database.test.ts | 27 +++++++++ src/generators/node/database.ts | 52 ++++++++++++++++++ src/generators/node/index.ts | 2 - src/generators/node/projectBuilder.test.ts | 21 ------- src/generators/node/projectBuilder.ts | 64 ---------------------- 6 files changed, 105 insertions(+), 87 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d2496b5..04edaf7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,6 +70,32 @@ cli.ts (collects prompts) - **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: diff --git a/src/generators/node/database.test.ts b/src/generators/node/database.test.ts index d73f06a..b9fd863 100644 --- a/src/generators/node/database.test.ts +++ b/src/generators/node/database.test.ts @@ -184,3 +184,30 @@ describe('generateDatabase — 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 529c20f..1c7a2b9 100644 --- a/src/generators/node/database.ts +++ b/src/generators/node/database.ts @@ -9,6 +9,9 @@ export async function generateDatabase(outputDir: string, options: GeneratorOpti await generateMigrate(outputDir, options); await generateMigrationSql(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 { @@ -232,6 +235,55 @@ function migrationSqlContent(database: GeneratorOptions['database']): string { `; } +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', diff --git a/src/generators/node/index.ts b/src/generators/node/index.ts index 70b1c9f..a284bd3 100644 --- a/src/generators/node/index.ts +++ b/src/generators/node/index.ts @@ -8,8 +8,6 @@ export const nodeGenerator: Generator = { .addDatabase() .addApp() .when(options.webhooks, (b) => b.addWebhooks()) - .when(options.database === 'postgres', (b) => b.addPostgres()) - .when(options.database === 'mysql', (b) => b.addMySQL()) .when(options.appExtensions.length > 0, (b) => b.addAppExtensions()) .addPipedriveClient() .addServerEntry() diff --git a/src/generators/node/projectBuilder.test.ts b/src/generators/node/projectBuilder.test.ts index d7adb23..bbde496 100644 --- a/src/generators/node/projectBuilder.test.ts +++ b/src/generators/node/projectBuilder.test.ts @@ -67,24 +67,3 @@ describe('NodeProjectBuilder', () => { }); }); -describe('PostgresDockerStep', () => { - it('generates docker-compose.yml with healthcheck', async () => { - await new NodeProjectBuilder(tmpDir, options).addPostgres().build(); - 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('healthcheck'); - expect(content).toContain('pg_isready'); - }); -}); - -describe('MySQLDockerStep', () => { - it('generates docker-compose.yml with healthcheck', async () => { - const mysqlOptions: GeneratorOptions = { ...options, database: 'mysql' }; - await new NodeProjectBuilder(tmpDir, mysqlOptions).addMySQL().build(); - const content = await read('docker-compose.yml'); - expect(content).toContain('mysql:8'); - expect(content).toContain('healthcheck'); - expect(content).toContain('mysqladmin'); - }); -}); diff --git a/src/generators/node/projectBuilder.ts b/src/generators/node/projectBuilder.ts index 6003d62..9f57905 100644 --- a/src/generators/node/projectBuilder.ts +++ b/src/generators/node/projectBuilder.ts @@ -146,64 +146,6 @@ class EnvExampleStep implements BuildStep { } } -class PostgresDockerStep implements BuildStep { - async execute(outputDir: string, options: GeneratorOptions): Promise { - await writeFile( - join(outputDir, 'docker-compose.yml'), - 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: - `, - ); - } -} - -class MySQLDockerStep implements BuildStep { - async execute(outputDir: string, options: GeneratorOptions): Promise { - await writeFile( - join(outputDir, 'docker-compose.yml'), - 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: - `, - ); - } -} export class NodeProjectBuilder { private steps: BuildStep[] = []; @@ -230,12 +172,6 @@ export class NodeProjectBuilder { addWebhooks(): this { return this.addStep(new WebhooksStep()); } - addPostgres(): this { - return this.addStep(new PostgresDockerStep()); - } - addMySQL(): this { - return this.addStep(new MySQLDockerStep()); - } addAppExtensions(): this { return this.addStep(new AppExtensionsStep()); } From cf41ced702af9fc7fdf1ea436f057554acf03bf5 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 11 May 2026 16:15:43 +0300 Subject: [PATCH 37/38] AINATIVEM-43-sqlite-fix --- src/generators/node/database.test.ts | 36 +++++++++++++++++++++++---- src/generators/node/database.ts | 32 ++++++++++++++++++------ src/generators/node/index.ts | 2 +- src/generators/node/projectBuilder.ts | 4 +-- 4 files changed, 59 insertions(+), 15 deletions(-) diff --git a/src/generators/node/database.test.ts b/src/generators/node/database.test.ts index d73f06a..bcc1a56 100644 --- a/src/generators/node/database.test.ts +++ b/src/generators/node/database.test.ts @@ -83,12 +83,12 @@ describe('generateDatabase — src/database/index.ts', () => { expect(content).toContain('drizzle-orm/mysql2'); }); - it('sqlite client uses better-sqlite3', async () => { + 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('better-sqlite3'); - expect(content).toContain('drizzle-orm/better-sqlite3'); + expect(content).toContain('@libsql/client'); + expect(content).toContain('drizzle-orm/libsql'); }); }); @@ -116,11 +116,11 @@ describe('generateDatabase — migrate.ts', () => { expect(content).toContain('mysql2/migrator'); }); - it('sqlite migrate imports from drizzle-orm/better-sqlite3/migrator', async () => { + 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('better-sqlite3/migrator'); + expect(content).toContain('libsql/migrator'); }); }); @@ -153,6 +153,32 @@ describe('generateDatabase — 0000_init.sql', () => { }); }); +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'); diff --git a/src/generators/node/database.ts b/src/generators/node/database.ts index 529c20f..855c1a9 100644 --- a/src/generators/node/database.ts +++ b/src/generators/node/database.ts @@ -8,6 +8,7 @@ export async function generateDatabase(outputDir: string, options: GeneratorOpti await generateDbClient(outputDir, options); await generateMigrate(outputDir, options); await generateMigrationSql(outputDir, options); + await generateMigrationJournal(outputDir, options); await generateDrizzleConfig(outputDir, options); } @@ -123,12 +124,12 @@ function dbClientContent(database: GeneratorOptions['database']): string { } return dedent` - import { drizzle } from 'drizzle-orm/better-sqlite3'; - import Database from 'better-sqlite3'; + import { drizzle } from 'drizzle-orm/libsql'; + import { createClient } from '@libsql/client'; import * as schema from './schema.js'; - const sqlite = new Database(process.env.DATABASE_URL ?? './data.db'); - export const db = drizzle(sqlite, { schema }); + const client = createClient({ url: process.env.DATABASE_URL ?? 'file:./data.db' }); + export const db = drizzle(client, { schema }); `; } @@ -161,11 +162,11 @@ function migrateContent(database: GeneratorOptions['database']): string { } return dedent` - import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; + import { migrate } from 'drizzle-orm/libsql/migrator'; import { db } from './index.js'; export async function runMigrations(): Promise { - migrate(db, { migrationsFolder: 'src/database/migrations' }); + await migrate(db, { migrationsFolder: 'src/database/migrations' }); } `; } @@ -232,6 +233,23 @@ function migrationSqlContent(database: GeneratorOptions['database']): string { `; } +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 generateDrizzleConfig(outputDir: string, options: GeneratorOptions): Promise { const dialectMap: Record = { postgres: 'postgresql', @@ -239,7 +257,7 @@ async function generateDrizzleConfig(outputDir: string, options: GeneratorOption sqlite: 'sqlite', }; const dialect = dialectMap[options.database]; - const url = options.database === 'sqlite' ? `process.env.DATABASE_URL ?? './data.db'` : `process.env.DATABASE_URL!`; + const url = options.database === 'sqlite' ? `process.env.DATABASE_URL ?? 'file:./data.db'` : `process.env.DATABASE_URL!`; const content = dedent` import { defineConfig } from 'drizzle-kit'; diff --git a/src/generators/node/index.ts b/src/generators/node/index.ts index 70b1c9f..d869919 100644 --- a/src/generators/node/index.ts +++ b/src/generators/node/index.ts @@ -4,8 +4,8 @@ import { NodeProjectBuilder } from './projectBuilder.js'; export const nodeGenerator: Generator = { async generate(outputDir: string, options: GeneratorOptions): Promise { await new NodeProjectBuilder(outputDir, options) - .addOAuth() .addDatabase() + .addOAuth() .addApp() .when(options.webhooks, (b) => b.addWebhooks()) .when(options.database === 'postgres', (b) => b.addPostgres()) diff --git a/src/generators/node/projectBuilder.ts b/src/generators/node/projectBuilder.ts index b6376bf..085bd71 100644 --- a/src/generators/node/projectBuilder.ts +++ b/src/generators/node/projectBuilder.ts @@ -74,13 +74,13 @@ class PackageJsonStep implements BuildStep { const dbDrivers: Record> = { postgres: { postgres: '^3.4.0' }, mysql: { mysql2: '^3.9.0' }, - sqlite: { 'better-sqlite3': '^9.4.0' }, + sqlite: { '@libsql/client': '^0.14.0' }, }; const dbDevDrivers: Record> = { postgres: {}, mysql: {}, - sqlite: { '@types/better-sqlite3': '^7.6.0' }, + sqlite: {}, }; const pkg = { From 63eb257cfb33255a5d9f001d89dfb7dc39eb86cc Mon Sep 17 00:00:00 2001 From: Youssef Date: Mon, 11 May 2026 16:17:07 +0300 Subject: [PATCH 38/38] AINATIVEM-44 remove unused imports from projectBuilder.test.ts --- src/generators/node/projectBuilder.test.ts | 18 +----------------- src/generators/node/projectBuilder.ts | 1 - 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/src/generators/node/projectBuilder.test.ts b/src/generators/node/projectBuilder.test.ts index bbde496..49774e4 100644 --- a/src/generators/node/projectBuilder.test.ts +++ b/src/generators/node/projectBuilder.test.ts @@ -1,23 +1,8 @@ -import { afterEach, describe, expect, it } from 'vitest'; -import { access, readFile, rm } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; +import { describe, expect, it } from 'vitest'; import type { GeneratorOptions } from '../interface.js'; import { NodeProjectBuilder } from './projectBuilder.js'; import type { BuildStep } from './projectBuilder.js'; -const tmpDir = join(tmpdir(), 'cpa-projectbuilder-test'); -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 options: GeneratorOptions = { projectName: 'test-app', database: 'postgres', @@ -66,4 +51,3 @@ describe('NodeProjectBuilder', () => { expect(builder.when(false, () => {})).toBe(builder); }); }); - diff --git a/src/generators/node/projectBuilder.ts b/src/generators/node/projectBuilder.ts index 9f57905..3d74b2a 100644 --- a/src/generators/node/projectBuilder.ts +++ b/src/generators/node/projectBuilder.ts @@ -146,7 +146,6 @@ class EnvExampleStep implements BuildStep { } } - export class NodeProjectBuilder { private steps: BuildStep[] = [];