Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
31e70f8
AINATIVEM-44 add oauth + token repository design spec
youssef-saber-3 May 12, 2026
ff813d8
AINATIVEM-44 add oauth + token repository implementation plan
youssef-saber-3 May 12, 2026
dec8b07
AINATIVEM-44 generate token repository with drizzle upsert per dialect
youssef-saber-3 May 12, 2026
e3a07e7
AINATIVEM-44 generate oauth state module with hmac-signed state
youssef-saber-3 May 12, 2026
4c7b3fb
AINATIVEM-44 fix timing-safe hmac comparison and missing secret valid…
youssef-saber-3 May 12, 2026
db193a6
AINATIVEM-44 use base64url buffer encoding in timingSafeEqual comparison
youssef-saber-3 May 12, 2026
18b2aaa
AINATIVEM-44 generate full oauth redirect and callback routes
youssef-saber-3 May 12, 2026
46a6c69
AINATIVEM-44 add api_domain guard and response.ok check in oauth call…
youssef-saber-3 May 12, 2026
6ba956a
AINATIVEM-44 wire pipedrive client to token repository, remove todo s…
youssef-saber-3 May 12, 2026
78a0c74
AINATIVEM-44 create oauth2 instance per getClient call to fix multi-t…
youssef-saber-3 May 12, 2026
3c84b8c
AINATIVEM-44 add PIPEDRIVE_OAUTH_HOST support to oauth generator
youssef-saber-3 May 13, 2026
6b950de
AINATIVEM-44 merge origin/master: keep token repository + migration j…
youssef-saber-3 May 13, 2026
1812ada
AINATIVEM-44 remove PIPEDRIVE_OAUTH_HOST, use live oauth by default
youssef-saber-3 May 13, 2026
1fcc5e4
AINATIVEM-44 move oauth redirect to root, add error handler, strip ap…
youssef-saber-3 May 13, 2026
799386f
AINATIVEM-44 add backend + watch mode to docker-compose, add Dockerfi…
youssef-saber-3 May 13, 2026
e8c635b
AINATIVEM-44 update next steps: docker compose up replaces npm run dev
youssef-saber-3 May 13, 2026
02ad204
AINATIVEM-44 remove webhooks support
youssef-saber-3 May 13, 2026
b1bd31b
AINATIVEM-44 fix docker-compose YAML indentation and use npm install …
youssef-saber-3 May 13, 2026
0497abb
AINATIVEM-44 remove plan files
youssef-saber-3 May 13, 2026
a7a67f5
AINATIVEM-44 merge master — resolve conflicts with AINATIVEM-45 app e…
youssef-saber-3 May 14, 2026
b7debea
AINATIVEM-44 remove unused shouldGenerateDockerCompose function
youssef-saber-3 May 14, 2026
ee115a6
AINATIVEM-44 remove extra blank line
youssef-saber-3 May 14, 2026
04d0ede
AINATIVEM-44 fix generated state.ts module-level throw and unawaited …
youssef-saber-3 May 14, 2026
608e7de
AINATIVEM-44 fix module-level OAuth2Configuration, add src bind mount…
youssef-saber-3 May 14, 2026
dc2f6ca
AINATIVEM-44 remove npm install step from CLI next steps
youssef-saber-3 May 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"lint": "eslint src",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"clean": "rm -rf apps/",
"generate": "tsx src/cli.ts apps/test-app",
"prepare": "husky"
},
Expand Down
9 changes: 3 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { basename, resolve } from 'node:path';
import { promptAppExtensions } from './prompts/appExtensions.js';
import { promptDatabase } from './prompts/database.js';
import { promptProjectName } from './prompts/projectName.js';
import { promptWebhooks } from './prompts/webhooks.js';
import { nodeGenerator } from './generators/node/index.js';

