Merge twenty-cli into twenty-sdk (#16150)

- Moves twenty-cli content into twenty-sdk
- add a new twenty-sdk:0.1.0 version
- this new twenty-sdk exports a cli command called 'twenty' (like
twenty-cli before)
- deprecates twenty-cli
- simplify app init command base-project
- use `twenty-sdk:0.1.0` in base project
- move the "twenty-sdk/application" barrel to "twenty-sdk"
- add `create-twenty-app` package

<img width="1512" height="919" alt="image"
src="https://github.com/user-attachments/assets/007bef45-4e71-419a-9213-cebed376adbf"
/>

<img width="1506" height="929" alt="image"
src="https://github.com/user-attachments/assets/3de2fec6-1624-4923-ae13-f4e1cf165eb5"
/>
This commit is contained in:
martmull 2025-12-01 11:44:35 +01:00 committed by GitHub
parent 3f08a0c901
commit e498367e2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
85 changed files with 1077 additions and 1560 deletions

56
.github/workflows/ci-create-app.yaml vendored Normal file
View file

@ -0,0 +1,56 @@
name: CI Create App
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/create-twenty-app/**
packages/twenty-server/**
create-app-test:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test, build]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/workflows/actions/yarn-install
- name: Run ${{ matrix.task }} task
uses: ./.github/workflows/actions/nx-affected
with:
tag: scope:create-app
tasks: ${{ matrix.task }}
ci-create-app-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, create-app-test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1

View file

@ -1,4 +1,4 @@
name: CI CLI
name: CI SDK
on:
push:
@ -19,9 +19,9 @@ jobs:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/twenty-cli/**
packages/twenty-sdk/**
packages/twenty-server/**
cli-test:
sdk-test:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
@ -43,12 +43,12 @@ jobs:
- name: Run ${{ matrix.task }} task
uses: ./.github/workflows/actions/nx-affected
with:
tag: scope:cli
tag: scope:sdk
tasks: ${{ matrix.task }}
cli-e2e-test:
sdk-e2e-test:
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
needs: [changed-files-check, cli-test]
needs: [changed-files-check, sdk-test]
if: needs.changed-files-check.outputs.any_changed == 'true'
services:
postgres:
@ -90,13 +90,13 @@ jobs:
- name: Server / Create Test DB
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: CLI / Run E2E Tests
run: npx nx test:e2e twenty-cli
ci-cli-status-check:
- name: SDK / Run E2E Tests
run: npx nx test:e2e twenty-sdk
ci-sdk-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, cli-test, cli-e2e-test]
needs: [changed-files-check, sdk-test, sdk-e2e-test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')

View file

@ -231,6 +231,7 @@
"packages/twenty-sdk",
"packages/twenty-apps",
"packages/twenty-cli",
"packages/create-twenty-app",
"tools/eslint-rules"
]
}

View file

@ -0,0 +1,91 @@
<div align="center">
<a href="https://twenty.com">
<picture>
<img alt="Twenty logo" src="https://raw.githubusercontent.com/twentyhq/twenty/2f25922f4cd5bd61e1427c57c4f8ea224e1d552c/packages/twenty-website/public/images/core/logo.svg" height="128">
</picture>
</a>
<h1>Create Twenty App</h1>
<a href="https://www.npmjs.com/package/create-twenty-app"><img alt="NPM version" src="https://img.shields.io/npm/v/create-twenty-app.svg?style=for-the-badge&labelColor=000000"></a>
<a href="https://github.com/twentyhq/twenty/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/npm/l/next.svg?style=for-the-badge&labelColor=000000"></a>
<a href="https://discord.gg/cx5n4Jzs57"><img alt="Join the community on Discord" src="https://img.shields.io/badge/Join%20the%20community-blueviolet.svg?style=for-the-badge&logo=Twenty&labelColor=000000&logoWidth=20"></a>
</div>
Create Twenty App is the official scaffolding CLI for building apps on top of [Twenty CRM](https://twenty.com). It sets up a readytorun project that works seamlessly with the [twenty-sdk](https://www.npmjs.com/package/twenty-sdk).
- Zeroconfig project bootstrap
- Preconfigured scripts for auth, generate, dev sync, oneoff sync, uninstall
- Strong TypeScript support and typed client generation
## Prerequisites
- Node.js 18+ (recommended) and Yarn 4
- A Twenty workspace and an API key (create one at https://app.twenty.com/settings/api-webhooks)
## Quick start
```bash
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# Authenticate using your API key (you'll be prompted)
yarn auth
# Add a new entity to your application (guided)
yarn create-entity
# Generate a typed Twenty client and workspace entity types
yarn generate
# Start dev mode: automatically syncs local changes to your workspace
yarn dev
# Or run a onetime sync
yarn sync
# Uninstall the application from the current workspace
yarn uninstall
```
## What gets scaffolded
- A minimal app structure ready for Twenty
- TypeScript configuration
- Prewired scripts that wrap the `twenty` CLI from twenty-sdk
- Example placeholders to help you add entities, actions, and sync logic
## Next steps
- Explore the generated project and add your first entity with `yarn create-entity`.
- Keep your types uptodate using `yarn generate`.
- Use `yarn dev` while you iterate to see changes instantly in your workspace.
## Publish your application
Applications are currently stored in `twenty/packages/twenty-apps`.
You can share your application with all Twenty users:
```bash
# pull the Twenty project
git clone https://github.com/twentyhq/twenty.git
cd twenty
# create a new branch
git checkout -b feature/my-awesome-app
```
- Copy your app folder into `twenty/packages/twenty-apps`.
- Commit your changes and open a pull request on https://github.com/twentyhq/twenty
```bash
git commit -m "Add new application"
git push
```
Our team reviews contributions for quality, security, and reusability before merging.
## Troubleshooting
- Auth prompts not appearing: run `yarn auth` again and verify the API key permissions.
- Types not generated: ensure `yarn generate` runs without errors, then restart `yarn dev`.
## Contributing
- See our [GitHub](https://github.com/twentyhq/twenty)
- Join our [Discord](https://discord.gg/cx5n4Jzs57)

View file

@ -0,0 +1,46 @@
{
"name": "create-twenty-app",
"version": "0.1.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.js",
"bin": "dist/cli.js",
"files": [
"dist/**/*"
],
"scripts": {
"build": "echo 'use npx nx build'",
"dev": "tsx src/cli.ts",
"start": "node dist/cli.js"
},
"keywords": [
"twenty",
"cli",
"crm",
"application",
"development"
],
"license": "AGPL-3.0",
"dependencies": {
"@genql/cli": "^3.0.3",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"fs-extra": "^11.2.0",
"inquirer": "^10.0.0",
"lodash.camelcase": "^4.3.0",
"lodash.kebabcase": "^4.1.1",
"lodash.startcase": "^4.4.0",
"uuid": "^13.0.0"
},
"devDependencies": {
"@types/fs-extra": "^11.0.0",
"@types/inquirer": "^9.0.0",
"@types/lodash.camelcase": "^4.3.7",
"@types/lodash.kebabcase": "^4.1.7",
"@types/lodash.startcase": "^4",
"@types/node": "^20.0.0"
},
"engines": {
"node": "^24.5.0",
"yarn": "^4.0.2"
}
}

View file

@ -1,34 +1,23 @@
{
"name": "twenty-cli",
"name": "create-twenty-app",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"tags": ["scope:cli"],
"projectType": "library",
"tags": ["scope:create-app"],
"targets": {
"before-build": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "packages/twenty-cli",
"commands": ["rimraf dist", "tsc --project tsconfig.lib.json"]
},
"dependsOn": ["^build", "typecheck"]
},
"build": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "packages/twenty-cli",
"commands": [
"cp -R src/constants/base-application-project dist/constants"
]
"cwd": "packages/create-twenty-app",
"commands": ["rimraf dist", "tsc --project tsconfig.json"]
},
"dependsOn": ["before-build"]
"dependsOn": ["^build", "typecheck"]
},
"dev": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"cwd": "packages/twenty-cli",
"cwd": "packages/create-twenty-app",
"command": "tsx src/cli.ts"
}
},
@ -36,7 +25,7 @@
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"cwd": "packages/twenty-cli",
"cwd": "packages/create-twenty-app",
"command": "node dist/cli.js"
}
},
@ -67,28 +56,6 @@
"watchAll": false
}
}
},
"test:e2e": {
"executor": "nx:run-commands",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"cwd": "packages/twenty-cli",
"commands": [
"npx wait-on http://localhost:3000/healthz --timeout 600000 --interval 1000 --log && NODE_ENV=test npx jest --config ./jest.e2e.config.ts"
]
},
"parallel": false,
"dependsOn": [
"build",
{
"target": "database:reset",
"projects": "twenty-server"
},
{
"target": "start:ci-if-needed",
"projects": "twenty-server"
}
]
}
}
}

