Skip to content

Commit e8fff8b

Browse files
authored
Merge pull request #6 from pipedrive/AINATIVEM-45
AINATIVEM-45 App Extensions Frontend Scaffold — React iframe UI (optional)
2 parents 7851306 + b7a5955 commit e8fff8b

21 files changed

Lines changed: 1926 additions & 118 deletions

CLAUDE.md

Lines changed: 44 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ npm test # Vitest suite
1717
npm run generate # generate test project in apps/test-app/ (gitignored)
1818
```
1919

20+
Run a single test file:
21+
22+
```bash
23+
npx vitest run src/generators/node/app.test.ts
24+
```
25+
2026
## Architecture
2127

2228
The tool is **CLI-first**, with an **AI plugin layer** built on top:
@@ -29,46 +35,59 @@ The tool is **CLI-first**, with an **AI plugin layer** built on top:
2935
The CLI asks for:
3036
- Backend: Node.js/Express, Node.js/Fastify, or PHP/Laravel
3137
- Database: Postgres, MySQL, or SQLite
32-
- App Extensions frontend: React, Vanilla JS, or none
38+
- App Extensions frontend: multi-select of `custom-panel` and/or `custom-modal` (or neither)
3339
- Webhooks: Yes/No
3440

41+
`GeneratorOptions.appExtensions` is `AppExtensionType[]` where `AppExtensionType = 'custom-panel' | 'custom-modal'`. Check membership with `.includes('custom-panel')`, not boolean equality.
42+
3543
### Generator flow
3644

3745
```
3846
cli.ts (collects prompts)
3947
→ prompts/ (projectName, database, appExtensions, webhooks)
40-
→ nodeGenerator (orchestrates 5 sub-generators)
48+
→ nodeGenerator (orchestrates sub-generators via NodeProjectBuilder)
4149
→ oauth.ts, database.ts, app.ts
42-
→ webhooks.ts (conditional), appExtensions.ts (conditional)
50+
→ webhooks.ts (conditional)
51+
→ appExtensions.ts (conditional)
52+
→ appExtensions/panel.ts — backend router + React snippet contributions
53+
→ appExtensions/modal.ts — backend router + React snippet contributions
54+
→ appExtensions/frontend.ts — Vite + React frontend (index.html, App.tsx, etc.)
55+
→ appExtensions/sdk.ts — usePipedriveSdk hook wrapper
56+
→ appExtensions/router.ts — shared Express static-file router
4357
→ serverEntry, packageJson, tsConfig, envExample, dockerCompose
4458
```
4559

46-
**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.
47-
48-
`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.
60+
**There is no template directory.** Generators build file content as strings using `dedent()`, with conditional string interpolation for optional features. 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.
4961

5062
### Generated project structure
5163

5264
```
5365
<project-name>/
54-
backend/
55-
oauth/ # Authorization redirect, callback, token exchange, refresh, state validation
56-
pipedrive-client/ # Official API client wrapper with preconfigured auth
57-
database/ # Tenant/account mapping, tokens, scopes, installation status
58-
webhooks/ # Optional webhook handlers
66+
src/
67+
app.ts # Express app, mounts all routers
68+
index.ts # Server entry with DB retry loop
69+
oauth/ # Authorization redirect, callback, token exchange, refresh
70+
pipedrive-client/ # Official API client wrapper
71+
database/ # Drizzle schema, migrations, db setup
72+
webhooks/ # Optional webhook handlers
73+
app-extensions/
74+
panel/ # Express router serving built frontend (custom-panel)
75+
modal/ # Express router serving built frontend (custom-modal)
5976
frontend/
60-
app-extension-ui/ # Optional: React or Vanilla iframe UI with App Extensions SDK
77+
app-extension-ui/ # Vite + React iframe UI (only when App Extensions selected)
6178
.env.example
6279
README.md
6380
docker-compose.yml
6481
marketplace-checklist.md
6582
```
6683

67-
## Adding features
84+
## App Extensions pattern
6885

69-
- **New prompt**: add `src/prompts/<feature>.ts` + `.test.ts`, export from `cli.ts`
70-
- **New generator**: add `src/generators/node/<feature>.ts` + `.test.ts`, call from `nodeGenerator`
71-
- **Modify generated scaffold**: edit template strings in the corresponding generator file
86+
Each extension type (panel, modal) contributes a `ReactSnippetContribution` — an object with `{ sdkImports, handlers, buttons }` — that gets merged into the generated `App.tsx`. This lets panel.ts and modal.ts independently declare what SDK imports and JSX they need without knowing about each other.
87+
88+
When App Extensions are enabled, `docker-compose up --watch` starts both the Express backend and the Vite dev server in containers with Compose Watch for live code sync. The Vite server must be exposed via a public HTTPS tunnel and configured in Developer Hub as the iframe URL.
89+
90+
The backend serves the built frontend at `/extensions/panel` and `/extensions/modal` via Express static routing (using the shared `routerContent()` from `appExtensions/router.ts`). In production, `npm run build` builds both backend TypeScript and the Vite bundle.
7291