async function main(): Promise<void> {
Expand All @@ -13,13 +12,12 @@ async function main(): Promise<void> {
const nameOrPath = await promptProjectName(process.argv[2]);
const database = await promptDatabase();
const appExtensions = await promptAppExtensions();
const webhooks = await promptWebhooks();

const outputDir = resolve(process.cwd(), nameOrPath);
const projectName = basename(outputDir);

try {
await nodeGenerator.generate(outputDir, { projectName, database, appExtensions, webhooks });
await nodeGenerator.generate(outputDir, { projectName, database, appExtensions });
} catch (error) {
clack.log.error(`Generation failed: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
Expand All @@ -40,13 +38,12 @@ async function main(): Promise<void> {
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(' # fill in PIPEDRIVE_CLIENT_ID and PIPEDRIVE_CLIENT_SECRET');
if (!installDeps) console.log(' npm install');
console.log(' npm run dev');
console.log(' docker compose up');
}

main();
1 change: 0 additions & 1 deletion src/generators/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ export type AppExtensionType = 'custom-panel' | 'custom-modal';
export interface GeneratorOptions {
projectName: string;
database: Database;
webhooks: boolean;
appExtensions: AppExtensionType[];
}

Expand Down
19 changes: 8 additions & 11 deletions src/generators/node/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,40 +21,39 @@ describe('generateApp', () => {
const content = await getAppContent({
projectName: 'test-app',
database: 'postgres',
webhooks: false,
appExtensions: [],
});
expect(content).toContain("from 'express'");
expect(content).toContain("from './oauth/index.js'");
expect(content).toContain('createAuthRedirect');
expect(content).toContain("app.use('/oauth'");
});

it('includes webhooks import and mount when webhooks is true', async () => {
it('has root route that redirects to oauth when not installed', async () => {
const content = await getAppContent({
projectName: 'test-app',
database: 'postgres',
webhooks: true,
appExtensions: [],
});
expect(content).toContain("from './webhooks/index.js'");
expect(content).toContain("app.use('/webhooks'");
expect(content).toContain("app.get('/'");
expect(content).toContain('createAuthRedirect()');
expect(content).toContain('client.deals.getDeals()');
});

it('excludes webhooks when webhooks is false', async () => {
it('has global error handler', async () => {
const content = await getAppContent({
projectName: 'test-app',
database: 'postgres',
webhooks: false,
appExtensions: [],
});
expect(content).not.toContain('./webhooks/index.js');
expect(content).toContain('NextFunction');
expect(content).toContain('res.status(500)');
});

it('includes panel import and mount when custom-panel is selected', async () => {
const content = await getAppContent({
projectName: 'test-app',
database: 'postgres',
webhooks: false,
appExtensions: ['custom-panel'],
});
expect(content).toContain("from './app-extensions/panel/index.js'");
Expand All @@ -65,7 +64,6 @@ describe('generateApp', () => {
const content = await getAppContent({
projectName: 'test-app',
database: 'postgres',
webhooks: false,
appExtensions: ['custom-modal'],
});
expect(content).toContain("from './app-extensions/modal/index.js'");
Expand All @@ -76,7 +74,6 @@ describe('generateApp', () => {
const content = await getAppContent({
projectName: 'test-app',
database: 'sqlite',
webhooks: false,
appExtensions: [],
});
expect(content).not.toContain('./app-extensions');
Expand Down
35 changes: 33 additions & 2 deletions src/generators/node/app.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import dedent from 'dedent';
import { join } from 'path';
import { writeFile } from '../../utils/writeFile.js';
import type { GeneratorOptions } from '../interface.js';
Expand All @@ -10,19 +11,49 @@ export async function generateApp(outputDir: string, options: GeneratorOptions):

const mounts = new RouterMountBuilder()
.add('/oauth', 'oauthRouter')
.addIf(options.webhooks, '/webhooks', 'webhooksRouter')
.addIf(hasPanel, '/extensions/panel', 'panelRouter')
.addIf(hasModal, '/extensions/modal', 'modalRouter')
.build();

const rootRoute = dedent`
app.get('/', async (_req, res, next) => {
try {
const rows = await db.select().from(pipedriveTokens).orderBy(desc(pipedriveTokens.updatedAt)).limit(1);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's implement encryption and decryption, this is what we want to enforce vendors: https://pipedrive.atlassian.net/browse/AINATIVEM-42

src/crypto/encrypt.ts — AES-256-GCM encrypt/decrypt using Node.js built-in crypto; DB stores encrypted_access_token and encrypted_refresh_token

if (!rows[0]) {
res.redirect(createAuthRedirect());
return;
}
const client = await getClient(rows[0].pipedriveCompanyId);
const deals = await client.deals.getDeals();
res.json(deals);
} catch (err) {
next(err);
}
});
`;

const errorHandler = dedent`
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
console.error(err);
res.status(500).send(err.message);
});
`;

const content = new SourceFileBuilder()
.importDefault('express', 'express')
.import('express', ['NextFunction', 'Request', 'Response'])
.importDefault('./oauth/index.js', 'oauthRouter')
.importDefaultIf(options.webhooks, './webhooks/index.js', 'webhooksRouter')
.import('./oauth/index.js', ['createAuthRedirect'])
.import('./pipedrive/client.js', ['getClient'])
.import('./database/index.js', ['db'])
.import('./database/schema.js', ['pipedriveTokens'])
.import('drizzle-orm', ['desc'])
.importDefaultIf(hasPanel, './app-extensions/panel/index.js', 'panelRouter')
.importDefaultIf(hasModal, './app-extensions/modal/index.js', 'modalRouter')
.addBlock('const app = express();')
.addBlock(rootRoute)
.addBlock(mounts)
.addBlock(errorHandler)
.exportDefault('app')
.build();

Expand Down
3 changes: 0 additions & 3 deletions src/generators/node/appExtensions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ describe('generateAppExtensions', () => {
const options: GeneratorOptions = {
projectName: 'test-app',
database: 'postgres',
webhooks: false,
appExtensions: ['custom-panel'],
};
await generateAppExtensions(tmpDir, options);
Expand All @@ -34,7 +33,6 @@ describe('generateAppExtensions', () => {
const options: GeneratorOptions = {
projectName: 'test-app',
database: 'postgres',
webhooks: false,
appExtensions: ['custom-modal'],
};
await generateAppExtensions(tmpDir, options);
Expand All @@ -47,7 +45,6 @@ describe('generateAppExtensions', () => {
const options: GeneratorOptions = {
projectName: 'test-app',
database: 'postgres',
webhooks: false,
appExtensions: ['custom-panel', 'custom-modal'],
};
await generateAppExtensions(tmpDir, options);
Expand Down
78 changes: 69 additions & 9 deletions src/generators/node/database.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,18 @@ afterEach(async () => {
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: [],
};

Expand Down Expand Up @@ -216,32 +213,95 @@ describe('generateDatabase — drizzle.config.ts', () => {
});

describe('generateDatabase — docker-compose.yml', () => {
it('generates docker-compose.yml for postgres with healthcheck', async () => {
it('generates docker-compose.yml for postgres with backend and 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('postgres_data:/var/lib/postgresql/data');
expect(content).toContain('db_data:/var/lib/postgresql/data');
expect(content).toContain('pg_isready');
expect(content).toContain('healthcheck');
expect(content).toContain('backend');
expect(content).toContain('tsx watch src/index.ts');
expect(content).toContain('action: sync');
});

it('generates docker-compose.yml for mysql with healthcheck', async () => {
it('generates docker-compose.yml for mysql with backend and 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('127.0.0.1:3307:3306');
expect(content).not.toContain('3306:3306');
expect(content).toContain('mysql_data:/var/lib/mysql');
expect(content).toContain('db_data:/var/lib/mysql');
expect(content).toContain('mysqladmin');
expect(content).toContain('healthcheck');
expect(content).toContain('backend');
expect(content).toContain('tsx watch src/index.ts');
});

it('does not generate docker-compose.yml for sqlite', async () => {
it('generates docker-compose.yml for sqlite with backend only', async () => {
const { generateDatabase } = await import('./database.js');
await generateDatabase(tmpDir, sqliteOptions);
expect(await exists(join(tmpDir, 'docker-compose.yml'))).toBe(false);
expect(await exists(join(tmpDir, 'docker-compose.yml'))).toBe(true);
const content = await read('docker-compose.yml');
expect(content).toContain('backend');
expect(content).toContain('tsx watch src/index.ts');
expect(content).toContain('sqlite_data');
expect(content).not.toContain('mysql');
expect(content).not.toContain('postgres');
});
});

describe('generateDatabase — tokenRepository.ts', () => {
it('generates src/database/tokenRepository.ts', async () => {
const { generateDatabase } = await import('./database.js');
await generateDatabase(tmpDir, pgOptions);
expect(await exists(join(tmpDir, 'src/database/tokenRepository.ts'))).toBe(true);
});

it('exports getToken, getTokenByCompany, upsertToken', async () => {
const { generateDatabase } = await import('./database.js');
await generateDatabase(tmpDir, pgOptions);
const content = await read('src/database/tokenRepository.ts');
expect(content).toContain('export async function getToken');
expect(content).toContain('export async function getTokenByCompany');
expect(content).toContain('export async function upsertToken');
});

it('exports StoredToken type', async () => {
const { generateDatabase } = await import('./database.js');
await generateDatabase(tmpDir, pgOptions);
const content = await read('src/database/tokenRepository.ts');
expect(content).toContain('StoredToken');
});

it('imports TokenResponse from pipedrive/v2', async () => {
const { generateDatabase } = await import('./database.js');
await generateDatabase(tmpDir, pgOptions);
const content = await read('src/database/tokenRepository.ts');
expect(content).toContain("from 'pipedrive/v2'");
});

it('postgres uses onConflictDoUpdate', async () => {
const { generateDatabase } = await import('./database.js');
await generateDatabase(tmpDir, pgOptions);
const content = await read('src/database/tokenRepository.ts');
expect(content).toContain('onConflictDoUpdate');
});

it('mysql uses onDuplicateKeyUpdate', async () => {
const { generateDatabase } = await import('./database.js');
await generateDatabase(tmpDir, mysqlOptions);
const content = await read('src/database/tokenRepository.ts');
expect(content).toContain('onDuplicateKeyUpdate');
});

it('sqlite uses onConflictDoUpdate', async () => {
const { generateDatabase } = await import('./database.js');
await generateDatabase(tmpDir, sqliteOptions);
const content = await read('src/database/tokenRepository.ts');
expect(content).toContain('onConflictDoUpdate');
});
});
Loading
Loading