View file

@ -0,0 +1,45 @@
#!/usr/bin/env node
import chalk from 'chalk';
import { Command, CommanderError } from 'commander';
import { readFileSync } from 'fs';
import { join } from 'path';
import { CreateAppCommand } from './create-app.command';
const packageJson = JSON.parse(
readFileSync(join(__dirname, '../package.json'), 'utf-8'),
);
const program = new Command(packageJson.name)
.description('CLI tool to initialize a new Twenty application')
.version(
packageJson.version,
'-v, --version',
'Output the current version of create-twenty-app.',
)
.argument('[directory]')
.helpOption('-h, --help', 'Display this help message.')
.action(async (directory?: string) => {
if (directory && !/^[a-z0-9-]+$/.test(directory)) {
console.error(
chalk.red(
`Invalid directory "${directory}". Must contain only lowercase letters, numbers, and hyphens`,
),
);
process.exit(1);
}
await new CreateAppCommand().execute(directory);
});
program.exitOverride();
try {
program.parse();
} catch (error) {
if (error instanceof CommanderError) {
process.exit(error.exitCode);
}
if (error instanceof Error) {
console.error(chalk.red('Error:'), error.message);
process.exit(1);
}
}

View file

@ -2,11 +2,17 @@ import chalk from 'chalk';
import * as fs from 'fs-extra';
import inquirer from 'inquirer';
import * as path from 'path';
import { copyBaseApplicationProject } from '../utils/app-template';
import { exec } from 'child_process';
import { promisify } from 'util';
import { copyBaseApplicationProject } from './utils/app-template';
import kebabCase from 'lodash.kebabcase';
import { convertToLabel } from '../utils/convert-to-label';
import { convertToLabel } from './utils/convert-to-label';
export class AppInitCommand {
const CURRENT_EXECUTION_DIRECTORY = process.env.INIT_CWD || process.cwd();
const execPromise = promisify(exec);
export class CreateAppCommand {
async execute(directory?: string): Promise<void> {
try {
const { appName, appDisplayName, appDirectory, appDescription } =
@ -25,7 +31,18 @@ export class AppInitCommand {
appDirectory,
});
this.logSuccess(appDirectory);
try {
const result = await execPromise('yarn --version', {
cwd: appDirectory,
});
console.log('Installing dependencies using yarn', result.stdout);
await execPromise('yarn', { cwd: appDirectory });
} catch (error: any) {
console.error(chalk.red('yarn install failed:'), error.stdout);
process.exit(1);
}
await this.logSuccess(appDirectory);
} catch (error) {
console.error(
chalk.red('Initialization failed:'),
@ -78,8 +95,8 @@ export class AppInitCommand {
const appDescription = description.trim();
const appDirectory = directory
? path.join(process.cwd(), kebabCase(directory))
: path.join(process.cwd(), kebabCase(appName));
? path.join(CURRENT_EXECUTION_DIRECTORY, directory)
: path.join(CURRENT_EXECUTION_DIRECTORY, kebabCase(appName));
return { appName, appDisplayName, appDirectory, appDescription };
}
@ -114,7 +131,7 @@ export class AppInitCommand {
console.log(chalk.green('✅ Application created successfully!'));
console.log('');
console.log(chalk.blue('Next steps:'));
console.log(` cd ${appDirectory}`);
console.log(` cd ${appDirectory.split('/').reverse()[0] ?? ''}`);
console.log(' twenty app dev');
}
}

View file

@ -0,0 +1,176 @@
import * as fs from 'fs-extra';
import { join } from 'path';
import { v4 } from 'uuid';
export const copyBaseApplicationProject = async ({
appName,
appDisplayName,
appDescription,
appDirectory,
}: {
appName: string;
appDisplayName: string;
appDescription: string;
appDirectory: string;
}) => {
await createPackageJson({ appName, appDirectory });
await createYarnLock(appDirectory);
await createYarnRc(appDirectory);
await createNvmRc(appDirectory);
await createTsConfig(appDirectory);
await createApplicationConfig({
displayName: appDisplayName,
description: appDescription,
appDirectory,
});
await createReadmeContent({
displayName: appDisplayName,
appDescription,
appDirectory,
});
};
const createYarnLock = async (appDirectory: string) => {
const yarnLockContent = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
`;
await fs.writeFile(join(appDirectory, 'yarn.lock'), yarnLockContent);
};
const createYarnRc = async (appDirectory: string) => {
const yarnRcContent = `nodeLinker: node-modules
`;
await fs.writeFile(join(appDirectory, '.yarnrc.yml'), yarnRcContent);
};
const createNvmRc = async (appDirectory: string) => {
const nvmRcContent = `24.5.0
`;
await fs.writeFile(join(appDirectory, '.nvmrc'), nvmRcContent);
};
const createTsConfig = async (appDirectory: string) => {
const tsConfigJson = {
compileOnSave: false,
compilerOptions: {
sourceMap: true,
declaration: true,
outDir: './dist',
rootDir: '.',
moduleResolution: 'node',
allowSyntheticDefaultImports: true,
emitDecoratorMetadata: true,
experimentalDecorators: true,
importHelpers: true,
allowUnreachableCode: false,
strictNullChecks: true,
alwaysStrict: true,
noImplicitAny: true,
strictBindCallApply: false,
target: 'es2018',
module: 'esnext',
lib: ['es2020', 'dom'],
skipLibCheck: true,
skipDefaultLibCheck: true,
resolveJsonModule: true,
},
exclude: ['node_modules', 'dist', '**/*.test.ts', '**/*.spec.ts'],
};
await fs.writeFile(
join(appDirectory, 'tsconfig.json'),
JSON.stringify(tsConfigJson, null, 2),
'utf8',
);
};
const createApplicationConfig = async ({
displayName,
description,
appDirectory,
}: {
displayName: string;
description?: string;
appDirectory: string;
}) => {
const content = `import { type ApplicationConfig } from 'twenty-sdk';
const config: ApplicationConfig = {
universalIdentifier: '${v4()}',
displayName: '${displayName}',
description: '${description ?? ''}',
};
export default config;
`;
await fs.writeFile(join(appDirectory, 'application.config.ts'), content);
};
const createPackageJson = async ({
appName,
appDirectory,
}: {
appName: string;
appDirectory: string;
}) => {
const packageJson = {
name: appName,
version: '0.0.1',
license: 'MIT',
engines: {
node: '^24.5.0',
npm: 'please-use-yarn',
yarn: '>=4.0.2',
},
packageManager: 'yarn@4.9.2',
scripts: {
'create-entity': 'twenty app add',
dev: 'twenty app dev',
generate: 'twenty app generate',
sync: 'twenty app sync',
uninstall: 'twenty app uninstall',
auth: 'twenty auth login',
},
dependencies: {
'twenty-sdk': '0.1.0',
},
devDependencies: {
'@types/node': '^24.7.2',
typescript: '^5.9.3',
},
};
await fs.writeFile(
join(appDirectory, 'package.json'),
JSON.stringify(packageJson, null, 2),
'utf8',
);
};
const createReadmeContent = async ({
displayName,
appDescription,
appDirectory,
}: {
displayName: string;
appDescription: string;
appDirectory: string;
}) => {
const readmeContent = `# ${displayName}
${appDescription}
`;
await fs.writeFile(join(appDirectory, 'README.md'), readmeContent);
};

View file

@ -3,19 +3,24 @@
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"target": "es2022",
"module": "commonjs",
"target": "ES2022",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"declaration": false,
"sourceMap": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts", "**/*.e2e-spec.ts", "**/__tests__/**"]
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts",
"**/__tests__/**"
]
}

View file

@ -1,13 +1,16 @@
# Why Twenty CLI?
# Deprecated: twenty-cli
A command-line interface to easily scaffold, develop, and publish applications that extend Twenty CRM
## Installation
This package is deprecated. Please install and use twenty-sdk instead:
```bash
npm install -g twenty-cli
npm uninstall twenty-cli
npm install -g twenty-sdk
```
The command name remains the same: twenty.
A command-line interface to easily scaffold, develop, and publish applications that extend Twenty CRM (now provided by twenty-sdk).
## Requirements
- yarn >= 4.9.2
- an `apiKey`. Go to `https://twenty.com/settings/api-webhooks` to generate one

View file

@ -0,0 +1,5 @@
#!/usr/bin/env node
const message = `\nTwenty CLI (twenty-cli) is deprecated.\n\nPlease install and use the new package instead:\n npm install -g twenty-sdk\n\nThe command name remains the same: \"twenty\".\nMore info: https://www.npmjs.com/package/twenty-sdk\n`;
console.error(message);
process.exitCode = 1;

View file

@ -1,64 +1,10 @@
{
"name": "twenty-cli",
"version": "0.2.4",
"description": "Command-line interface for Twenty application development",
"main": "dist/cli.js",
"bin": {
"twenty": "dist/cli.js"
},
"files": [
"dist/**/*",
"!dist/**/*.e2e-spec.*",
"!dist/**/__tests__/**"
],
"version": "0.3.0",
"description": "[DEPRECATED] Use twenty-sdk instead: https://www.npmjs.com/package/twenty-sdk",
"scripts": {
"build": "echo 'use npx nx build'",
"dev": "tsx src/cli.ts",
"start": "node dist/cli.js"
"start": "echo 'deprecated'"
},
"keywords": [
"twenty",
"cli",
"crm",
"application",
"development"
],
"license": "AGPL-3.0",
"dependencies": {
"@genql/cli": "^3.0.3",
"ajv": "^8.12.0",
"ajv-formats": "^2.1.1",
"axios": "^1.6.0",
"chalk": "^5.3.0",
"chokidar": "^4.0.0",
"commander": "^12.0.0",
"dotenv": "^16.4.0",
"fs-extra": "^11.2.0",
"graphql": "^16.8.1",
"inquirer": "^10.0.0",
"jsonc-parser": "^3.2.0",
"lodash.camelcase": "^4.3.0",
"lodash.capitalize": "^4.2.1",
"lodash.kebabcase": "^4.1.1",
"lodash.startcase": "^4.4.0",
"typescript": "^5.9.2",
"uuid": "^13.0.0"
},
"devDependencies": {
"@types/fs-extra": "^11.0.0",
"@types/inquirer": "^9.0.0",
"@types/jest": "^29.5.0",
"@types/lodash.camelcase": "^4.3.7",
"@types/lodash.capitalize": "^4",
"@types/lodash.kebabcase": "^4.1.7",
"@types/lodash.startcase": "^4",
"@types/node": "^20.0.0",
"jest": "^29.5.0",
"tsx": "^4.7.0",
"wait-on": "^7.2.0"
},
"engines": {
"node": "^24.5.0",
"yarn": "^4.0.2"
}
"license": "AGPL-3.0"
}

View file

@ -1,26 +0,0 @@
# Set environment values for your application here.
# Use the format: KEY=value
#
# These variables are automatically loaded when running your serverless functions.
# You can access them directly in your code using:
# const myValue = process.env.KEY;
#
# To make these variables available to your application, add them in application.config.ts file
#
# const config: ApplicationConfig = {
# ...
# applicationVariables: {
# KEY: {
# universalIdentifier: 'dedc53eb-9c12-4fe2-ba86-4a2add19d305',
# description: 'Description',
# isSecret: true,
# },
# },
# };
#
# Those environment variables will be provided to your serverless
# functions at runtime.
#
# Example:
# API_TOKEN=your-api-token
# TIMEOUT_MS=3000

View file

@ -1,5 +0,0 @@
# Duplicated with ./gitignore because npm publish does not include .gitignore
# https://github.com/npm/npm/issues/3763
.yarn/install-state.gz
.env

File diff suppressed because one or more lines are too long

View file

@ -1,3 +0,0 @@
yarnPath: .yarn/releases/yarn-4.9.2.cjs
nodeLinker: node-modules

View file

@ -1,15 +0,0 @@
# {title}
{description}
## Requirements
- twenty-cli `npm install -g twenty-cli`
- an `apiKey`. Go to `https://twenty.com/settings/api-webhooks` to generate one
## Install to your Twenty workspace
```bash
twenty auth login
twenty app sync
```

View file

@ -1,2 +0,0 @@
.yarn/install-state.gz
.env

View file

@ -1,17 +0,0 @@
{
"name": "my-application",
"version": "0.0.1",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"dependencies": {
"twenty-sdk": "0.0.6"
},
"devDependencies": {
"@types/node": "^24.7.2"
}
}

View file

@ -1,27 +0,0 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"allowUnreachableCode": false,
"strictNullChecks": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
},
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}

View file

@ -1,38 +0,0 @@
# This file is generated by running "yarn install" inside your project.
# Manual changes might be lost - proceed with caution!
__metadata:
version: 8
cacheKey: 10c0
"@types/node@npm:^24.7.2":
version: 24.9.1
resolution: "@types/node@npm:24.9.1"
dependencies:
undici-types: "npm:~7.16.0"
checksum: 10c0/c52f8168080ef9a7c3dc23d8ac6061fab5371aad89231a0f6f4c075869bc3de7e89b075b1f3e3171d9e5143d0dda1807c3dab8e32eac6d68f02e7480e7e78576
languageName: node
linkType: hard
"root-workspace-0b6124@workspace:.":
version: 0.0.0-use.local
resolution: "root-workspace-0b6124@workspace:."
dependencies:
"@types/node": "npm:^24.7.2"
twenty-sdk: "npm:^0.0.2"
languageName: unknown
linkType: soft
"twenty-sdk@npm:^0.0.2":
version: 0.0.2
resolution: "twenty-sdk@npm:0.0.2"
checksum: 10c0/99e6fe86059d847b548c1f03e0f0c59a4d540caf1d28dd4500f1f5f0094196985ded955801274de9e72ff03e3d1f41e9a509b4c2c5a02ffc8a027277b1e35d8e
languageName: node
linkType: hard
"undici-types@npm:~7.16.0":
version: 7.16.0
resolution: "undici-types@npm:7.16.0"
checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a
languageName: node
linkType: hard

View file

@ -1,8 +0,0 @@
import { join } from 'path';
const BASE_PATH = join(__dirname, '../constants');
export const BASE_APPLICATION_PROJECT_PATH = join(
BASE_PATH,
'base-application-project',
);

View file

@ -1,107 +0,0 @@
import * as fs from 'fs-extra';
import { BASE_APPLICATION_PROJECT_PATH } from '../constants/constants-path';
import { writeJsoncFile } from '../utils/jsonc-parser';
import { join } from 'path';
import path from 'path';
import { v4 } from 'uuid';
export const copyBaseApplicationProject = async ({
appName,
appDisplayName,
appDescription,
appDirectory,
}: {
appName: string;
appDisplayName: string;
appDescription: string;
appDirectory: string;
}) => {
await fs.copy(BASE_APPLICATION_PROJECT_PATH, appDirectory);
await fs.rename(
join(appDirectory, 'gitignore'),
join(appDirectory, '.gitignore'),
);
await fs.copy(join(appDirectory, '.env.example'), join(appDirectory, '.env'));
await createBasePackageJson({
appName,
appDirectory,
});
await createApplicationConfig({
displayName: appDisplayName,
description: appDescription,
appDirectory,
});
await createReadmeContent({
displayName: appDisplayName,
appDescription,
appDirectory,
});
};
const createApplicationConfig = async ({
displayName,
description,
appDirectory,
}: {
displayName: string;
description?: string;
appDirectory: string;
}) => {
const content = `import { type ApplicationConfig } from 'twenty-sdk/application';
const config: ApplicationConfig = {
universalIdentifier: '${v4()}',
displayName: '${displayName}',
description: '${description ?? ''}',
};
export default config;
`;
await fs.writeFile(path.join(appDirectory, 'application.config.ts'), content);
};
const createBasePackageJson = async ({
appName,
appDirectory,
}: {
appName: string;
appDirectory: string;
}) => {
const base = JSON.parse(await readBaseApplicationProjectFile('package.json'));
base['universalIdentifier'] = v4();
base['name'] = appName;
await writeJsoncFile(join(appDirectory, 'package.json'), base);
};
const createReadmeContent = async ({
displayName,
appDescription,
appDirectory,
}: {
displayName: string;
appDescription: string;
appDirectory: string;
}) => {
let readmeContent = await readBaseApplicationProjectFile('README.md');
readmeContent = readmeContent.replace(/\{title}/g, displayName);
readmeContent = readmeContent.replace(/\{description}/g, appDescription);
await fs.writeFile(path.join(appDirectory, 'README.md'), readmeContent);
};
const readBaseApplicationProjectFile = async (fileName: string) => {
return await fs.readFile(
join(BASE_APPLICATION_PROJECT_PATH, fileName),
'utf-8',
);
};

View file

@ -1,8 +0,0 @@
{
"extends": "./tsconfig.lib.json",
"compilerOptions": {
"composite": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts", "**/*.e2e-spec.ts", "**/__tests__/**"]
}

View file

@ -1,14 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["jest", "node"]
},
"include": [
"src/**/*",
"src/**/__tests__/**/*.spec.ts"
],
"exclude": [
"node_modules",
"dist",
]
}

View file

@ -1,6 +1,26 @@
# twenty-sdk
<div align="center">
<a href="https://twenty.com">
<picture>
<img alt="Twenty logo" src="https://raw.githubusercontent.com/twentyhq/twenty/2f25922f4cd5bd61e1427c57c4f8ea224e1d552c/packages/twenty-website/public/images/core/logo.svg" height="128">
</picture>
</a>
<h1>Twenty SDK</h1>
A lightweight TypeScript SDK for Twenty CRM.
<a href="https://www.npmjs.com/package/twenty-sdk"><img alt="NPM version" src="https://img.shields.io/npm/v/twenty-sdk.svg?style=for-the-badge&labelColor=000000"></a>
<a href="https://github.com/twentyhq/twenty/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/npm/l/next.svg?style=for-the-badge&labelColor=000000"></a>
<a href="https://discord.gg/cx5n4Jzs57"><img alt="Join the community on Discord" src="https://img.shields.io/badge/Join%20the%20community-blueviolet.svg?style=for-the-badge&logo=Twenty&labelColor=000000&logoWidth=20"></a>
</div>
A CLI and SDK to develop, build, and publish applications that extend [Twenty CRM](https://twenty.com).
- Typesafe client and workspace entity typings
- Builtin CLI for auth, generate, dev sync, oneoff sync, and uninstall
- Works great with the scaffolder: [create-twenty-app](https://www.npmjs.com/package/create-twenty-app)
## Prerequisites
- Node.js 18+ (recommended) and Yarn 4
- A Twenty workspace and an API key. Generate one at https://app.twenty.com/settings/api-webhooks
## Installation
@ -10,18 +30,72 @@ npm install twenty-sdk
yarn add twenty-sdk
```
## Usage
## Getting started
You can either scaffold a new app or add the SDK to an existing one.
- Start new (recommended):
```bash
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
```
- Existing project: install the SDK as shown above, then use the CLI below.
## CLI quickstart
```bash
# Authenticate using your API key (CLI will prompt for it)
twenty auth login
# Add a new entity to your application (guided prompts)
twenty app add
# Generate a typed Twenty client and TypeScript definitions for your workspace entities
twenty app generate
# Start dev mode: automatically syncs changes to your workspace for instant testing
twenty app dev
# Onetime sync of local changes
twenty app sync
# Uninstall the application from the current workspace
twenty app uninstall
```
## Usage (SDK)
```typescript
// Example: import what you need from the SDK
import { /* your exports */ } from 'twenty-sdk';
```
## Development
## Publish your application
Applications are currently stored in [`twenty/packages/twenty-apps`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps).
You can share your application with all Twenty users:
```bash
# Build
npx nx build twenty-sdk
# pull the Twenty project
git clone https://github.com/twentyhq/twenty.git
cd twenty
# Lint
npx nx lint twenty-sdk
# create a new branch
git checkout -b feature/my-awesome-app
```
- Copy your app folder into `twenty/packages/twenty-apps`.
- Commit your changes and open a pull request on https://github.com/twentyhq/twenty
```bash
git commit -m "Add new application"
git push
```
Our team reviews contributions for quality, security, and reusability.
## Troubleshooting
- Auth errors: run `twenty auth login` again and ensure the API key has the required permissions.
- Typings out of date: run `twenty app generate` to refresh the client and types.
- Not seeing changes in dev: make sure dev mode is running (`twenty app dev`).
## Contributing
- See our [GitHub](https://github.com/twentyhq/twenty)
- Join our [Discord](https://discord.gg/cx5n4Jzs57)

View file

@ -3,6 +3,12 @@ import baseConfig from '../../eslint.config.mjs';
export default [
...baseConfig,
{
ignores: ['**/dist/**', 'vite.config.ts'],
ignores: ['**/dist/**'],
},
{
rules: {
'no-console': 'off',
},
ignores: ['src/**/*.ts', '!src/cli/**/*.ts'],
},
];

View file

@ -0,0 +1,40 @@
const jestConfig = {
displayName: 'twenty-cli',
preset: '../../jest.preset.js',
testEnvironment: 'node',
transformIgnorePatterns: ['../../node_modules/'],
transform: {
'^.+\\.[tj]sx?$': [
'@swc/jest',
{
jsc: {
parser: { syntax: 'typescript', tsx: false },
},
},
],
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
moduleFileExtensions: ['ts', 'js'],
extensionsToTreatAsEsm: ['.ts'],
coverageDirectory: './coverage',
testMatch: [
'<rootDir>/src/**/__tests__/**/*.(test|spec).{js,ts}',
'<rootDir>/src/**/?(*.)(test|spec).{js,ts}',
],
collectCoverageFrom: [
'src/**/*.{ts,js}',
'!src/**/*.d.ts',
'!src/cli/cli.ts',
],
coverageThreshold: {
global: {
statements: 1,
lines: 1,
functions: 1,
},
},
};
export default jestConfig;

View file

@ -4,7 +4,7 @@ const jestConfig: JestConfigWithTsJest = {
// For more information please have a look to official docs https://jestjs.io/docs/configuration/#prettierpath-string
// Prettier v3 should be supported in jest v30 https://github.com/jestjs/jest/releases/tag/v30.0.0-alpha.1
prettierPath: null,
displayName: 'twenty-cli-e2e',
displayName: 'twenty-sdk-e2e',
silent: false,
errorOnDeprecated: true,
maxConcurrency: 1,
@ -13,8 +13,8 @@ const jestConfig: JestConfigWithTsJest = {
testEnvironment: 'node',
testRegex: '\\.e2e-spec\\.ts$',
modulePathIgnorePatterns: ['<rootDir>/dist'],
globalTeardown: '<rootDir>/src/__tests__/e2e/teardown.ts',
setupFilesAfterEnv: ['<rootDir>/src/__tests__/e2e/setupTest.ts'],
globalTeardown: '<rootDir>/src/cli/__tests__/e2e/teardown.ts',
setupFilesAfterEnv: ['<rootDir>/src/cli/__tests__/e2e/setupTest.ts'],
testTimeout: 30000, // 30 seconds timeout for e2e tests
maxWorkers: 1,
transform: {

View file

@ -1,41 +1,69 @@
{
"name": "twenty-sdk",
"version": "0.0.6",
"version": "0.1.0",
"license": "AGPL-3.0",
"main": "dist/index.cjs",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"bin": {
"twenty": "dist/cli/cli.js"
},
"keywords": [
"twenty",
"cli",
"sdk",
"crm",
"application",
"development"
],
"files": [
"dist",
"application"
"dist"
],
"scripts": {
"build": "vite build"
"build": "vite build && tsc --project tsconfig.cli.json"
},
"dependencies": {
"@genql/cli": "^3.0.3",
"axios": "^1.6.0",
"chalk": "^5.3.0",
"chokidar": "^4.0.0",
"commander": "^12.0.0",
"dotenv": "^16.4.0",
"fs-extra": "^11.2.0",
"graphql": "^16.8.1",
"inquirer": "^10.0.0",
"jsonc-parser": "^3.2.0",
"lodash.camelcase": "^4.3.0",
"lodash.capitalize": "^4.2.1",
"lodash.kebabcase": "^4.1.1",
"lodash.startcase": "^4.4.0",
"typescript": "^5.9.2",
"uuid": "^13.0.0"
},
"devDependencies": {
"@types/fs-extra": "^11.0.0",
"@types/inquirer": "^9.0.0",
"@types/jest": "^29.5.0",
"@types/lodash.camelcase": "^4.3.7",
"@types/lodash.capitalize": "^4",
"@types/lodash.kebabcase": "^4.1.7",
"@types/lodash.startcase": "^4",
"@types/node": "^24.0.0",
"typescript": "5.9.2",
"jest": "^29.5.0",
"tsx": "^4.7.0",
"vite": "^7.0.0",
"vite-plugin-dts": "3.8.1",
"vite-tsconfig-paths": "^4.2.1"
"vite-tsconfig-paths": "^4.2.1",
"wait-on": "^7.2.0"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./application": {
"types": "./dist/application/index.d.ts",
"import": "./dist/application.mjs",
"require": "./dist/application.cjs"
}
},
"typesVersions": {
"*": {
"application": [
"dist/application/index.d.ts"
]
}
"*": {}
}
}

View file

@ -13,11 +13,19 @@
"^build"
],
"outputs": [
"{projectRoot}/dist",
"{projectRoot}/application/package.json",
"{projectRoot}/application/dist"
"{projectRoot}/dist"
]
},
"start": {
"executor": "nx:run-commands",
"dependsOn": [
"build"
],
"options": {
"cwd": "packages/twenty-sdk",
"command": "node dist/cli/cli.js"
}
},
"generateBarrels": {
"executor": "nx:run-commands",
"cache": true,
@ -35,9 +43,58 @@
}
},
"lint": {
"options": {
"lintFilePatterns": [
"{projectRoot}/src/**/*.{ts,json}"
],
"maxWarnings": 0
},
"configurations": {
"ci": {
"lintFilePatterns": [
"{projectRoot}/src/**/*.{ts,json}"
],
"maxWarnings": 0
},
"fix": {}
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": [
"{workspaceRoot}/coverage/{projectRoot}"
],
"options": {
"jestConfig": "{projectRoot}/jest.config.mjs"
},
"configurations": {
"ci": {
"ci": true,
"coverage": true,
"watchAll": false
}
}
},
"test:e2e": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-sdk",
"commands": [
"npx wait-on http://localhost:3000/healthz --timeout 600000 --interval 1000 --log && NODE_ENV=test npx jest --config ./jest.e2e.config.ts"
]
},
"parallel": false,
"dependsOn": [
"build",
{
"target": "database:reset",
"projects": "twenty-server"
},
{
"target": "start:ci-if-needed",
"projects": "twenty-server"
}
]
}
}
}

View file

@ -21,6 +21,22 @@ const NX_PROJECT_CONFIGURATION_PATH = path.join(
PACKAGE_PATH,
NX_PROJECT_CONFIGURATION_FILENAME,
);
const EXCLUDED_EXTENSIONS = [
'**/*.test.ts',
'**/*.test.tsx',
'**/*.spec.ts',
'**/*.spec.tsx',
'**/*.stories.ts',
'**/*.stories.tsx',
] as const;
const EXCLUDED_DIRECTORIES = [
'**/__tests__/**',
'**/__mocks__/**',
'**/__stories__/**',
'**/internal/**',
'**/cli/**',
] as const;
const ROOT_DIRECTORIES = ['application'];
const prettierConfigFile = prettier.resolveConfigFile();
if (prettierConfigFile == null) {
@ -257,24 +273,10 @@ const computeProjectNxBuildOutputsPath = (moduleDirectories: string[]) => {
return ['{projectRoot}/dist', ...dynamicOutputsPath];
};
const EXCLUDED_EXTENSIONS = [
'**/*.test.ts',
'**/*.test.tsx',
'**/*.spec.ts',
'**/*.spec.tsx',
'**/*.stories.ts',
'**/*.stories.tsx',
] as const;
const EXCLUDED_DIRECTORIES = [
'**/__tests__/**',
'**/__mocks__/**',
'**/__stories__/**',
'**/internal/**',
] as const;
function getTypeScriptFiles(
const getTypeScriptFiles = (
directoryPath: string,
includeIndex: boolean = false,
): string[] {
): string[] => {
const pattern = slash(path.join(directoryPath, '**', '*.{ts,tsx}'));
const files = globSync(pattern, {
cwd: SRC_PATH,
@ -287,7 +289,7 @@ function getTypeScriptFiles(
!file.endsWith('.d.ts') &&
(includeIndex ? true : !file.endsWith('index.ts')),
);
}
};
const getKind = (
node: ts.VariableStatement,
@ -305,10 +307,10 @@ const getKind = (
return 'var';
};
function extractExportsFromSourceFile(sourceFile: ts.SourceFile) {
const extractExportsFromSourceFile = (sourceFile: ts.SourceFile) => {
const exports: DeclarationOccurrence[] = [];
function visit(node: ts.Node) {
const visit = (node: ts.Node) => {
if (!ts.canHaveModifiers(node)) {
return ts.forEachChild(node, visit);
}
@ -409,11 +411,11 @@ function extractExportsFromSourceFile(sourceFile: ts.SourceFile) {
break;
}
return ts.forEachChild(node, visit);
}
};
visit(sourceFile);
return exports;
}
};
type ExportKind =
| 'type'
@ -430,7 +432,7 @@ type FileExports = Array<{
exports: DeclarationOccurrence[];
}>;
function findAllExports(directoryPath: string): FileExports {
const findAllExports = (directoryPath: string): FileExports => {
const results: FileExports = [];
const files = getTypeScriptFiles(directoryPath);
@ -453,7 +455,7 @@ function findAllExports(directoryPath: string): FileExports {
}
return results;
}
};
type ExportByBarrel = {
barrel: {
@ -484,15 +486,39 @@ const retrieveExportsByBarrel = (barrelDirectories: string[]) => {
const main = () => {
const moduleDirectories = getSubDirectoryPaths(SRC_PATH);
const rootDirectory = moduleDirectories.find((dir) =>
ROOT_DIRECTORIES.includes(getLastPathFolder(dir)),
);
const otherBarrelDirectories = moduleDirectories.filter(
(dir) => !ROOT_DIRECTORIES.includes(getLastPathFolder(dir)),
);
const exportsByBarrel = retrieveExportsByBarrel(moduleDirectories);
const moduleIndexFiles = generateModuleIndexFiles(exportsByBarrel);
const packageJsonConfig =
computePackageJsonFilesAndExportsConfig(moduleDirectories);
const nxBuildOutputsPath =
computeProjectNxBuildOutputsPath(moduleDirectories);
const packageJsonConfig = computePackageJsonFilesAndExportsConfig(
otherBarrelDirectories,
);
const nxBuildOutputsPath = computeProjectNxBuildOutputsPath(
otherBarrelDirectories,
);
updateNxProjectConfigurationBuildOutputs(nxBuildOutputsPath);
writeInPackageJson(packageJsonConfig);
moduleIndexFiles.forEach(createTypeScriptFile);
// Ensure top-level src/index.ts re-exports the root directories barrel so consumers can `import * from "twenty-sdk"`
// We intentionally keep this file minimal: it delegates to the generated src/<rootDirectory>/index.ts
if (rootDirectory) {
createTypeScriptFile({
path: SRC_PATH,
filename: INDEX_FILENAME,
content: ROOT_DIRECTORIES.map(
(rootDirectory) => `export * from "./${rootDirectory}";`,
).join('\n'),
});
}
};
main();

View file

@ -1,4 +1,4 @@
import { type SyncableEntityOptions } from '@/application/syncable-entity-options.type';
import { type SyncableEntityOptions } from './syncable-entity-options.type';
type ApplicationVariable = SyncableEntityOptions & {
value?: string;

View file

@ -1,4 +1,4 @@
import { type SyncableEntityOptions } from '@/application/syncable-entity-options.type';
import { type SyncableEntityOptions } from '../syncable-entity-options.type';
import {
type FieldMetadataType,

View file

@ -1,4 +1,4 @@
import { type SyncableEntityOptions } from '@/application/syncable-entity-options.type';
import { type SyncableEntityOptions } from '../syncable-entity-options.type';
import {
type RelationOnDeleteAction,

View file

@ -1,4 +1,4 @@
import { type SyncableEntityOptions } from '@/application/syncable-entity-options.type';
import { type SyncableEntityOptions } from './syncable-entity-options.type';
type RouteTrigger = {
type: 'route';
@ -17,12 +17,12 @@ type DatabaseEventTrigger = {
eventName: string;
};
type ServerlessFunctionTrigger = SyncableEntityOptions &
type FunctionTrigger = SyncableEntityOptions &
(RouteTrigger | CronTrigger | DatabaseEventTrigger);
export type ServerlessFunctionConfig = SyncableEntityOptions & {
export type FunctionConfig = SyncableEntityOptions & {
name?: string;
description?: string;
timeoutSeconds?: number;
triggers?: ServerlessFunctionTrigger[];
triggers?: FunctionTrigger[];
};

View file

@ -23,7 +23,7 @@ export { Field } from './field-metadata/field.decorator';
export { OnDeleteAction } from './field-metadata/on-delete-action';
export { RelationType } from './field-metadata/relation-type';
export { Relation } from './field-metadata/relation.decorator';
export type { FunctionConfig } from './function-config';
export { Object } from './object-metadata/object.decorator';
export { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from './object-metadata/standard-object-ids';
export type { ServerlessFunctionConfig } from './serverless-function-config';
export type { SyncableEntityOptions } from './syncable-entity-options.type';

View file

@ -1,4 +1,4 @@
import { type SyncableEntityOptions } from '@/application/syncable-entity-options.type';
import { type SyncableEntityOptions } from '../syncable-entity-options.type';
type ObjectMetadataOptions = SyncableEntityOptions & {
nameSingular: string;

View file

@ -1,4 +1,4 @@
import { TwentyConfig } from '../../../types/config.types';
import { type TwentyConfig } from '../../../types/config.types';
export const testConfig: TwentyConfig = {
apiUrl: 'http://localhost:3000',

View file

@ -1,7 +1,7 @@
import { exec } from 'child_process';
export default async function globalTeardown() {
return new Promise<void>((resolve) => {
export default async () =>
new Promise<void>((resolve) => {
exec('pkill -f "nest start" || true', (error: unknown) => {
if (error) {
console.log('No server processes to kill');
@ -11,4 +11,3 @@ export default async function globalTeardown() {
resolve();
});
});
}

View file

@ -1,11 +1,9 @@
import path from 'path';
export const getTestedApplicationPath = (relativePath: string): string => {
const currentFileDir = __dirname;
const twentyAppsPath = path.resolve(
currentFileDir,
'../../../../../twenty-apps',
__dirname,
'../../../../../../twenty-apps',
);
return path.join(twentyAppsPath, relativePath);

View file

@ -8,7 +8,7 @@ import { AuthCommand } from './commands/auth.command';
import { ConfigService } from './services/config.service';
const packageJson = JSON.parse(
readFileSync(join(__dirname, '../package.json'), 'utf-8'),
readFileSync(join(__dirname, '../../package.json'), 'utf-8'),
);
const program = new Command();

View file

@ -5,13 +5,13 @@ import { join } from 'path';
import camelcase from 'lodash.camelcase';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
import { getObjectDecoratedClass } from '../utils/get-object-decorated-class';
import { getServerlessFunctionBaseFile } from '../utils/get-serverless-function-base-file';
import { getFunctionBaseFile } from '../utils/get-function-base-file';
import { convertToLabel } from '../utils/convert-to-label';
export enum SyncableEntity {
AGENT = 'agent',
OBJECT = 'object',
SERVERLESS_FUNCTION = 'serverlessFunction',
FUNCTION = 'function',
}
export const isSyncableEntity = (value: string): value is SyncableEntity => {
@ -44,12 +44,12 @@ export class AppAddCommand {
return;
}
if (entity === SyncableEntity.SERVERLESS_FUNCTION) {
if (entity === SyncableEntity.FUNCTION) {
const entityName = await this.getEntityName(entity);
const objectFileName = `${camelcase(entityName)}.ts`;
const decoratedServerlessFunction = getServerlessFunctionBaseFile({
const decoratedServerlessFunction = getFunctionBaseFile({
name: entityName,
});
@ -76,7 +76,7 @@ export class AppAddCommand {
name: 'entity',
message: `What entity do you want to create?`,
default: '',
choices: [SyncableEntity.SERVERLESS_FUNCTION, SyncableEntity.OBJECT],
choices: [SyncableEntity.FUNCTION, SyncableEntity.OBJECT],
},
]);

View file

@ -2,7 +2,7 @@ import chalk from 'chalk';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
import { ApiService } from '../services/api.service';
import { GenerateService } from '../services/generate.service';
import { ApiResponse } from '../types/config.types';
import { type ApiResponse } from '../types/config.types';
import { loadManifest } from '../utils/load-manifest';
export class AppSyncCommand {
@ -49,7 +49,7 @@ export class AppSyncCommand {
});
}
if (!serverlessSyncResult.success) {
if (serverlessSyncResult.success === false) {
console.error(
chalk.red('❌ Serverless functions Sync failed:'),
serverlessSyncResult.error,

View file

@ -2,7 +2,7 @@ import chalk from 'chalk';
import inquirer from 'inquirer';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
import { ApiService } from '../services/api.service';
import { ApiResponse } from '../types/config.types';
import { type ApiResponse } from '../types/config.types';
import { loadManifest } from '../utils/load-manifest';
export class AppUninstallCommand {
@ -31,7 +31,7 @@ export class AppUninstallCommand {
manifest.application.universalIdentifier,
);
if (!result.success) {
if (result.success === false) {
console.error(chalk.red('❌ Uninstall failed:'), result.error);
} else {
console.log(chalk.green('✅ Application uninstalled successfully'));

View file

@ -7,7 +7,6 @@ import {
} from './app-add.command';
import { AppUninstallCommand } from './app-uninstall.command';
import { AppDevCommand } from './app-dev.command';
import { AppInitCommand } from './app-init.command';
import { AppSyncCommand } from './app-sync.command';
import { formatPath } from '../utils/format-path';
import { AppGenerateCommand } from './app-generate.command';
@ -16,7 +15,6 @@ export class AppCommand {
private devCommand = new AppDevCommand();
private syncCommand = new AppSyncCommand();
private uninstallCommand = new AppUninstallCommand();
private initCommand = new AppInitCommand();
private addCommand = new AppAddCommand();
private generateCommand = new AppGenerateCommand();
@ -84,21 +82,6 @@ export class AppCommand {
}
});
appCommand
.command('init [directory]')
.description('Initialize a new Twenty application')
.action(async (directory?: string) => {
if (directory && !/^[a-z0-9-]+$/.test(directory)) {
console.error(
chalk.red(
`Invalid directory "${directory}". Must contain only lowercase letters, numbers, and hyphens`,
),
);
process.exit(1);
}
await this.initCommand.execute(directory);
});
appCommand
.command('add [entityType]')
.option('--path <path>', 'Path in which the entity should be created.')

View file

@ -1,7 +1,7 @@
import * as fs from 'fs-extra';
import * as os from 'os';
import * as path from 'path';
import { TwentyConfig } from '../types/config.types';
import { type TwentyConfig } from '../types/config.types';
type PersistedConfig = TwentyConfig & {
profiles?: Record<string, TwentyConfig>;
@ -69,7 +69,7 @@ export class ConfigService {
raw.profiles = {};
}
const currentProfile = raw.profiles[profile] || {};
const currentProfile = raw.profiles[profile] || { apiUrl: '' };
raw.profiles[profile] = { ...currentProfile, ...config };

View file

@ -0,0 +1,10 @@
import { convertToLabel } from '../convert-to-label';
describe('convertToLabel', () => {
it('should convert to label', () => {
expect(convertToLabel('toto')).toBe('Toto');
expect(convertToLabel('totoTata')).toBe('Toto tata');
expect(convertToLabel('totoTataTiti')).toBe('Toto tata titi');
expect(convertToLabel('toto-tata-titi')).toBe('Toto tata titi');
});
});

View file

@ -1,14 +1,13 @@
import { getServerlessFunctionBaseFile } from '../get-serverless-function-base-file';
import { getFunctionBaseFile } from '../get-function-base-file';
describe('getServerlessFunctionBaseFile', () => {
describe('getFunctionBaseFile', () => {
it('should render proper file', () => {
expect(
getServerlessFunctionBaseFile({
getFunctionBaseFile({
name: 'serverless-function-name',
universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4',
}),
)
.toBe(`import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
).toBe(`import { type FunctionConfig } from 'twenty-sdk';
export const main = async (params: {
a: string;
@ -23,7 +22,7 @@ export const main = async (params: {
return { message };
};
export const config: ServerlessFunctionConfig = {
export const config: FunctionConfig = {
universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4',
name: 'serverless-function-name',
timeoutSeconds: 5,

View file

@ -14,7 +14,7 @@ describe('getObjectDecoratedClass', () => {
name: 'MyNewObject',
}),
).toBe(
`import { Object } from 'twenty-sdk/application';
`import { Object } from 'twenty-sdk';
@Object({
universalIdentifier: '4122a047-260f-4cf1-bf4f-a268579d7ddf',

View file

@ -1,8 +1,8 @@
import { ensureDirSync, removeSync, writeFileSync } from 'fs-extra';
import { ensureDirSync, writeFileSync, removeSync } from 'fs-extra';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { copyBaseApplicationProject } from '../app-template';
import { loadManifest } from '../load-manifest';
import { v4 } from 'uuid';
const write = (root: string, file: string, content: string) => {
const abs = join(root, file);
@ -22,7 +22,7 @@ const tsLibMock = `declare module 'tslib' {
}`;
const twentySdkTypesMock = `
declare module 'twenty-sdk/application' {
declare module 'twenty-sdk' {
export type SyncableEntityOptions = { universalIdentifier: string };
type ApplicationVariable = SyncableEntityOptions & {
@ -58,7 +58,7 @@ declare module 'twenty-sdk/application' {
type ServerlessFunctionTrigger = SyncableEntityOptions &
(RouteTrigger | CronTrigger | DatabaseEventTrigger);
export type ServerlessFunctionConfig = SyncableEntityOptions & {
export type FunctionConfig = SyncableEntityOptions & {
name?: string;
description?: string;
timeoutSeconds?: number;
@ -93,13 +93,13 @@ declare module 'twenty-sdk/application' {
`;
const serverlessFunctionMock = `
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
import { type FunctionConfig } from 'twenty-sdk';
export const main = async (params: any): Promise<any> => {
return {};
}
export const config: ServerlessFunctionConfig = {
export const config: FunctionConfig = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'hello',
timeoutSeconds: 2,
@ -129,7 +129,7 @@ const objectMock = `import {
BaseObjectMetadata,
FieldMetadata,
FieldMetadataType
} from 'twenty-sdk/application';
} from 'twenty-sdk';
enum PostCardStatus {
DRAFT = 'DRAFT',
@ -215,19 +215,97 @@ export class PostCard extends BaseObjectMetadata {
}
`;
const packageJsonMock = {
name: 'my-app',
version: '0.0.1',
license: 'MIT',
engines: {
node: '^24.5.0',
npm: 'please-use-yarn',
yarn: '>=4.0.2',
},
packageManager: 'yarn@4.9.2',
scripts: {
'create-entity': 'twenty app add',
dev: 'twenty app dev',
generate: 'twenty app generate',
sync: 'twenty app sync',
uninstall: 'twenty app uninstall',
auth: 'twenty auth login',
},
dependencies: {
'twenty-sdk': '0.1.0',
},
devDependencies: {
'@types/node': '^24.7.2',
typescript: '^5.9.3',
},
};
const tsConfigJsonMock = {
compileOnSave: false,
compilerOptions: {
sourceMap: true,
declaration: true,
outDir: './dist',
rootDir: '.',
moduleResolution: 'node',
allowSyntheticDefaultImports: true,
emitDecoratorMetadata: true,
experimentalDecorators: true,
importHelpers: true,
allowUnreachableCode: false,
strictNullChecks: true,
alwaysStrict: true,
noImplicitAny: true,
strictBindCallApply: false,
target: 'es2018',
module: 'esnext',
lib: ['es2020', 'dom'],
skipLibCheck: true,
skipDefaultLibCheck: true,
resolveJsonModule: true,
},
exclude: ['node_modules', 'dist', '**/*.test.ts', '**/*.spec.ts'],
};
const yarnLockMock = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
`;
const applicationConfigMock = `import { type ApplicationConfig } from 'twenty-sdk';
const config: ApplicationConfig = {
universalIdentifier: '${v4()}',
displayName: 'My App',
description: 'My app description',
};
export default config;
`;
describe('loadManifest (integration)', () => {
const appName = 'my-app';
const appDisplayName = 'My App';
const appDescription = 'My app description';
const appDirectory = join(tmpdir(), 'twenty-manifest-');
const appDirectory = join(tmpdir(), 'test-app');
beforeEach(async () => {
await copyBaseApplicationProject({
appName,
appDisplayName,
appDescription,
await ensureDirSync(appDirectory);
write(appDirectory, 'yarn.lock', yarnLockMock);
write(appDirectory, 'application.config.ts', applicationConfigMock);
write(
appDirectory,
});
'tsconfig.json',
JSON.stringify(tsConfigJsonMock, null, 2),
);
write(
appDirectory,
'package.json',
JSON.stringify(packageJsonMock, null, 2),
);
write(appDirectory, 'src/Account.ts', objectMock);
@ -258,10 +336,11 @@ describe('loadManifest (integration)', () => {
expect(packageJson.name).toBe('my-app');
expect(packageJson.version).toBe('0.0.1');
expect(packageJson.license).toBe('MIT');
expect(yarnLock).toContain('# This file is generated by running ');
expect(yarnLock).toContain(
'# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.',
);
// application
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { universalIdentifier: _, ...otherInfo } = manifest.application;
expect(otherInfo).toEqual({
displayName: 'My App',
@ -271,7 +350,6 @@ describe('loadManifest (integration)', () => {
expect(manifest.objects.length).toBe(1);
for (const object of manifest.objects) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { universalIdentifier: _, fields, ...otherInfo } = object;
expect(otherInfo).toEqual({
description: ' A post card object',
@ -341,9 +419,7 @@ describe('loadManifest (integration)', () => {
// serverless functions
for (const serverlessFunction of manifest.serverlessFunctions) {
const {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
universalIdentifier: _,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
handlerPath: __,
triggers,
...otherInfo
@ -356,7 +432,6 @@ describe('loadManifest (integration)', () => {
});
for (const trigger of triggers) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { universalIdentifier: _, ...otherInfo } = trigger;
switch (trigger.type) {
case 'route':

View file

@ -0,0 +1,6 @@
import { startCase } from 'lodash';
export const convertToLabel = (str: string) => {
const s = startCase(str).toLowerCase();
return s.charAt(0).toUpperCase() + s.slice(1);
};

View file

@ -1,9 +1,13 @@
import ts, { formatDiagnosticsWithColorAndContext, sys } from 'typescript';
import {
type Diagnostic,
formatDiagnosticsWithColorAndContext,
sys,
} from 'typescript';
export const formatAndWarnTsDiagnostics = ({
diagnostics,
}: {
diagnostics: ts.Diagnostic[];
diagnostics: Diagnostic[];
}) => {
if (diagnostics.length > 0) {
const formattedDiagnostics = formatDiagnosticsWithColorAndContext(

View file

@ -1,7 +1,7 @@
import kebabCase from 'lodash.kebabcase';
import { v4 } from 'uuid';
export const getServerlessFunctionBaseFile = ({
export const getFunctionBaseFile = ({
name,
universalIdentifier = v4(),
}: {
@ -10,7 +10,7 @@ export const getServerlessFunctionBaseFile = ({
}) => {
const kebabCaseName = kebabCase(name);
return `import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
return `import { type FunctionConfig } from 'twenty-sdk';
export const main = async (params: {
a: string;
@ -25,7 +25,7 @@ export const main = async (params: {
return { message };
};
export const config: ServerlessFunctionConfig = {
export const config: FunctionConfig = {
universalIdentifier: '${universalIdentifier}',
name: '${kebabCaseName}',
timeoutSeconds: 5,

View file

@ -15,7 +15,7 @@ export const getObjectDecoratedClass = ({
const className = camelCaseName[0].toUpperCase() + camelCaseName.slice(1);
return `import { Object } from 'twenty-sdk/application';
return `import { Object } from 'twenty-sdk';
@Object({
${decoratorOptions}

View file

@ -1,4 +1,3 @@
import ts from 'typescript';
import { join } from 'path';
import {
createProgram,
@ -6,6 +5,8 @@ import {
parseJsonConfigFileContent,
readConfigFile,
sys,
type Program,
type Diagnostic,
} from 'typescript';
const getProgramFromTsconfig = ({
@ -41,7 +42,7 @@ export const getTsProgramAndDiagnostics = async ({
appPath,
}: {
appPath: string;
}): Promise<{ program: ts.Program; diagnostics: ts.Diagnostic[] }> => {
}): Promise<{ program: Program; diagnostics: Diagnostic[] }> => {
const program = getProgramFromTsconfig({
appPath,
tsconfigPath: 'tsconfig.json',

View file

@ -1,5 +1,5 @@
import * as fs from 'fs-extra';
import { ParseError, parse as parseJsonc } from 'jsonc-parser';
import { type ParseError, parse as parseJsonc } from 'jsonc-parser';
export interface JsoncParseOptions {
allowTrailingComma?: boolean;

View file

@ -1,15 +1,15 @@
import * as fs from 'fs-extra';
import { posix, relative, sep } from 'path';
import {
Decorator,
Expression,
FunctionDeclaration,
Modifier,
Node,
Program,
SourceFile,
type Decorator,
type Expression,
type FunctionDeclaration,
type Modifier,
type Node,
type Program,
type SourceFile,
SyntaxKind,
VariableDeclaration,
type VariableDeclaration,
forEachChild,
getDecorators,
isArrayLiteralExpression,
@ -34,13 +34,13 @@ import {
} from 'typescript';
import { GENERATED_FOLDER_NAME } from '../services/generate.service';
import {
AppManifest,
Application,
FieldMetadata,
ObjectManifest,
PackageJson,
ServerlessFunctionManifest,
Sources,
type AppManifest,
type Application,
type FieldMetadata,
type ObjectManifest,
type PackageJson,
type ServerlessFunctionManifest,
type Sources,
} from '../types/config.types';
import { findPathFile } from '../utils/find-path-file';
import { getTsProgramAndDiagnostics } from '../utils/get-ts-program-and-diagnostics';
@ -210,7 +210,7 @@ const hasExportModifier = (st: any) =>
/**
* Finds (and validates) the new serverless file shape:
* - exactly 2 exported bindings
* - one must be `config` (typed ServerlessFunctionConfig)
* - one must be `config` (typed FunctionConfig)
* - the other must be a function (exported function declaration, or const initialized with arrow/function expression)
*/
const findHandlerAndConfig = (
@ -290,13 +290,13 @@ const findHandlerAndConfig = (
`"config" in ${sf.fileName} must be initialized to an object literal.`,
);
}
// (Light) type guard: ensure declared type mentions ServerlessFunctionConfig if present
// (Light) type guard: ensure declared type mentions FunctionConfig if present
const maybeVarDecl = configExport.declNode as VariableDeclaration;
if ('type' in maybeVarDecl && maybeVarDecl.type) {
const typeText = maybeVarDecl.type.getText(sf);
if (!/\bServerlessFunctionConfig\b/.test(typeText)) {
if (!/\bFunctionConfig\b/.test(typeText)) {
throw new Error(
`"config" in ${sf.fileName} must be typed as ServerlessFunctionConfig (got: ${typeText}).`,
`"config" in ${sf.fileName} must be typed as FunctionConfig (got: ${typeText}).`,
);
}
}

View file

@ -1 +1,10 @@
export default {};
/*
* _____ _
*|_ _|_ _____ _ __ | |_ _ _
* | | \ \ /\ / / _ \ '_ \| __| | | | Auto-generated file
* | | \ V V / __/ | | | |_| |_| | Any edits to this will be overridden
* |_| \_/\_/ \___|_| |_|\__|\__, |
* |___/
*/
export * from './application';

View file

@ -0,0 +1,26 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist/cli",
"rootDir": "./src/cli",
"moduleResolution": "bundler",
"strict": true,
"noEmit": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"declaration": false,
"declarationMap": false,
"isolatedModules": false
},
"include": ["src/cli/**/*.ts"],
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts",
"**/*.e2e-spec.ts",
"**/__tests__/**"
]
}

View file

@ -9,12 +9,9 @@
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"declarationMap": false,
"sourceMap": true,
"types": ["jest", "node"]
},
"include": [

View file

@ -1,28 +1,36 @@
{
"compileOnSave": false,
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "src",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"allowUnreachableCode": false,
"strictNullChecks": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"isolatedModules": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
"paths": {
"@/*": ["./src/*"]
}
"declaration": true,
"declarationMap": true,
"rootDir": "src"
},
"include": ["src/**/*.ts"]
"files": [],
"references": [
{
"path": "./tsconfig.cli.json"
},
{
"path": "./tsconfig.e2e.json"
}
],
"include": ["src/**/*.ts", "vite.config.ts"],
"exclude": [
"src/cli/**",
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts",
"**/*.e2e-spec.ts",
"**/__tests__/**"
]
}

View file

@ -42,7 +42,11 @@ export default defineConfig(() => {
tsconfigPaths({
root: __dirname,
}),
dts({ entryRoot: './src', tsconfigPath: tsConfigPath }),
dts({
entryRoot: './src',
tsconfigPath: tsConfigPath,
exclude: ['vite.config.ts'],
}),
],
build: {
outDir: 'dist',

View file

@ -26753,7 +26753,7 @@ __metadata:
languageName: node
linkType: hard
"ajv@npm:8.17.1, ajv@npm:^8.0.0, ajv@npm:^8.12.0, ajv@npm:^8.17.1, ajv@npm:^8.9.0":
"ajv@npm:8.17.1, ajv@npm:^8.0.0, ajv@npm:^8.17.1, ajv@npm:^8.9.0":
version: 8.17.1
resolution: "ajv@npm:8.17.1"
dependencies:
@ -30919,6 +30919,30 @@ __metadata:
languageName: node
linkType: hard
"create-twenty-app@workspace:packages/create-twenty-app":
version: 0.0.0-use.local
resolution: "create-twenty-app@workspace:packages/create-twenty-app"
dependencies:
"@genql/cli": "npm:^3.0.3"
"@types/fs-extra": "npm:^11.0.0"
"@types/inquirer": "npm:^9.0.0"
"@types/lodash.camelcase": "npm:^4.3.7"
"@types/lodash.kebabcase": "npm:^4.1.7"
"@types/lodash.startcase": "npm:^4"
"@types/node": "npm:^20.0.0"
chalk: "npm:^5.3.0"
commander: "npm:^12.0.0"
fs-extra: "npm:^11.2.0"
inquirer: "npm:^10.0.0"
lodash.camelcase: "npm:^4.3.0"
lodash.kebabcase: "npm:^4.1.1"
lodash.startcase: "npm:^4.4.0"
uuid: "npm:^13.0.0"
bin:
create-twenty-app: dist/cli.js
languageName: unknown
linkType: soft
"crelt@npm:^1.0.0, crelt@npm:^1.0.5":
version: 1.0.6
resolution: "crelt@npm:1.0.6"
@ -55614,38 +55638,8 @@ __metadata:
"twenty-cli@workspace:packages/twenty-cli":
version: 0.0.0-use.local
resolution: "twenty-cli@workspace:packages/twenty-cli"
dependencies:
"@genql/cli": "npm:^3.0.3"
"@types/fs-extra": "npm:^11.0.0"
"@types/inquirer": "npm:^9.0.0"
"@types/jest": "npm:^29.5.0"
"@types/lodash.camelcase": "npm:^4.3.7"
"@types/lodash.capitalize": "npm:^4"
"@types/lodash.kebabcase": "npm:^4.1.7"
"@types/lodash.startcase": "npm:^4"
"@types/node": "npm:^20.0.0"
ajv: "npm:^8.12.0"
ajv-formats: "npm:^2.1.1"
axios: "npm:^1.6.0"
chalk: "npm:^5.3.0"
chokidar: "npm:^4.0.0"
commander: "npm:^12.0.0"
dotenv: "npm:^16.4.0"
fs-extra: "npm:^11.2.0"
graphql: "npm:^16.8.1"
inquirer: "npm:^10.0.0"
jest: "npm:^29.5.0"
jsonc-parser: "npm:^3.2.0"
lodash.camelcase: "npm:^4.3.0"
lodash.capitalize: "npm:^4.2.1"
lodash.kebabcase: "npm:^4.1.1"
lodash.startcase: "npm:^4.4.0"
tsx: "npm:^4.7.0"
typescript: "npm:^5.9.2"
uuid: "npm:^13.0.0"
wait-on: "npm:^7.2.0"
bin:
twenty: dist/cli.js
twenty: ./deprecate.js
languageName: unknown
linkType: soft
@ -55814,11 +55808,38 @@ __metadata:
version: 0.0.0-use.local
resolution: "twenty-sdk@workspace:packages/twenty-sdk"
dependencies:
"@genql/cli": "npm:^3.0.3"
"@types/fs-extra": "npm:^11.0.0"
"@types/inquirer": "npm:^9.0.0"
"@types/jest": "npm:^29.5.0"
"@types/lodash.camelcase": "npm:^4.3.7"
"@types/lodash.capitalize": "npm:^4"
"@types/lodash.kebabcase": "npm:^4.1.7"
"@types/lodash.startcase": "npm:^4"
"@types/node": "npm:^24.0.0"
typescript: "npm:5.9.2"
axios: "npm:^1.6.0"
chalk: "npm:^5.3.0"
chokidar: "npm:^4.0.0"
commander: "npm:^12.0.0"
dotenv: "npm:^16.4.0"
fs-extra: "npm:^11.2.0"
graphql: "npm:^16.8.1"
inquirer: "npm:^10.0.0"
jest: "npm:^29.5.0"
jsonc-parser: "npm:^3.2.0"
lodash.camelcase: "npm:^4.3.0"
lodash.capitalize: "npm:^4.2.1"
lodash.kebabcase: "npm:^4.1.1"
lodash.startcase: "npm:^4.4.0"
tsx: "npm:^4.7.0"
typescript: "npm:^5.9.2"
uuid: "npm:^13.0.0"
vite: "npm:^7.0.0"
vite-plugin-dts: "npm:3.8.1"
vite-tsconfig-paths: "npm:^4.2.1"
wait-on: "npm:^7.2.0"
bin:
twenty: dist/cli/cli.js
languageName: unknown
linkType: soft