7392
## Builder Pattern
7493

@@ -96,45 +115,35 @@ The scaffold generator uses `NodeProjectBuilder` + `BuildStep` (`src/generators/
96115
- Use `RouterMountBuilder` (`src/utils/templates.ts`) to accumulate `app.use()` calls conditionally
97116
- Use plain `dedent` for static content (YAML, JSON, `.env`, SQL)
98117

99-
## MVP Scope
100-
101-
The initial implementation targets:
102-
- **Runtime**: Node.js + TypeScript
103-
- **Backend**: Express or Fastify
104-
- **Database**: Postgres via Docker Compose
105-
- **Auth**: Full OAuth 2.0 install/callback/token-refresh flow
106-
- **API client**: Pipedrive Node.js client wrapper
107-
- **Frontend** (optional): React App Extensions UI
108-
- Outputs `.env.example` and a Marketplace readiness checklist
118+
## Adding features
109119

110-
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.
120+
- **New prompt**: add `src/prompts/<feature>.ts` + `.test.ts`, export from `cli.ts`
121+
- **New generator**: add `src/generators/node/<feature>.ts` + `.test.ts`, call from `nodeGenerator`
122+
- **New App Extension type**: add a file under `src/generators/node/appExtensions/` that exports an `async generate*` function and a `*ReactSnippets()` function returning `ReactSnippetContribution`, then wire it in `appExtensions.ts` and `frontend.ts`
123+
- **Modify generated scaffold**: edit template strings in the corresponding generator file
111124

112125
## Core Modules
113126

114127
### OAuth (`backend/oauth/`)
115128
Full OAuth 2.0: app registration guidance, authorization redirect, callback handling, token exchange, token refresh, state validation.
116129

117130
### Database (`backend/database/`)
118-
Uses **Drizzle ORM** (`drizzle-orm` + `drizzle-kit`) for schema definition and migrations. Drizzle is chosen because it supports Postgres, MySQL, and SQLite with the same TypeScript API — matching the three database options the CLI offers — and produces readable schema files with no codegen step.
131+
Uses **Drizzle ORM** (`drizzle-orm` + `drizzle-kit`) for schema definition and migrations. Supports Postgres, MySQL, and SQLite with the same TypeScript API.
119132

120133
Structure:
121134
- `schema.ts` — Drizzle table definitions (tenants, oauth_tokens, installations)
122135
- `migrations/` — SQL migration files managed by `drizzle-kit`
123-
- `db.ts` — driver setup (selects `postgres-js`, `mysql2`, or `better-sqlite3` based on the chosen DB)
136+
- `db.ts` — driver setup (selects `postgres-js`, `mysql2`, or `better-sqlite3` based on chosen DB)
124137

125138
### Pipedrive API client (`backend/pipedrive-client/`)
126139
Wrapper around the official Pipedrive Node.js client with preconfigured authentication and helpers for common API calls.
127140

128141
### App Extensions frontend (`frontend/app-extension-ui/`)
129-
Only generated when the user opts in. Iframe-based UI using the App Extensions SDK, supporting: initialization, resizing, modals, notifications/snackbars, theme handling.
142+
Generated when the user selects `custom-panel` and/or `custom-modal`. Iframe-based React + Vite UI using the App Extensions SDK (`usePipedriveSdk` hook), with: SDK initialization, theme handling, resize, snackbar, confirmation dialog, signed token, and extension-type-specific actions (open modal from panel, close modal).
130143

131144
## Tests
132145

133-
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:
134-
135-
```bash
136-
npx vitest run src/generators/node/app.test.ts
137-
```
146+
Vitest. Tests generate files into a `tmpdir()/cpa-app-test` directory, read them back to verify content, and clean up in `afterEach`.
138147

139148
## AI Plugin Commands (future layer)
140149

README.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,27 +26,38 @@ The CLI will prompt for:
2626
pipedrive-client/ # Pipedrive API client wrapper
2727
webhooks/ # Webhook handlers (if selected)
2828
app-extensions/ # App Extensions handlers (if selected)
29+
frontend/
30+
app-extension-ui/ # React + Vite iframe UI (if App Extensions selected)
2931
.env.example
30-
docker-compose.yml # Postgres or MySQL (if applicable)
32+
docker-compose.yml # Postgres, MySQL, and/or App Extensions UI (if applicable)
33+
README.md
3134
package.json
3235
tsconfig.json
3336
```
3437

3538
The generated project uses **Express + TypeScript + Drizzle ORM** (ESM, Node.js).
3639

40+
When App Extensions are selected, the generated project also includes a shared React iframe app for custom panels and custom modals. `docker-compose up --watch` starts the backend and Vite dev server in containers with Compose Watch. Local Developer Hub iframe URLs should point at an HTTPS tunnel to the Vite dev server, for example `https://<your-vite-tunnel>/extensions/panel` or `https://<your-vite-tunnel>/extensions/modal`. After `npm run build`, production iframe URLs can point at the backend-hosted `/extensions/panel` and `/extensions/modal` routes.
41+
3742
## Next steps after generation
3843

3944
```bash
4045
cd <project-name>
4146
cp .env.example .env
42-
docker-compose up -d # if Postgres or MySQL was selected
47+
docker-compose up -d db # if Postgres or MySQL was selected
4348
npm install
4449
npm run dev
4550
```
4651

52+
If App Extensions were selected, use Compose Watch instead of the local dev server:
53+
54+
```bash
55+
docker-compose up --watch
56+
```
57+
4758
Fill in `PIPEDRIVE_CLIENT_ID`, `PIPEDRIVE_CLIENT_SECRET`, and `DATABASE_URL` in `.env`.
4859

4960
## Requirements
5061

5162
- Node.js 18+
52-
- Docker (if using Postgres or MySQL)
63+
- Docker (if using Postgres, MySQL, or App Extensions frontend development)

src/cli.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { pathToFileURL } from 'node:url';
3+
import { isCliEntrypoint, nextStepLines } from './cli.js';
4+
5+
describe('nextStepLines', () => {
6+
it('prints backend-only next steps for apps without App Extensions', () => {
7+
expect(nextStepLines({ nameOrPath: 'test-app', database: 'sqlite', installDeps: false, hasAppExtensions: false }).join('\n')).toBe(`
8+
Next steps:
9+
cd test-app
10+
cp .env.example .env
11+
npm install
12+
npm run dev`);
13+
});
14+
15+
it('prints the Compose Watch command when App Extensions are selected', () => {
16+
expect(nextStepLines({ nameOrPath: 'test-app', database: 'postgres', installDeps: true, hasAppExtensions: true }).join('\n')).toBe(`
17+
Next steps:
18+
cd test-app
19+
cp .env.example .env
20+
docker-compose up --watch`);
21+
});
22+
});
23+
24+
describe('isCliEntrypoint', () => {
25+
it('treats npm bin symlinks as the CLI entrypoint', () => {
26+
const realCliPath = '/package/dist/cli.js';
27+
const binSymlinkPath = '/npm-cache/.bin/create-pipedrive-app';
28+
const importMetaUrl = pathToFileURL(realCliPath).href;
29+
30+
expect(
31+
isCliEntrypoint(importMetaUrl, binSymlinkPath, (path) => (path === binSymlinkPath ? realCliPath : path)),
32+
).toBe(true);
33+
});
34+
35+
it('does not treat unrelated files as the CLI entrypoint', () => {
36+
const importMetaUrl = pathToFileURL('/package/dist/cli.js').href;
37+
38+
expect(isCliEntrypoint(importMetaUrl, '/other/tool.js', (path) => path)).toBe(false);
39+
});
40+
});

src/cli.ts

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,60 @@
11
import * as clack from '@clack/prompts';
22
import { spawn } from 'node:child_process';
3+
import { realpathSync } from 'node:fs';
34
import { basename, resolve } from 'node:path';
5+
import { fileURLToPath, pathToFileURL } from 'node:url';
46
import { promptAppExtensions } from './prompts/appExtensions.js';
57
import { promptDatabase } from './prompts/database.js';
68
import { promptProjectName } from './prompts/projectName.js';
79
import { promptWebhooks } from './prompts/webhooks.js';
810
import { nodeGenerator } from './generators/node/index.js';
11+
import type { Database } from './generators/interface.js';
12+
13+
interface NextStepOptions {
14+
nameOrPath: string;
15+
database: Database;
16+
installDeps: boolean;
17+
hasAppExtensions: boolean;
18+
}
19+
20+
export function nextStepLines(options: NextStepOptions): string[] {
21+
const needsDocker = options.database === 'postgres' || options.database === 'mysql';
22+
const runWithCompose = options.hasAppExtensions;
23+
24+
const steps = [`cd ${options.nameOrPath}`, 'cp .env.example .env'];
25+
26+
if (runWithCompose) {
27+
steps.push('docker-compose up --watch');
28+
} else {
29+
if (needsDocker) steps.push('docker-compose up -d db');
30+
if (!options.installDeps) steps.push('npm install');
31+
steps.push('npm run dev');
32+
}
33+
34+
return ['', 'Next steps:', ...steps.map((s) => ` ${s}`)];
35+
}
36+
37+
function printNextSteps(options: NextStepOptions): void {
38+
for (const line of nextStepLines(options)) {
39+
console.log(line);
40+
}
41+
}
42+
43+
type ResolvePath = (path: string) => string;
44+
45+
export function isCliEntrypoint(
46+
importMetaUrl: string,
47+
argvPath: string | undefined,
48+
resolvePath: ResolvePath = realpathSync,
49+
): boolean {
50+
if (!argvPath) return false;
51+
52+
try {
53+
return resolvePath(fileURLToPath(importMetaUrl)) === resolvePath(argvPath);
54+
} catch {
55+
return importMetaUrl === pathToFileURL(argvPath).href;
56+
}
57+
}
958

1059
async function main(): Promise<void> {
1160
clack.intro('create-pipedrive-app');
@@ -40,13 +89,14 @@ async function main(): Promise<void> {
4089
spinner.stop(ok ? 'Dependencies installed' : 'npm install failed — run it manually');
4190
}
4291

43-
const needsDocker = database === 'postgres' || database === 'mysql';
44-
console.log('\nNext steps:');
45-
console.log(` cd ${nameOrPath}`);
46-
console.log(' cp .env.example .env');
47-
if (needsDocker) console.log(' docker-compose up -d');
48-
if (!installDeps) console.log(' npm install');
49-
console.log(' npm run dev');
92+
printNextSteps({
93+
nameOrPath,
94+
database,
95+
installDeps: Boolean(installDeps),
96+
hasAppExtensions: appExtensions.length > 0,
97+
});
5098
}
5199

52-
main();
100+
if (isCliEntrypoint(import.meta.url, process.argv[1])) {
101+
void main();
102+
}

src/generators/node/app.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ describe('generateApp', () => {
5858
appExtensions: ['custom-panel'],
5959
});
6060
expect(content).toContain("from './app-extensions/panel/index.js'");
61+
expect(content).toContain("import { join } from 'node:path';");
62+
expect(content).toContain("app.use('/extensions/assets', express.static(appExtensionAssetsPath));");
6163
expect(content).toContain("app.use('/extensions/panel'");
6264
});
6365

