diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..849b368 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "pipedrive", + "interface": { + "displayName": "Create pipedrive app" + }, + "plugins": [ + { + "name": "create-pipedrive-app", + "source": { + "source": "local", + "path": "./plugins/create-pipedrive-app" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Coding" + } + ] +} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..06f7857 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,17 @@ +{ + "name": "pipedrive", + "owner": { + "name": "Pipedrive", + "url": "https://www.pipedrive.com/" + }, + "metadata": { + "description": "Pipedrive developer tools for Claude Code." + }, + "plugins": [ + { + "name": "create-pipedrive-app", + "source": "./plugin", + "description": "Slash commands for scaffolding and extending Pipedrive Marketplace integration projects" + } + ] +} diff --git a/.gitignore b/.gitignore index c8dc9f0..bc5fbbf 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,8 @@ docs/superpowers/ *-app/ apps/ +# repo-local Codex plugin +!plugins/create-pipedrive-app/ +!plugins/create-pipedrive-app/** + .idea/ diff --git a/CLAUDE.md b/CLAUDE.md index 5cba97c..af5ba80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,8 @@ npm run typecheck # type-check without emitting npm run lint # ESLint npm run format # Prettier (120 char width, tabs, trailing commas) npm test # Vitest suite -npm run generate # generate test project in apps/test-app/ (gitignored) +npm run generate # generate test project in apps/test-app/ (gitignored) +npm run clean # delete generated apps/ directory ``` Run a single test file: @@ -33,28 +34,36 @@ The tool is **CLI-first**, with an **AI plugin layer** built on top: ### Interactive prompts (CLI) The CLI asks for: -- Backend: Node.js/Express, Node.js/Fastify, or PHP/Laravel +- Project name - Database: Postgres, MySQL, or SQLite - App Extensions frontend: multi-select of `custom-panel` and/or `custom-modal` (or neither) -- Webhooks: Yes/No -`GeneratorOptions.appExtensions` is `AppExtensionType[]` where `AppExtensionType = 'custom-panel' | 'custom-modal'`. Check membership with `.includes('custom-panel')`, not boolean equality. +`GeneratorOptions.appExtensions` is `AppExtensionType[]` where `AppExtensionType` is derived from the `APP_EXTENSION_TYPES` const (`'custom-panel' | 'custom-modal'`). Use `isAppExtensionType(value)` to validate CLI input. Check membership with `.includes('custom-panel')`, not boolean equality. + +### CLI subcommands + +Beyond the interactive main flow, the CLI supports subcommands invoked by the AI plugin skills: + +```bash +npx create-pipedrive-app add-app-extension --app-extensions custom-panel|custom-modal [--output-dir ] +``` + +Subcommand dispatch happens in `dispatchSubcommand()` in `cli.ts` before the interactive flow runs. ### Generator flow ``` cli.ts (collects prompts) - → prompts/ (projectName, database, appExtensions, webhooks) + → prompts/ (projectName, database, appExtensions) → nodeGenerator (orchestrates sub-generators via NodeProjectBuilder) - → oauth.ts, database.ts, app.ts - → webhooks.ts (conditional) + → oauth.ts, database.ts, app.ts, pipedriveClient.ts, crypto.ts → appExtensions.ts (conditional) → appExtensions/panel.ts — backend router + React snippet contributions → appExtensions/modal.ts — backend router + React snippet contributions → appExtensions/frontend.ts — Vite + React frontend (index.html, App.tsx, etc.) → appExtensions/sdk.ts — usePipedriveSdk hook wrapper → appExtensions/router.ts — shared Express static-file router - → serverEntry, packageJson, tsConfig, envExample, dockerCompose + → serverEntry, packageJson, tsConfig, envExample, dockerCompose, readme ``` **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. @@ -67,9 +76,8 @@ cli.ts (collects prompts) app.ts # Express app, mounts all routers index.ts # Server entry with DB retry loop oauth/ # Authorization redirect, callback, token exchange, refresh - pipedrive-client/ # Official API client wrapper + pipedrive/ # Official API client wrapper (getClient, token refresh via onTokenUpdate) database/ # Drizzle schema, migrations, db setup - webhooks/ # Optional webhook handlers app-extensions/ panel/ # Express router serving built frontend (custom-panel) modal/ # Express router serving built frontend (custom-modal) @@ -78,7 +86,6 @@ cli.ts (collects prompts) .env.example README.md docker-compose.yml - marketplace-checklist.md ``` ## App Extensions pattern @@ -105,7 +112,7 @@ The scaffold generator uses `NodeProjectBuilder` + `BuildStep` (`src/generators/ 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()) + .when(options.appExtensions.length > 0, b => b.addAppExtensions()) ``` **Rule: never put conditional logic inside `execute()`** — use `when()` at the call site. Steps must be unconditional internally; the builder chain controls what runs. @@ -122,21 +129,20 @@ The scaffold generator uses `NodeProjectBuilder` + `BuildStep` (`src/generators/ - **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` - **Modify generated scaffold**: edit template strings in the corresponding generator file -## Core Modules +## Generated Project Modules -### OAuth (`backend/oauth/`) -Full OAuth 2.0: app registration guidance, authorization redirect, callback handling, token exchange, token refresh, state validation. +### OAuth (`src/oauth/`) +Full OAuth 2.0: authorization redirect, callback handling, token exchange, HMAC-signed state parameter with TTL validation. -### Database (`backend/database/`) +### Database (`src/database/`) Uses **Drizzle ORM** (`drizzle-orm` + `drizzle-kit`) for schema definition and migrations. Supports Postgres, MySQL, and SQLite with the same TypeScript API. -Structure: - `schema.ts` — Drizzle table definitions (tenants, oauth_tokens, installations) - `migrations/` — SQL migration files managed by `drizzle-kit` - `db.ts` — driver setup (selects `postgres-js`, `mysql2`, or `better-sqlite3` based on chosen DB) -### Pipedrive API client (`backend/pipedrive-client/`) -Wrapper around the official Pipedrive Node.js client with preconfigured authentication and helpers for common API calls. +### Pipedrive API client (`src/pipedrive/`) +Wraps the official Pipedrive Node.js SDK. `getClient(companyId)` loads the stored OAuth token and configures `onTokenUpdate` so the SDK handles token refresh automatically. ### App Extensions frontend (`frontend/app-extension-ui/`) 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). @@ -145,11 +151,15 @@ Generated when the user selects `custom-panel` and/or `custom-modal`. Iframe-bas Vitest. Tests generate files into a `tmpdir()/cpa-app-test` directory, read them back to verify content, and clean up in `afterEach`. -## AI Plugin Commands (future layer) +## AI Plugin Layer -``` -/pipedrive-new-app -/pipedrive-add-oauth -/pipedrive-add-app-extension -/pipedrive-review-marketplace-readiness -``` +The `plugin/` directory is shipped with the npm package (`"files": ["dist", "plugin"]`) and contains Claude Code skills that wrap the CLI for AI-assisted development. Each skill is a markdown instruction document in `plugin/skills//SKILL.md`. + +| Skill | Purpose | +|-------|---------| +| `pipedrive-new-app` | Scaffold a new app interactively via `npx create-pipedrive-app` | +| `pipedrive-add-app-extension` | Add a panel or modal extension via the `add-app-extension` subcommand | +| `pipedrive-api` | Guide on using the Pipedrive REST API and SDK within a generated project | +| `pipedrive-review-marketplace-readiness` | Gap analysis before marketplace submission: checks token refresh, HTTPS, error handling, and rate limit handling | + +Skills use `allowed-tools` frontmatter to restrict which Claude tools they can invoke. Adding a new skill means creating a `SKILL.md` in a new subdirectory under `plugin/skills/`. diff --git a/README.md b/README.md index 1e18ac8..16b9ca5 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,34 @@ # create-pipedrive-app -CLI scaffolding tool for Pipedrive Marketplace integrations. +CLI scaffolding tool for Pipedrive Marketplace integrations. Generates an Express + TypeScript + Drizzle ORM project with OAuth 2.0, a Pipedrive API client, and optional App Extensions frontend (React + Vite). The CLI can be used standalone without any AI tooling. ## Usage ```bash -npx create-pipedrive-app +npx create-pipedrive-app ``` -The CLI will prompt for: +The CLI prompts for: +- **Project name** - **Database**: Postgres, MySQL, or SQLite -- **App Extensions**: custom panel, custom modal, or none -- **Webhooks**: yes or no +- **App Extensions**: custom panel, custom modal, or neither ## Generated project ``` / src/ - index.ts # server entry point (port 3000) - app.ts # Express app with OAuth router (+ optional webhooks/extensions) + index.ts # server entry point + app.ts # Express app with OAuth router (+ optional App Extensions) oauth/ # OAuth 2.0 install, callback, token exchange, refresh + pipedrive/ # Pipedrive API client wrapper 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) frontend/ app-extension-ui/ # React + Vite iframe UI (if App Extensions selected) .env.example - docker-compose.yml # Postgres, MySQL, and/or App Extensions UI (if applicable) + docker-compose.yml README.md package.json tsconfig.json @@ -37,27 +36,51 @@ The CLI will prompt for: The generated project uses **Express + TypeScript + Drizzle ORM** (ESM, Node.js). -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:///extensions/panel` or `https:///extensions/modal`. After `npm run build`, production iframe URLs can point at the backend-hosted `/extensions/panel` and `/extensions/modal` routes. +When App Extensions are selected, the project includes a shared React iframe app for custom panels and modals. `docker-compose up --watch` starts the backend and Vite dev server in containers with Compose Watch. Local Developer Hub iframe URLs must point at an HTTPS tunnel to the Vite dev server — for example `https:///extensions/panel` or `https:///extensions/modal`. After `npm run build`, production iframe URLs can point at the backend-hosted routes. -## Next steps after generation +## Requirements + +- Node.js (to run `npx create-pipedrive-app`) +- Docker (for Postgres/MySQL databases and App Extensions development) + +## Using with an AI coding assistant + +The package ships plugins for **Claude Code** and **Codex** that wrap the CLI with guided slash commands. The plugins require the CLI — they call `npx create-pipedrive-app` under the hood. + +### Claude Code + +This repository acts as the Claude Code plugin marketplace. Claude reads the marketplace catalog from `.claude-plugin/marketplace.json`, then installs the plugin from `plugin/`. + +After this repository is public, install the plugin with: ```bash -cd -cp .env.example .env -docker-compose up -d db # if Postgres or MySQL was selected -npm install -npm run dev +claude plugin marketplace add pipedrive/create-pipedrive-app +claude plugin install create-pipedrive-app@pipedrive +``` + +Inside Claude Code, use the equivalent slash commands: + +```text +/plugin marketplace add pipedrive/create-pipedrive-app +/plugin install create-pipedrive-app@pipedrive +/reload-plugins ``` -If App Extensions were selected, use Compose Watch instead of the local dev server: +The plugin calls `npx create-pipedrive-app` under the hood, so the npm package must also be published or otherwise available to users. + +### Codex ```bash -docker-compose up --watch +codex plugin install create-pipedrive-app ``` -Fill in `PIPEDRIVE_CLIENT_ID`, `PIPEDRIVE_CLIENT_SECRET`, and `DATABASE_URL` in `.env`. +### Available commands -## Requirements +Both plugins expose the same slash commands: -- Node.js 18+ -- Docker (if using Postgres, MySQL, or App Extensions frontend development) +| Command | What it does | +|---------|-------------| +| `/pipedrive-new-app` | Scaffold a new integration project | +| `/pipedrive-add-app-extension` | Add a custom panel or modal extension | +| `/pipedrive-api` | Get guidance on using the Pipedrive API | +| `/pipedrive-review-marketplace-readiness` | Check for gaps before submitting to the marketplace | diff --git a/package.json b/package.json index 3865311..fcbbd43 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,11 @@ "version": "0.1.0", "description": "Scaffold a production-ready Pipedrive Marketplace app", "type": "module", + "files": [ + "dist", + "plugin", + "plugins" + ], "bin": { "create-pipedrive-app": "./dist/cli.js" }, diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..9a65a11 --- /dev/null +++ b/plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "create-pipedrive-app", + "description": "Slash commands for scaffolding and extending Pipedrive Marketplace integration projects", + "version": "0.1.0", + "author": { + "name": "Pipedrive" + } +} diff --git a/plugin/skills/pipedrive-add-app-extension/SKILL.md b/plugin/skills/pipedrive-add-app-extension/SKILL.md new file mode 100644 index 0000000..a79d374 --- /dev/null +++ b/plugin/skills/pipedrive-add-app-extension/SKILL.md @@ -0,0 +1,131 @@ +--- +name: pipedrive-add-app-extension +description: Add a custom panel or custom modal App Extension to an existing Pipedrive integration project. Use when the developer wants to add an iframe-based UI extension to their app. +allowed-tools: [Bash, Read, Edit] +--- + +# Add App Extension to Existing Project + +## App Extensions background + +Pipedrive App Extensions let your app embed custom web content as an iframe directly inside Pipedrive's UI. The two types supported by this tool are: + +**Custom panel** — an iframe embedded in the sidebar of deal, person, and organization detail views. Use this when you want to show contextual data from your app alongside a Pipedrive record. + +**Custom modal** — an iframe opened as a modal from Pipedrive menus or triggered by a panel action. Use this for workflows that require user input or actions (e.g. creating a record in your app from Pipedrive). + +Both types require the `@pipedrive/app-extensions-sdk` in the iframe. The SDK handles: +- Initialisation and receiving the signed JWT token identifying the current user and company +- Theme synchronisation (light/dark) +- Iframe resize +- Snackbar notifications +- Confirmation dialogs +- Panel → modal navigation (open a modal from a panel) + +Full documentation: https://pipedrive.readme.io/docs/app-extensions + +## Preconditions + +1. Check that `package.json` exists in the current directory. If it does not, tell the developer to run this command from their project root and stop. +2. Check which extension types already exist: + - Panel: `src/app-extensions/panel/index.ts` + - Modal: `src/app-extensions/modal/index.ts` +3. If both already exist, tell the developer both extension types are already set up and stop. + +## Generation + +Ask the developer which extension type they want to add (custom-panel or custom-modal). If one type already exists, only offer the other. + +Run the CLI subcommand with the chosen type: + +```bash +npx create-pipedrive-app add-app-extension --app-extensions custom-panel +# or +npx create-pipedrive-app add-app-extension --app-extensions custom-modal +``` + +If the command exits with a non-zero code, report the error and stop. + +## Wire router into src/app.ts + +Read `src/app.ts`. For a custom-panel addition: + +1. Add this import near the other router imports: + ```ts + import panelRouter from './app-extensions/panel/index.js'; + ``` +2. Add this router mount before the error handler: + ```ts + app.use('/extensions/panel', panelRouter); + ``` + +For a custom-modal addition, use `modalRouter` and `/extensions/modal` instead. + +## Wire React snippets into frontend + +Read `frontend/app-extension-ui/src/main.tsx`. If the App Extensions frontend already exists (the developer added the other extension type earlier), update the routing to include the new extension type. If the frontend does not exist, it was just generated by the CLI — no further action needed. + +## Update docker-compose.yml + +Read `docker-compose.yml`. If it does not already contain an `app-extension-ui` service, add it under `services:` and add `app_extension_ui_node_modules:` under `volumes:`: + +```yaml +services: + app-extension-ui: + build: + context: . + dockerfile: Dockerfile.app-extension-ui + user: root + command: sh -c "chown -R node:node /app/node_modules && su-exec node sh -c 'echo Installing dependencies... && npm install --no-package-lock --no-audit --no-fund --loglevel=error && npm run dev:frontend'" + environment: + CHOKIDAR_USEPOLLING: "true" + ports: + - "5173:5173" + volumes: + - ./package.json:/app/package.json:ro + - app_extension_ui_node_modules:/app/node_modules + develop: + watch: + - action: sync + path: ./frontend/app-extension-ui + target: /app/frontend/app-extension-ui + initial_sync: true + ignore: + - node_modules/ + - dist/ + - action: rebuild + path: ./package.json + +volumes: + app_extension_ui_node_modules: +``` + +Also create `Dockerfile.app-extension-ui` in the project root if it does not exist: + +```dockerfile +FROM node:24-alpine + +RUN apk add --no-cache su-exec +WORKDIR /app +ENV NPM_CONFIG_USERCONFIG=/tmp/.npmrc +RUN mkdir -p /app/node_modules && chown -R node:node /app +USER node +RUN npm config set registry https://registry.npmjs.org/ + +COPY --chown=node:node package.json ./ +COPY --chown=node:node frontend/app-extension-ui ./frontend/app-extension-ui + +EXPOSE 5173 +CMD ["npm", "run", "dev:frontend"] +``` + +## Report to developer + +Tell the developer: +- The files that were added +- To expose the Vite server (port 5173) via a public HTTPS tunnel for local development +- To set the tunnel URL as the iframe URL in the Pipedrive Developer Hub for this extension type: + - Custom panel: `https:///extensions/panel` + - Custom modal: `https:///extensions/modal` +- In production, `npm run build` compiles the Vite frontend and the backend serves it at `/extensions/panel` or `/extensions/modal` +- Link to documentation: https://pipedrive.readme.io/docs/app-extensions diff --git a/plugin/skills/pipedrive-api/SKILL.md b/plugin/skills/pipedrive-api/SKILL.md new file mode 100644 index 0000000..1428f45 --- /dev/null +++ b/plugin/skills/pipedrive-api/SKILL.md @@ -0,0 +1,72 @@ +--- +name: pipedrive-api +description: Guide developers on using the Pipedrive REST API v1 within their integration project. Use when a developer wants to query or mutate Pipedrive data (deals, persons, organizations, leads, activities, etc.) from their app. +allowed-tools: [Read, Edit, Bash] +--- + +# Using the Pipedrive API + +## Overview + +The Pipedrive REST API v1 base URL is `https://api.pipedrive.com/v1`. It is stateless and returns JSON. Full reference: https://developers.pipedrive.com/docs/api/v1 + +## Authentication in this project + +This project uses **OAuth 2.0**. After a user authorises your app, the access token is stored in the database (`pipedrive_tokens` table). The generated `src/pipedrive/` module handles token retrieval and client initialisation: + +```ts +import { getClient } from './pipedrive/client.js'; + +const client = await getClient(companyId); +``` + +Use `client` to call the official Pipedrive Node.js SDK (`pipedrive` npm package), which maps directly to the REST API. + +## Main resource categories + +| Category | Resources | +|----------|-----------| +| CRM core | Deals, Persons, Organizations, Activities, Notes | +| Lead management | Leads, Lead Fields, Lead Labels | +| Sales | Pipelines, Stages, Products, Goals | +| Communication | Webhooks, Call Logs, Mailbox | +| Admin | Users, Roles, Permission Sets | + +## Common usage patterns + +**List deals:** +```ts +const deals = await client.deals.getDeals({ limit: 50 }); +``` + +**Get a person:** +```ts +const person = await client.persons.getPerson({ id: personId }); +``` + +**Create an activity:** +```ts +await client.activities.addActivity({ + subject: 'Follow-up call', + type: 'call', + due_date: '2026-06-01', +}); +``` + +## Adding a new API call + +1. Read `src/pipedrive/` to understand the existing wrapper pattern +2. Use `client..()` following the SDK method names +3. Handle token expiry — the client wrapper in this project manages token refresh automatically + +## Rate limits and pagination + +- Rate limits apply per API token / OAuth token — check response headers for `X-RateLimit-*` values +- Paginate large result sets using the `start` and `limit` query parameters +- The SDK returns a `data` array and `additional_data.pagination` object for paginated resources + +## Further reading + +- API reference: https://developers.pipedrive.com/docs/api/v1 +- Node.js SDK: https://github.com/pipedrive/client-nodejs +- OAuth guide: https://pipedrive.readme.io/docs/marketplace-oauth-authorization diff --git a/plugin/skills/pipedrive-new-app/SKILL.md b/plugin/skills/pipedrive-new-app/SKILL.md new file mode 100644 index 0000000..ace6ec3 --- /dev/null +++ b/plugin/skills/pipedrive-new-app/SKILL.md @@ -0,0 +1,43 @@ +--- +name: pipedrive-new-app +description: Scaffold a new Pipedrive Marketplace integration project with OAuth, database, and optional App Extensions. Use when the developer wants to start a new Pipedrive app from scratch. +allowed-tools: [Bash, AskUserQuestion] +--- + +# New Pipedrive App + +Collect the developer's choices, then scaffold the project non-interactively. + +## Step 1: Collect inputs + +Ask each question separately and wait for the answer before asking the next. + +**Project name** +Ask: "What should the project be named? (This will also be the output directory name, e.g. `my-pipedrive-app`)" + +**Database** +Ask: "Which database will the app use?" — offer three options: `postgres`, `mysql`, `sqlite`. + +**App Extensions** +Ask: "Should the app include App Extensions (iframe UI panels/modals)? If yes, which types — `custom-panel`, `custom-modal`, or both?" + +## Step 2: Run the CLI + +Assemble the `--app-extensions` value: +- No extensions → `none` +- Custom panel only → `custom-panel` +- Custom modal only → `custom-modal` +- Both → `custom-panel,custom-modal` + +Run: + +```bash +npx create-pipedrive-app \ + --project-name \ + --database \ + --app-extensions +``` + +## Step 3: Report + +After the CLI exits successfully, tell the developer what was created and what to do next (refer to the "Next steps" output printed by the CLI). diff --git a/plugin/skills/pipedrive-review-marketplace-readiness/SKILL.md b/plugin/skills/pipedrive-review-marketplace-readiness/SKILL.md new file mode 100644 index 0000000..fd49f39 --- /dev/null +++ b/plugin/skills/pipedrive-review-marketplace-readiness/SKILL.md @@ -0,0 +1,223 @@ +--- +name: pipedrive-review-marketplace-readiness +description: Review a Pipedrive Marketplace integration project for readiness. Checks the project for missing requirements and reports only what needs to be fixed before submission. +allowed-tools: [Read, Bash] +--- + +# Marketplace Readiness Review + +Checks 4 requirements for marketplace submission readiness. Reports only what is missing. If nothing is missing, the app is ready to submit. + +## Precondition + +Check that `src/app.ts` and `package.json` exist in the current directory. If they do not exist, tell the developer to run this skill from their project root and stop. + +## Gather project context + +Read these files: + +- `src/app.ts` +- `package.json` +- `src/pipedrive/client.ts` (if it exists) +- `src/oauth/index.ts` (if it exists) + +Find all TypeScript source files: + +```bash +find src -name "*.ts" | sort +``` + +Read all files found. + +## Run all 4 checks + +Collect all failures before reporting. Do not stop at the first failure. + +--- + +### Check 1: Token refresh for non-SDK API calls + +Scan source files for `fetch(` or `axios` calls (including `axios.get`, `axios.post`, etc.) that include an Authorization header with a token value. + +Run: + +```bash +grep -rn "Authorization\|Bearer\|access_token" src --include="*.ts" | grep -v "src/pipedrive/client.ts" +``` + +Review each match to determine the token source (see below). + +For each match: + +1. Is it inside `src/pipedrive/client.ts`? → Skip. The SDK wrapper handles token refresh automatically via `onTokenUpdate`. +2. Is the token taken directly from an OAuth response in the same function scope (e.g., `token.access_token` where `token` is the return value of `oauth2.authorize()`)? → Skip. This is a fresh token — no refresh needed. +3. Is the token retrieved from the database (e.g., via `getTokenByCompany`, `stored.token`, `row.accessToken`, or similar)? → This is a stored token that can expire. Check if the same code path handles 401 responses by refreshing the token and retrying. + +**Fail condition:** A stored token is used in a direct HTTP call without 401/refresh handling. + +**Report if failing:** + +Identify the specific file and line. Then output: + + ❌ Token refresh not handled for direct API calls + + `src/path/to/file.ts` makes a direct HTTP call using a stored access token without handling 401 responses. When the token expires, this call will fail silently. + + Fix: Either use `getClient(companyId)` from `src/pipedrive/client.ts` (which handles refresh automatically), or add a 401 retry that refreshes the token and retries the request. + +--- + +### Check 2: HTTPS enforced + +**Sub-check A — no hardcoded HTTP URLs:** + +Run: + +```bash +grep -rn "http://" src --include="*.ts" | grep -v "localhost" | grep -v "127.0.0.1" +``` + +Any output is a failure. List each match with file and line number. + +**Report if sub-check A failing:** + + ❌ Hardcoded HTTP URLs found + + The following URLs use HTTP instead of HTTPS: + - `src/path/to/file.ts:42`: `http://example.com/endpoint` + + Fix: Replace with `https://` URLs. Pipedrive requires all external calls to use HTTPS in production. + +**Sub-check B — trust proxy configured:** + +Run: + +```bash +grep -n "trust proxy" src/app.ts +``` + +No output is a failure. + +**Report if sub-check B failing:** + + ❌ trust proxy not configured + + `src/app.ts` does not set `trust proxy`. Without it, Express cannot detect the protocol used by the + client when the app runs behind a reverse proxy (Nginx, load balancer, etc.). This breaks HTTPS + enforcement and secure cookie handling in production. + + Fix: Add to `src/app.ts` before any route definitions: + + ```ts + app.set('trust proxy', 1); + ``` + +--- + +### Check 3: Error handling + +**Sub-check A — Express error handler in app.ts:** + +Run: + +```bash +grep -n "NextFunction" src/app.ts +``` + +This will match both the import line and any usage. Look specifically for a 4-argument middleware with the signature `(err: Error, _req: Request, res: Response, _next: NextFunction)` — ignore any lines that are just import statements. If no such handler exists (only the import line matches), this sub-check fails. + +**Report if sub-check A failing:** + + ❌ No Express error handler + + `src/app.ts` has no error-handling middleware. Unhandled errors will crash the process or send empty + responses to the client. + + Fix: Add before `export default app`: + + ```ts + app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { + console.error(err); + res.status(500).json({ error: err.message }); + }); + ``` + +**Sub-check B — async route handlers with try/catch:** + +Run: + +```bash +grep -rn "async.*req.*res" src --include="*.ts" +``` + +For each async route handler found, read the surrounding code and verify the function body has a try/catch block that calls `next(err)` in the catch. If any handler lacks it, this sub-check fails. + +**Report if sub-check B failing:** + + ❌ Async route handlers without error handling + + The following route handlers do not catch errors: + - `src/path/to/file.ts:15` + + Fix: Wrap each async handler body in try/catch and pass errors to next: + + ```ts + router.get('/example', async (req, res, next) => { + try { + // handler logic + } catch (err) { + next(err); + } + }); + ``` + +--- + +### Check 4: Rate limit handling + +Run: + +```bash +grep -rn "429\|X-RateLimit\|Retry-After" src --include="*.ts" +``` + +**Fail condition:** No matches found. + +**Report if failing:** + + ❌ No rate limit handling + + Pipedrive enforces API rate limits per OAuth token. When the limit is exceeded, the API returns HTTP + 429. Without handling this, your app will silently fail under load. + + Headers returned on every API response: + - `X-RateLimit-Limit` — requests allowed per 10 seconds + - `X-RateLimit-Remaining` — requests remaining in the current window + - `X-RateLimit-Reset` — Unix timestamp when the limit resets + + Fix: Add 429 handling to your API calls. Example retry pattern: + + ```ts + async function callWithRetry(fn: () => Promise): Promise { + const response = await fn(); + if (response.status === 429) { + const resetAt = Number(response.headers.get('X-RateLimit-Reset')) * 1000; + const delay = Math.max(resetAt - Date.now(), 1000); + await new Promise(resolve => setTimeout(resolve, delay)); + return fn(); + } + return response; + } + ``` + + Reference: https://pipedrive.readme.io/docs/core-api-concepts-rate-limiting#http-headers-and-response-codes + +--- + +## Output + +If all 4 checks pass: + + ✅ All marketplace readiness checks passed. Your app is ready to submit. + +If any checks fail, output only the failing checks using the report formats above. Do not mention passing checks. diff --git a/plugins/create-pipedrive-app/.codex-plugin/plugin.json b/plugins/create-pipedrive-app/.codex-plugin/plugin.json new file mode 100644 index 0000000..b44ae9b --- /dev/null +++ b/plugins/create-pipedrive-app/.codex-plugin/plugin.json @@ -0,0 +1,40 @@ +{ + "name": "create-pipedrive-app", + "version": "0.1.0", + "description": "Scaffold and extend Pipedrive Marketplace integration projects from Codex.", + "author": { + "name": "Pipedrive", + "url": "https://www.pipedrive.com/" + }, + "homepage": "https://github.com/pipedrive/create-pipedrive-app", + "repository": "https://github.com/pipedrive/create-pipedrive-app", + "keywords": [ + "pipedrive", + "marketplace", + "oauth", + "app-extensions", + "scaffolding" + ], + "skills": "./skills/", + "interface": { + "displayName": "Create Pipedrive App", + "shortDescription": "Build Pipedrive Marketplace apps from Codex", + "longDescription": "Scaffold new Pipedrive Marketplace integration projects, add OAuth, add iframe App Extensions, call the Pipedrive API, and review Marketplace readiness from Codex.", + "developerName": "Pipedrive", + "category": "Coding", + "capabilities": [ + "Interactive", + "Write" + ], + "websiteURL": "https://github.com/pipedrive/create-pipedrive-app", + "privacyPolicyURL": "https://www.pipedrive.com/en/privacy", + "termsOfServiceURL": "https://www.pipedrive.com/en/terms-of-service", + "defaultPrompt": [ + "Scaffold a new Pipedrive Marketplace app.", + "Add OAuth to this Pipedrive app.", + "Review this app for Marketplace readiness." + ], + "brandColor": "#017A5B", + "screenshots": [] + } +} diff --git a/plugins/create-pipedrive-app/skills/pipedrive-add-app-extension/SKILL.md b/plugins/create-pipedrive-app/skills/pipedrive-add-app-extension/SKILL.md new file mode 100644 index 0000000..7479234 --- /dev/null +++ b/plugins/create-pipedrive-app/skills/pipedrive-add-app-extension/SKILL.md @@ -0,0 +1,124 @@ +--- +name: pipedrive-add-app-extension +description: Add custom panel or modal App Extensions. Use when embedding iframe UI in Pipedrive records or menus. +allowed-tools: [Bash, Read, Edit] +--- + +# Add App Extension to Existing Project + +## App Extensions background + +Pipedrive App Extensions embed iframe UI inside Pipedrive: + +- Custom panel: sidebar iframe on deal, person, and organization detail views. +- Custom modal: menu-opened or panel-triggered modal iframe for user workflows. + +Both use `@pipedrive/app-extensions-sdk` for signed user/company JWTs, theme sync, iframe resizing, snackbars, confirmations, and panel-to-modal navigation. + +Docs: https://pipedrive.readme.io/docs/app-extensions + +## Preconditions + +1. Check that `package.json` exists in the current directory. If it does not, tell the developer to run this command from their project root and stop. +2. Check which extension types already exist: + - Panel: `src/app-extensions/panel/index.ts` + - Modal: `src/app-extensions/modal/index.ts` +3. If both already exist, tell the developer both extension types are already set up and stop. + +## Generation + +Ask the developer which extension type they want to add (custom-panel or custom-modal). If one type already exists, only offer the other. + +Run the CLI subcommand with the chosen type: + +```bash +npx create-pipedrive-app add-app-extension --app-extensions custom-panel +# or +npx create-pipedrive-app add-app-extension --app-extensions custom-modal +``` + +If the command exits with a non-zero code, report the error and stop. + +## Wire router into src/app.ts + +Read `src/app.ts`. For a custom-panel addition: + +1. Add this import near the other router imports: + ```ts + import panelRouter from './app-extensions/panel/index.js'; + ``` +2. Add this router mount before the error handler: + ```ts + app.use('/extensions/panel', panelRouter); + ``` + +For a custom-modal addition, use `modalRouter` and `/extensions/modal` instead. + +## Wire React snippets into frontend + +Read `frontend/app-extension-ui/src/main.tsx`. If the App Extensions frontend already exists (the developer added the other extension type earlier), update the routing to include the new extension type. If the frontend does not exist, it was just generated by the CLI — no further action needed. + +## Update docker-compose.yml + +Read `docker-compose.yml`. If it does not already contain an `app-extension-ui` service, add it under `services:` and add `app_extension_ui_node_modules:` under `volumes:`: + +```yaml +services: + app-extension-ui: + build: + context: . + dockerfile: Dockerfile.app-extension-ui + user: root + command: sh -c "chown -R node:node /app/node_modules && su-exec node sh -c 'echo Installing dependencies... && npm install --no-package-lock --no-audit --no-fund --loglevel=error && npm run dev:frontend'" + environment: + CHOKIDAR_USEPOLLING: "true" + ports: + - "5173:5173" + volumes: + - ./package.json:/app/package.json:ro + - app_extension_ui_node_modules:/app/node_modules + develop: + watch: + - action: sync + path: ./frontend/app-extension-ui + target: /app/frontend/app-extension-ui + initial_sync: true + ignore: + - node_modules/ + - dist/ + - action: rebuild + path: ./package.json + +volumes: + app_extension_ui_node_modules: +``` + +Also create `Dockerfile.app-extension-ui` in the project root if it does not exist: + +```dockerfile +FROM node:24-alpine + +RUN apk add --no-cache su-exec +WORKDIR /app +ENV NPM_CONFIG_USERCONFIG=/tmp/.npmrc +RUN mkdir -p /app/node_modules && chown -R node:node /app +USER node +RUN npm config set registry https://registry.npmjs.org/ + +COPY --chown=node:node package.json ./ +COPY --chown=node:node frontend/app-extension-ui ./frontend/app-extension-ui + +EXPOSE 5173 +CMD ["npm", "run", "dev:frontend"] +``` + +## Report to developer + +Tell the developer: +- The files that were added +- To expose the Vite server (port 5173) via a public HTTPS tunnel for local development +- To set the tunnel URL as the iframe URL in the Pipedrive Developer Hub for this extension type: + - Custom panel: `https:///extensions/panel` + - Custom modal: `https:///extensions/modal` +- In production, `npm run build` compiles the Vite frontend and the backend serves it at `/extensions/panel` or `/extensions/modal` +- Link to documentation: https://pipedrive.readme.io/docs/app-extensions diff --git a/plugins/create-pipedrive-app/skills/pipedrive-add-app-extension/agents/openai.yaml b/plugins/create-pipedrive-app/skills/pipedrive-add-app-extension/agents/openai.yaml new file mode 100644 index 0000000..10df525 --- /dev/null +++ b/plugins/create-pipedrive-app/skills/pipedrive-add-app-extension/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Pipedrive Add App Extension" + short_description: "Add iframe App Extensions" + default_prompt: "Use $pipedrive-add-app-extension to add a custom panel or modal." diff --git a/plugins/create-pipedrive-app/skills/pipedrive-api/SKILL.md b/plugins/create-pipedrive-app/skills/pipedrive-api/SKILL.md new file mode 100644 index 0000000..2ddf861 --- /dev/null +++ b/plugins/create-pipedrive-app/skills/pipedrive-api/SKILL.md @@ -0,0 +1,66 @@ +--- +name: pipedrive-api +description: Use Pipedrive REST API v1 from an integration. Use when reading or writing deals, persons, organizations, leads, or activities. +allowed-tools: [Read, Edit, Bash] +--- + +# Using the Pipedrive API + +## Overview + +The Pipedrive REST API v1 base URL is `https://api.pipedrive.com/v1`. It is stateless and returns JSON. Full reference: https://developers.pipedrive.com/docs/api/v1 + +## Authentication in this project + +This project uses **OAuth 2.0**. After a user authorises your app, the access token is stored in the database (`pipedrive_tokens` table). The generated `src/pipedrive/` module handles token retrieval and client initialisation: + +```ts +import { getClient } from './pipedrive/client.js'; + +const client = await getClient(companyId); +``` + +Use `client` to call the official Pipedrive Node.js SDK (`pipedrive` npm package), which maps directly to the REST API. + +## Main resource categories + +Common resources include Deals, Persons, Organizations, Activities, Notes, Leads, Pipelines, Stages, Products, Goals, Webhooks, Users, Roles, and Permission Sets. + +## Common usage patterns + +**List deals:** +```ts +const deals = await client.deals.getDeals({ limit: 50 }); +``` + +**Get a person:** +```ts +const person = await client.persons.getPerson({ id: personId }); +``` + +**Create an activity:** +```ts +await client.activities.addActivity({ + subject: 'Follow-up call', + type: 'call', + due_date: '2026-06-01', +}); +``` + +## Adding a new API call + +1. Read `src/pipedrive/` to understand the existing wrapper pattern +2. Use `client..()` following the SDK method names +3. Handle token expiry — the client wrapper in this project manages token refresh automatically + +## Rate limits and pagination + +- Rate limits apply per API token / OAuth token — check response headers for `X-RateLimit-*` values +- Paginate large result sets using the `start` and `limit` query parameters +- The SDK returns a `data` array and `additional_data.pagination` object for paginated resources + +## Further reading + +- API reference: https://developers.pipedrive.com/docs/api/v1 +- Node.js SDK: https://github.com/pipedrive/client-nodejs +- OAuth guide: https://pipedrive.readme.io/docs/marketplace-oauth-authorization diff --git a/plugins/create-pipedrive-app/skills/pipedrive-api/agents/openai.yaml b/plugins/create-pipedrive-app/skills/pipedrive-api/agents/openai.yaml new file mode 100644 index 0000000..cd34177 --- /dev/null +++ b/plugins/create-pipedrive-app/skills/pipedrive-api/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Pipedrive API" + short_description: "Call Pipedrive REST API from apps" + default_prompt: "Use $pipedrive-api to add a Pipedrive API call to this app." diff --git a/plugins/create-pipedrive-app/skills/pipedrive-new-app/SKILL.md b/plugins/create-pipedrive-app/skills/pipedrive-new-app/SKILL.md new file mode 100644 index 0000000..04cf410 --- /dev/null +++ b/plugins/create-pipedrive-app/skills/pipedrive-new-app/SKILL.md @@ -0,0 +1,15 @@ +--- +name: pipedrive-new-app +description: Scaffold a new Pipedrive Marketplace app. Use when starting a new Pipedrive integration project from scratch. +allowed-tools: [Bash] +--- + +# New Pipedrive App + +Run `npx create-pipedrive-app` in the current directory. If the developer provided a project name, pass it as the first argument. Otherwise, run the CLI interactively and let it prompt for project name, database, and App Extensions options. + +Run it now: + +```bash +npx create-pipedrive-app +``` diff --git a/plugins/create-pipedrive-app/skills/pipedrive-new-app/agents/openai.yaml b/plugins/create-pipedrive-app/skills/pipedrive-new-app/agents/openai.yaml new file mode 100644 index 0000000..4e79e8e --- /dev/null +++ b/plugins/create-pipedrive-app/skills/pipedrive-new-app/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Pipedrive New App" + short_description: "Scaffold Pipedrive Marketplace apps" + default_prompt: "Use $pipedrive-new-app to scaffold a new Pipedrive Marketplace app." diff --git a/plugins/create-pipedrive-app/skills/pipedrive-review-marketplace-readiness/SKILL.md b/plugins/create-pipedrive-app/skills/pipedrive-review-marketplace-readiness/SKILL.md new file mode 100644 index 0000000..3f23b08 --- /dev/null +++ b/plugins/create-pipedrive-app/skills/pipedrive-review-marketplace-readiness/SKILL.md @@ -0,0 +1,16 @@ +--- +name: pipedrive-review-marketplace-readiness +description: Review Marketplace readiness. Use when checking a generated Pipedrive app before submission. +allowed-tools: [Read, Bash] +--- + +# Marketplace Readiness Review + +Use `references/marketplace-readiness-checks.md` for the exact checks and report format. + +Workflow: + +1. Confirm `src/app.ts` and `package.json` exist. If either is missing, tell the developer to run this from the generated app root and stop. +2. Read the context files listed in the reference, then inspect all TypeScript files under `src/`. +3. Run all 4 readiness checks before reporting: token refresh, HTTPS, error handling, and rate limits. +4. Report only failing checks. If none fail, say the app is ready to submit. diff --git a/plugins/create-pipedrive-app/skills/pipedrive-review-marketplace-readiness/agents/openai.yaml b/plugins/create-pipedrive-app/skills/pipedrive-review-marketplace-readiness/agents/openai.yaml new file mode 100644 index 0000000..fc87c2a --- /dev/null +++ b/plugins/create-pipedrive-app/skills/pipedrive-review-marketplace-readiness/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Pipedrive Marketplace Readiness" + short_description: "Review Pipedrive Marketplace readiness" + default_prompt: "Use $pipedrive-review-marketplace-readiness to review this app." diff --git a/plugins/create-pipedrive-app/skills/pipedrive-review-marketplace-readiness/references/marketplace-readiness-checks.md b/plugins/create-pipedrive-app/skills/pipedrive-review-marketplace-readiness/references/marketplace-readiness-checks.md new file mode 100644 index 0000000..10d9fe8 --- /dev/null +++ b/plugins/create-pipedrive-app/skills/pipedrive-review-marketplace-readiness/references/marketplace-readiness-checks.md @@ -0,0 +1,109 @@ +# Marketplace Readiness Checks + +Read these files before checking: + +- `src/app.ts` +- `package.json` +- `src/pipedrive/client.ts`, if present +- `src/oauth/index.ts`, if present +- all `*.ts` files found by `find src -name "*.ts" | sort` + +Collect every failure before reporting. + +## 1. Token refresh for direct API calls + +Run: + +```bash +grep -rn "Authorization\|Bearer\|access_token" src --include="*.ts" | grep -v "src/pipedrive/client.ts" +``` + +Review each direct `fetch(` or `axios` call with an Authorization header. + +Skip safe cases: + +- Code inside `src/pipedrive/client.ts`; the SDK wrapper handles refresh. +- Tokens taken directly from the OAuth response in the same function scope. + +Fail if a stored token from the database is used in a direct HTTP call without 401 refresh-and-retry handling. + +Report: + +```text +Token refresh not handled for direct API calls + +`src/path/file.ts:line` makes a direct HTTP call using a stored access token without 401 refresh handling. + +Fix: Use `getClient(companyId)` from `src/pipedrive/client.ts`, or add a 401 retry that refreshes the token and retries the request. +``` + +## 2. HTTPS enforced + +Run: + +```bash +grep -rn "http://" src --include="*.ts" +grep -n "trust proxy" src/app.ts +``` + +Fail if any production URL uses `http://`; ignore localhost and 127.0.0.1. + +Fail if `src/app.ts` does not configure: + +```ts +app.set('trust proxy', 1); +``` + +Report hardcoded HTTP URLs with file and line. For missing proxy trust, tell the developer to add the setting before route definitions. + +## 3. Error handling + +Run: + +```bash +grep -n "NextFunction" src/app.ts +grep -rn "async.*req.*res" src --include="*.ts" +``` + +Fail if `src/app.ts` has no 4-argument Express error middleware like: + +```ts +app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { + console.error(err); + res.status(500).json({ error: err.message }); +}); +``` + +Fail if an async route handler does not wrap its body in `try/catch` and call `next(err)` in the catch. + +Report each missing handler by file and line, and tell the developer to wrap the route or add the error middleware. + +## 4. Rate limit handling + +Run: + +```bash +grep -rn "429\|X-RateLimit\|Retry-After" src --include="*.ts" +``` + +Fail if there is no handling for Pipedrive API rate limits. Pipedrive returns HTTP 429 and rate-limit headers including `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset`. + +Report: + +```text +No rate limit handling + +The app does not appear to handle HTTP 429 responses from Pipedrive. + +Fix: Add retry/backoff handling for 429 responses, using `Retry-After` or `X-RateLimit-Reset` when present. +``` + +## Final output + +If any checks fail, output only the failing checks and fixes. Do not mention passing checks. + +If all checks pass, output: + +```text +All marketplace readiness checks passed. Your app is ready to submit. +``` diff --git a/src/cli.test.ts b/src/cli.test.ts index ba2c474..ef9721a 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, afterEach } from 'vitest'; import { pathToFileURL } from 'node:url'; +import { access, mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import { isCliEntrypoint, nextStepLines } from './cli.js'; describe('nextStepLines', () => { @@ -9,7 +12,7 @@ Next steps: cd test-app cp .env.example .env # fill in PIPEDRIVE_CLIENT_ID and PIPEDRIVE_CLIENT_SECRET - docker-compose up`); + docker-compose up --watch`); }); }); @@ -30,3 +33,130 @@ describe('isCliEntrypoint', () => { expect(isCliEntrypoint(importMetaUrl, '/other/tool.js', (path) => path)).toBe(false); }); }); + +const dispatchTmpDir = join(tmpdir(), 'cpa-dispatch-test'); +const dispatchExists = (p: string) => + access(p).then( + () => true, + () => false, + ); + +afterEach(async () => { + await rm(dispatchTmpDir, { recursive: true, force: true }); +}); + +describe('dispatchSubcommand', () => { + it('returns false for unknown subcommand', async () => { + const { dispatchSubcommand } = await import('./cli.js'); + expect(await dispatchSubcommand(['node', 'cli.js', 'unknown'])).toBe(false); + }); + + it('returns true and runs add-app-extension for matching subcommand', async () => { + const { dispatchSubcommand } = await import('./cli.js'); + await mkdir(dispatchTmpDir, { recursive: true }); + await writeFile(join(dispatchTmpDir, 'package.json'), '{}'); + + const result = await dispatchSubcommand([ + 'node', + 'cli.js', + 'add-app-extension', + '--output-dir', + dispatchTmpDir, + '--app-extensions', + 'custom-panel', + ]); + + expect(result).toBe(true); + expect(await dispatchExists(join(dispatchTmpDir, 'src/app-extensions/panel/index.ts'))).toBe(true); + }); +}); + +describe('parseFlags', () => { + it('returns empty partial when no flags are present', async () => { + const { parseFlags } = await import('./cli.js'); + expect(parseFlags(['node', 'cli.js'])).toEqual({}); + }); + + it('parses --project-name', async () => { + const { parseFlags } = await import('./cli.js'); + expect(parseFlags(['node', 'cli.js', '--project-name', 'my-app'])).toMatchObject({ nameOrPath: 'my-app' }); + }); + + it('parses --database postgres', async () => { + const { parseFlags } = await import('./cli.js'); + expect(parseFlags(['node', 'cli.js', '--database', 'postgres'])).toMatchObject({ database: 'postgres' }); + }); + + it('parses --database mysql', async () => { + const { parseFlags } = await import('./cli.js'); + expect(parseFlags(['node', 'cli.js', '--database', 'mysql'])).toMatchObject({ database: 'mysql' }); + }); + + it('parses --database sqlite', async () => { + const { parseFlags } = await import('./cli.js'); + expect(parseFlags(['node', 'cli.js', '--database', 'sqlite'])).toMatchObject({ database: 'sqlite' }); + }); + + it('throws on invalid --database', async () => { + const { parseFlags } = await import('./cli.js'); + expect(() => parseFlags(['node', 'cli.js', '--database', 'oracle'])).toThrow( + 'Invalid database "oracle". Choose one of: postgres, mysql, sqlite.', + ); + }); + + it('parses --app-extensions none as empty array', async () => { + const { parseFlags } = await import('./cli.js'); + expect(parseFlags(['node', 'cli.js', '--app-extensions', 'none'])).toMatchObject({ appExtensions: [] }); + }); + + it('throws when --app-extensions combines none with extension types', async () => { + const { parseFlags } = await import('./cli.js'); + expect(() => parseFlags(['node', 'cli.js', '--app-extensions', 'none,custom-panel'])).toThrow( + '"none" cannot be combined with other extension types.', + ); + }); + + it('parses --app-extensions custom-panel', async () => { + const { parseFlags } = await import('./cli.js'); + expect(parseFlags(['node', 'cli.js', '--app-extensions', 'custom-panel'])).toMatchObject({ + appExtensions: ['custom-panel'], + }); + }); + + it('parses --app-extensions custom-panel,custom-modal', async () => { + const { parseFlags } = await import('./cli.js'); + expect(parseFlags(['node', 'cli.js', '--app-extensions', 'custom-panel,custom-modal'])).toMatchObject({ + appExtensions: ['custom-panel', 'custom-modal'], + }); + }); + + it('throws on invalid --app-extensions value', async () => { + const { parseFlags } = await import('./cli.js'); + expect(() => parseFlags(['node', 'cli.js', '--app-extensions', 'custom-widget'])).toThrow( + 'Invalid app extension type "custom-widget". Choose from: custom-panel, custom-modal.', + ); + }); + + it('throws on --project-name with empty value', async () => { + const { parseFlags } = await import('./cli.js'); + expect(() => parseFlags(['node', 'cli.js', '--project-name', ' '])).toThrow( + '--project-name cannot be empty.', + ); + }); + + it('parses all three flags together', async () => { + const { parseFlags } = await import('./cli.js'); + expect( + parseFlags([ + 'node', + 'cli.js', + '--project-name', + 'my-app', + '--database', + 'sqlite', + '--app-extensions', + 'none', + ]), + ).toEqual({ nameOrPath: 'my-app', database: 'sqlite', appExtensions: [] }); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 5d2b09c..87b5448 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,11 +1,16 @@ +#!/usr/bin/env node import * as clack from '@clack/prompts'; import { realpathSync } from 'node:fs'; import { basename, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; import { promptAppExtensions } from './prompts/appExtensions.js'; import { promptDatabase } from './prompts/database.js'; import { promptProjectName } from './prompts/projectName.js'; import { nodeGenerator } from './generators/node/index.js'; +import { addAppExtension } from './subcommands/addAppExtension.js'; +import type { AppExtensionType, Database } from './generators/interface.js'; +import { isAppExtensionType } from './generators/interface.js'; interface NextStepOptions { nameOrPath: string; @@ -16,7 +21,7 @@ export function nextStepLines(options: NextStepOptions): string[] { `cd ${options.nameOrPath}`, 'cp .env.example .env', '# fill in PIPEDRIVE_CLIENT_ID and PIPEDRIVE_CLIENT_SECRET', - 'docker-compose up', + 'docker-compose up --watch', ]; return ['', 'Next steps:', ...steps.map((s) => ` ${s}`)]; @@ -44,12 +49,100 @@ export function isCliEntrypoint( } } +interface ParsedFlags { + nameOrPath?: string; + database?: Database; + appExtensions?: AppExtensionType[]; +} + +export function parseFlags(argv: string[]): ParsedFlags { + const { values } = parseArgs({ + args: argv.slice(2), + options: { + 'project-name': { type: 'string' }, + 'database': { type: 'string' }, + 'app-extensions': { type: 'string' }, + }, + strict: false, + }); + + const result: ParsedFlags = {}; + + if (values['project-name'] !== undefined) { + const name = values['project-name'] as string; + if (!name.trim()) throw new Error('--project-name cannot be empty.'); + result.nameOrPath = name.trim(); + } + + if (values['database'] !== undefined) { + const db = values['database'] as string; + const valid: Database[] = ['postgres', 'mysql', 'sqlite']; + if (!valid.includes(db as Database)) { + throw new Error(`Invalid database "${db}". Choose one of: postgres, mysql, sqlite.`); + } + result.database = db as Database; + } + + if (values['app-extensions'] !== undefined) { + const raw = values['app-extensions'] as string; + if (raw === 'none') { + result.appExtensions = []; + } else { + if (raw.includes(',') && raw.split(',').includes('none')) { + throw new Error('"none" cannot be combined with other extension types.'); + } + const types = raw.split(','); + for (const type of types) { + if (!isAppExtensionType(type)) { + throw new Error(`Invalid app extension type "${type}". Choose from: custom-panel, custom-modal.`); + } + } + result.appExtensions = types as AppExtensionType[]; + } + } + + return result; +} + +export async function dispatchSubcommand(argv: string[]): Promise { + const subcommand = argv[2]; + const outputDirIdx = argv.indexOf('--output-dir'); + const outputDir = outputDirIdx !== -1 ? argv[outputDirIdx + 1] : undefined; + + if (subcommand === 'add-app-extension') { + const appExtIdx = argv.indexOf('--app-extensions'); + const appExtValue = appExtIdx !== -1 ? argv[appExtIdx + 1] : undefined; + let appExtensions: AppExtensionType[] | undefined; + if (appExtValue !== undefined) { + if (!isAppExtensionType(appExtValue)) { + throw new Error( + `Invalid app extension type "${appExtValue}". Choose from: custom-panel, custom-modal.`, + ); + } + appExtensions = [appExtValue]; + } + await addAppExtension(outputDir, appExtensions); + return true; + } + + return false; +} + async function main(): Promise { clack.intro('create-pipedrive-app'); - const nameOrPath = await promptProjectName(process.argv[2]); - const database = await promptDatabase(); - const appExtensions = await promptAppExtensions(); + let flags: ReturnType; + try { + flags = parseFlags(process.argv); + } catch (error) { + clack.log.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } + + const positional = process.argv[2]?.startsWith('--') ? undefined : process.argv[2]; + const nameOrPath = flags.nameOrPath ?? (await promptProjectName(positional)); + const database = flags.database ?? (await promptDatabase()); + const appExtensions = flags.appExtensions ?? (await promptAppExtensions()); const outputDir = resolve(process.cwd(), nameOrPath); const projectName = basename(outputDir); @@ -67,5 +160,12 @@ async function main(): Promise { } if (isCliEntrypoint(import.meta.url, process.argv[1])) { - void main(); + try { + if (!(await dispatchSubcommand(process.argv))) { + await main(); + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } } diff --git a/src/generators/interface.ts b/src/generators/interface.ts index d7a0d94..8468aed 100644 --- a/src/generators/interface.ts +++ b/src/generators/interface.ts @@ -1,5 +1,11 @@ export type Database = 'postgres' | 'mysql' | 'sqlite'; -export type AppExtensionType = 'custom-panel' | 'custom-modal'; + +export const APP_EXTENSION_TYPES = ['custom-panel', 'custom-modal'] as const; +export type AppExtensionType = (typeof APP_EXTENSION_TYPES)[number]; + +export function isAppExtensionType(value: string): value is AppExtensionType { + return (APP_EXTENSION_TYPES as readonly string[]).includes(value); +} export interface GeneratorOptions { projectName: string; diff --git a/src/generators/node/appExtensions/frontend.ts b/src/generators/node/appExtensions/frontend.ts index cefc610..ca727e3 100644 --- a/src/generators/node/appExtensions/frontend.ts +++ b/src/generators/node/appExtensions/frontend.ts @@ -78,7 +78,7 @@ function mainContent(options: AppExtensionFrontendOptions): string { return dedent` import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; - import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; + import { BrowserRouter, Route, Routes } from 'react-router-dom'; import Modal from './Modal'; import Panel from './Panel'; import '../shared/styles.css'; @@ -92,7 +92,6 @@ function mainContent(options: AppExtensionFrontendOptions): string { } /> } /> - } /> , @@ -104,7 +103,7 @@ function mainContent(options: AppExtensionFrontendOptions): string { return dedent` import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; - import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; + import { BrowserRouter, Route, Routes } from 'react-router-dom'; import Modal from './Modal'; import '../shared/styles.css'; @@ -116,7 +115,6 @@ function mainContent(options: AppExtensionFrontendOptions): string { } /> - } /> , @@ -127,7 +125,7 @@ function mainContent(options: AppExtensionFrontendOptions): string { return dedent` import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; - import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; + import { BrowserRouter, Route, Routes } from 'react-router-dom'; import Panel from './Panel'; import '../shared/styles.css'; @@ -139,7 +137,6 @@ function mainContent(options: AppExtensionFrontendOptions): string { } /> - } /> , diff --git a/src/generators/node/projectBuilder.ts b/src/generators/node/projectBuilder.ts index ce8056b..446b55b 100644 --- a/src/generators/node/projectBuilder.ts +++ b/src/generators/node/projectBuilder.ts @@ -288,11 +288,18 @@ function readmeContent(options: GeneratorOptions): string { export class NodeProjectBuilder { private steps: BuildStep[] = []; + private options: GeneratorOptions; constructor( private outputDir: string, - private options: GeneratorOptions, - ) {} + options: Partial = {}, + ) { + this.options = { + projectName: options.projectName ?? '', + database: options.database ?? 'postgres', + appExtensions: options.appExtensions ?? [], + }; + } addStep(step: BuildStep): this { this.steps.push(step); diff --git a/src/subcommands/addAppExtension.test.ts b/src/subcommands/addAppExtension.test.ts new file mode 100644 index 0000000..861a258 --- /dev/null +++ b/src/subcommands/addAppExtension.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { access, mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const tmpDir = join(tmpdir(), 'cpa-add-appext-test'); +const exists = (p: string) => + access(p).then( + () => true, + () => false, + ); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +describe('addAppExtension', () => { + it('generates panel files when custom-panel is passed', async () => { + const { addAppExtension } = await import('./addAppExtension.js'); + await mkdir(tmpDir, { recursive: true }); + await writeFile(join(tmpDir, 'package.json'), '{}'); + + await addAppExtension(tmpDir, ['custom-panel']); + + expect(await exists(join(tmpDir, 'src/app-extensions/panel/index.ts'))).toBe(true); + expect(await exists(join(tmpDir, 'src/app-extensions/modal/index.ts'))).toBe(false); + expect(await exists(join(tmpDir, 'frontend/app-extension-ui/src/Panel.tsx'))).toBe(true); + expect(await exists(join(tmpDir, 'frontend/app-extension-ui/src/Modal.tsx'))).toBe(false); + }); + + it('generates modal files when custom-modal is passed', async () => { + const { addAppExtension } = await import('./addAppExtension.js'); + await mkdir(tmpDir, { recursive: true }); + await writeFile(join(tmpDir, 'package.json'), '{}'); + + await addAppExtension(tmpDir, ['custom-modal']); + + expect(await exists(join(tmpDir, 'src/app-extensions/modal/index.ts'))).toBe(true); + expect(await exists(join(tmpDir, 'src/app-extensions/panel/index.ts'))).toBe(false); + expect(await exists(join(tmpDir, 'frontend/app-extension-ui/src/Modal.tsx'))).toBe(true); + expect(await exists(join(tmpDir, 'frontend/app-extension-ui/src/Panel.tsx'))).toBe(false); + }); + + it('throws when no package.json is present', async () => { + const { addAppExtension } = await import('./addAppExtension.js'); + await mkdir(tmpDir, { recursive: true }); + + await expect(addAppExtension(tmpDir, ['custom-panel'])).rejects.toThrow('No package.json found'); + }); +}); diff --git a/src/subcommands/addAppExtension.ts b/src/subcommands/addAppExtension.ts new file mode 100644 index 0000000..5d71736 --- /dev/null +++ b/src/subcommands/addAppExtension.ts @@ -0,0 +1,17 @@ +import { existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import type { AppExtensionType } from '../generators/interface.js'; +import { NodeProjectBuilder } from '../generators/node/projectBuilder.js'; +import { promptAppExtensions } from '../prompts/appExtensions.js'; + +export async function addAppExtension( + outputDir: string = process.cwd(), + appExtensions?: AppExtensionType[], +): Promise { + const resolved = resolve(outputDir); + if (!existsSync(join(resolved, 'package.json'))) { + throw new Error(`No package.json found in ${resolved}. Run this command from your project root.`); + } + const extensions = appExtensions ?? (await promptAppExtensions()); + await new NodeProjectBuilder(resolved, { appExtensions: extensions }).addAppExtensions().build(); +}