@@ -80,5 +82,6 @@ describe('generateApp', () => {
8082
appExtensions: [],
8183
});
8284
expect(content).not.toContain('./app-extensions');
85+
expect(content).not.toContain('/extensions/assets');
8386
});
8487
});

src/generators/node/app.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { RouterMountBuilder } from '../../utils/templates.js';
77
export async function generateApp(outputDir: string, options: GeneratorOptions): Promise<void> {
88
const hasPanel = options.appExtensions.includes('custom-panel');
99
const hasModal = options.appExtensions.includes('custom-modal');
10+
const hasAppExtensions = hasPanel || hasModal;
1011

1112
const mounts = new RouterMountBuilder()
1213
.add('/oauth', 'oauthRouter')
@@ -17,11 +18,17 @@ export async function generateApp(outputDir: string, options: GeneratorOptions):
1718

1819
const content = new SourceFileBuilder()
1920
.importDefault('express', 'express')
21+
.importIf(hasAppExtensions, 'node:path', ['join'])
2022
.importDefault('./oauth/index.js', 'oauthRouter')
2123
.importDefaultIf(options.webhooks, './webhooks/index.js', 'webhooksRouter')
2224
.importDefaultIf(hasPanel, './app-extensions/panel/index.js', 'panelRouter')
2325
.importDefaultIf(hasModal, './app-extensions/modal/index.js', 'modalRouter')
2426
.addBlock('const app = express();')
27+
.addBlockIf(
28+
hasAppExtensions,
29+
"const appExtensionAssetsPath = join(process.cwd(), 'frontend/app-extension-ui/dist/assets');",
30+
)
31+
.addBlockIf(hasAppExtensions, "app.use('/extensions/assets', express.static(appExtensionAssetsPath));")
2532
.addBlock(mounts)
2633
.exportDefault('app')
2734
.build();

0 commit comments

Comments
 (0)