snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": "off",
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": [
"error",
{
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}
],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": [
"error",
{
"allowInterfaces": "with-single-extends"
}
],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": [
"warn",
{
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}
]
}
}

View File

@@ -0,0 +1 @@
dist

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023-present Twenty.com, PBC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,58 @@
<div align="center">
<a href="https://twenty.com">
<picture>
<img alt="Twenty logo" src="https://raw.githubusercontent.com/twentyhq/twenty/main/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>
The official scaffolding CLI for building apps on top of [Twenty CRM](https://twenty.com). Sets up a ready-to-run project with [twenty-sdk](https://www.npmjs.com/package/twenty-sdk).
## Quick start
```bash
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
yarn twenty dev
```
The scaffolder will:
1. Create a new project with TypeScript, linting, tests, and a preconfigured `twenty` CLI
2. Start a local Twenty server via Docker (pulls the latest image automatically)
3. Authenticate with the development API key
## Options
| Flag | Description |
| ---------------------------------- | --------------------------------------------------------------------- |
| `--name <name>` | Set the app name |
| `--display-name <displayName>` | Set the display name |
| `--description <description>` | Set the description |
| `--url <url>` | Twenty workspace URL (default: `http://localhost:2020`) |
| `--authentication-method <method>` | `oauth` or `apiKey` (default: `apiKey` for local, `oauth` for remote) |
## Documentation
Full documentation is available at **[docs.twenty.com/developers/extend/apps](https://docs.twenty.com/developers/extend/apps/getting-started/quick-start)**:
- [Quick Start](https://docs.twenty.com/developers/extend/apps/getting-started/quick-start) — scaffold, run a local server, sync your code
- [Concepts](https://docs.twenty.com/developers/extend/apps/getting-started/concepts) — how apps work: entity model, sandboxing, lifecycle
- [Operations](https://docs.twenty.com/developers/extend/apps/operations/overview) — CLI, testing, CI, deploy and publish
## Troubleshooting
- Server not starting: check Docker is running (`docker info`), then try `yarn twenty docker:logs`.
- Auth not working: run `yarn twenty remote:add` to re-authenticate.
- Types not generated: ensure `yarn twenty dev` is running — it auto-generates the typed client.
## Contributing
- See our [GitHub](https://github.com/twentyhq/twenty)
- Join our [Discord](https://discord.gg/cx5n4Jzs57)

View File

@@ -0,0 +1,47 @@
const jestConfig = {
displayName: 'create-twenty-app',
preset: '../../jest.preset.js',
testEnvironment: 'node',
transformIgnorePatterns: ['../../node_modules/'],
transform: {
'^.+\\.[tj]sx?$': [
'@swc/jest',
{
jsc: {
parser: { syntax: 'typescript', tsx: false },
},
},
],
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^package.json$': '<rootDir>/package.json',
},
moduleFileExtensions: ['ts', 'js'],
extensionsToTreatAsEsm: ['.ts'],
coverageDirectory: './coverage',
testMatch: [
'<rootDir>/src/**/__tests__/**/*.(test|spec).{js,ts}',
'<rootDir>/src/**/?(*.)(test|spec).{js,ts}',
],
// The scaffold template ships its own tests that run under the generated
// project's vitest, not this package's jest, so ignore the template directory.
testPathIgnorePatterns: [
'/node_modules/',
'<rootDir>/src/constants/template/',
],
collectCoverageFrom: [
'src/**/*.{ts,js}',
'!src/**/*.d.ts',
'!src/cli.ts', // Exclude CLI entry point from coverage
],
coverageThreshold: {
global: {
statements: 1,
lines: 1,
functions: 1,
},
},
};
export default jestConfig;

View File

@@ -0,0 +1,62 @@
{
"name": "create-twenty-app",
"version": "2.42.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
"files": [
"dist",
"README.md",
"package.json"
],
"scripts": {
"build": "npx rimraf dist && npx vite build"
},
"keywords": [
"twenty",
"cli",
"crm",
"application",
"development"
],
"exports": {
".": {
"types": "./dist/cli.d.ts",
"import": "./dist/cli.mjs",
"require": "./dist/cli.cjs"
}
},
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",
"commander": "^12.0.0",
"fs-extra": "^11.2.0",
"lodash.camelcase": "^4.3.0",
"lodash.kebabcase": "^4.1.1",
"lodash.startcase": "^4.4.0",
"twenty-sdk": "workspace:*",
"uuid": "^13.0.2"
},
"devDependencies": {
"@swc/core": "^1.15.11",
"@swc/jest": "^0.2.39",
"@types/fs-extra": "^11.0.0",
"@types/jest": "^30.0.0",
"@types/lodash.camelcase": "^4.3.7",
"@types/lodash.kebabcase": "^4.1.7",
"@types/lodash.startcase": "^4",
"@types/node": "^20.0.0",
"jest": "29.7.0",
"jest-environment-node": "^29.4.1",
"twenty-shared": "workspace:*",
"twenty-ui": "workspace:*",
"typescript": "^5.9.3",
"vite": "^8.0.0",
"vite-plugin-dts": "^4.5.4",
"yaml": "^2.9.0"
},
"engines": {
"node": "^24.5.0",
"yarn": "^4.0.2"
}
}

View File

@@ -0,0 +1,53 @@
{
"name": "create-twenty-app",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"tags": ["scope:create-app"],
"targets": {
"build": {
"dependsOn": ["^build"],
"outputs": ["{projectRoot}/dist"]
},
"dev": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"cwd": "packages/create-twenty-app",
"command": "tsx src/cli.ts"
}
},
"start": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"cwd": "packages/create-twenty-app",
"command": "node dist/cli.cjs"
}
},
"set-local-version": {},
"generate-template-lock": {
"executor": "nx:run-commands",
"cache": false,
"options": {
"cwd": "packages/create-twenty-app",
"command": "tsx src/scripts/generate-template-lock.ts --version={args.releaseVersion} --registry={args.registry}"
}
},
"typecheck": {},
"lint": {},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "{projectRoot}/jest.config.mjs"
},
"configurations": {
"ci": {
"ci": true,
"coverage": true,
"watchAll": false
}
}
}
}
}

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env node
import chalk from 'chalk';
import { Command, CommanderError } from 'commander';
import {
type AuthenticationMethod,
CreateAppCommand,
} from '@/create-app.command';
import packageJson from '../package.json';
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]')
.option('-n, --name <name>', 'Application name')
.option('-d, --display-name <displayName>', 'Application display name')
.option('--description <description>', 'Application description')
.option('--url <url>', 'Twenty server URL (default: http://localhost:2020)')
.option('--api-url <apiUrl>', '[deprecated: use --url]')
.option(
'--authentication-method <method>',
'Authentication method: oauth or apiKey (default: apiKey for local, oauth for remote)',
)
.helpOption('-h, --help', 'Display this help message.')
.action(
async (
directory?: string,
options?: {
name?: string;
displayName?: string;
description?: string;
url?: string;
apiUrl?: string;
authenticationMethod?: AuthenticationMethod;
},
) => {
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);
}
if (options?.name !== undefined && options.name.trim().length === 0) {
console.error(chalk.red('Error: --name cannot be empty.'));
process.exit(1);
}
if (
options?.authenticationMethod &&
!['oauth', 'apiKey'].includes(options.authenticationMethod)
) {
console.error(
chalk.red(
'Error: --authentication-method must be "oauth" or "apiKey".',
),
);
process.exit(1);
}
if (options?.apiUrl) {
console.warn(
chalk.yellow('Warning: --api-url is deprecated. Use --url instead.'),
);
}
const serverUrl = (options?.url ?? options?.apiUrl)?.replace(/\/+$/, '');
await new CreateAppCommand().execute({
directory,
name: options?.name,
displayName: options?.displayName,
description: options?.description,
serverUrl,
authenticationMethod: options?.authenticationMethod,
});
},
);
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

@@ -0,0 +1,9 @@
// twenty-client-sdk, twenty-sdk and twenty-ui are released together at a single
// version, so the scaffolder pins all of them to its own version. Keep this list
// as the one source of truth: the template lockfile generator resolves exactly
// these names, and a mismatch between the two silently ships an unusable lockfile.
export const TEMPLATE_FIRST_PARTY_PACKAGES = [
'twenty-client-sdk',
'twenty-sdk',
'twenty-ui',
] as const;

View File

@@ -0,0 +1 @@
24.5.0

View File

@@ -0,0 +1,37 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
},
"overrides": [
{
"files": ["**/*.logic-function.ts", "**/logic-functions/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["twenty-shared", "twenty-shared/*"],
"message": "Logic functions must not import from twenty-shared directly. Import runtime types and helpers from `twenty-sdk/logic-function` instead so the logic-function bundle stays minimal."
}
]
}
]
}
}
]
}

View File

@@ -0,0 +1,70 @@
## Base documentation
- Getting started:
- https://docs.twenty.com/developers/extend/apps/getting-started/quick-start.md
- https://docs.twenty.com/developers/extend/apps/getting-started/concepts.md
- https://docs.twenty.com/developers/extend/apps/getting-started/project-structure.md
- https://docs.twenty.com/developers/extend/apps/getting-started/local-server.md
- https://docs.twenty.com/developers/extend/apps/getting-started/scaffolding.md
- https://docs.twenty.com/developers/extend/apps/getting-started/troubleshooting.md
- Config:
- https://docs.twenty.com/developers/extend/apps/config/overview.md
- https://docs.twenty.com/developers/extend/apps/config/application.md
- https://docs.twenty.com/developers/extend/apps/config/roles.md
- https://docs.twenty.com/developers/extend/apps/config/install-hooks.md
- https://docs.twenty.com/developers/extend/apps/config/public-assets.md
- Data:
- https://docs.twenty.com/developers/extend/apps/data/overview.md
- https://docs.twenty.com/developers/extend/apps/data/objects.md
- https://docs.twenty.com/developers/extend/apps/data/extending-objects.md
- https://docs.twenty.com/developers/extend/apps/data/relations.md
- Logic:
- https://docs.twenty.com/developers/extend/apps/logic/overview.md
- https://docs.twenty.com/developers/extend/apps/logic/logic-functions.md
- https://docs.twenty.com/developers/extend/apps/logic/skills-and-agents.md
- https://docs.twenty.com/developers/extend/apps/logic/connections.md
- Layout:
- https://docs.twenty.com/developers/extend/apps/layout/overview.md
- https://docs.twenty.com/developers/extend/apps/layout/views.md
- https://docs.twenty.com/developers/extend/apps/layout/navigation-menu-items.md
- https://docs.twenty.com/developers/extend/apps/layout/page-layouts.md
- https://docs.twenty.com/developers/extend/apps/layout/front-components.md
- https://docs.twenty.com/developers/extend/apps/layout/command-menu-items.md
- Operations:
- https://docs.twenty.com/developers/extend/apps/operations/overview.md
- https://docs.twenty.com/developers/extend/apps/operations/cli.md
- https://docs.twenty.com/developers/extend/apps/operations/testing.md
- https://docs.twenty.com/developers/extend/apps/operations/publishing.md
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
## Best practice
It's highly recommended to create new app entities using `yarn twenty dev:add`. These are the options:
| Entity type | Command | Generated file |
| -------------------- | ---------------------------------------- | ------------------------------------- |
| Object | `yarn twenty dev:add object` | `src/objects/<name>.ts` |
| Field | `yarn twenty dev:add field` | `src/fields/<name>.ts` |
| Logic function | `yarn twenty dev:add logicFunction` | `src/logic-functions/<name>.ts` |
| Front component | `yarn twenty dev:add frontComponent` | `src/front-components/<name>.tsx` |
| Role | `yarn twenty dev:add role` | `src/roles/<name>.ts` |
| Skill | `yarn twenty dev:add skill` | `src/skills/<name>.ts` |
| Agent | `yarn twenty dev:add agent` | `src/agents/<name>.ts` |
| View | `yarn twenty dev:add view` | `src/views/<name>.ts` |
| Navigation menu item | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
| Page layout | `yarn twenty dev:add pageLayout` | `src/page-layouts/<name>.ts` |
| Page layout tab | `yarn twenty dev:add pageLayoutTab` | `src/page-layout-tabs/<name>.ts` |
| Command menu item | `yarn twenty dev:add commandMenuItem` | `src/command-menu-items/<name>.ts` |
| View field | `yarn twenty dev:add viewField` | `src/view-fields/<name>.ts` |
| Connection provider | `yarn twenty dev:add connectionProvider` | `src/connection-providers/<name>.ts` |
This helps automatically generate required IDs etc.

View File

@@ -0,0 +1,7 @@
# Changelog
All notable changes to this application are documented in this file.
## 0.1.0
- Initial application scaffolded with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app)

View File

@@ -0,0 +1,34 @@
# My Twenty App
Describe your app in one or two sentences.
## Features
List the top things your app does, for example:
- Feature one
- Feature two
- Feature three
## Getting started
Setup instructions live in [SETUP.md](SETUP.md).
## Publishing
The `Publish` workflow (`.github/workflows/publish.yml`) publishes the app to npm with provenance using [npm trusted publishing](https://docs.npmjs.com/trusted-publishers). To publish:
1. On npmjs.com register this repository as a trusted publisher of your package, pointing at the `publish.yml` workflow.
2. Bump the version in `package.json`, then push a version tag (e.g. `git tag v1.0.0 && git push --tags`) or run the workflow manually from the Actions tab.
Publishing with provenance is also how you prove ownership when claiming your app in a Twenty marketplace.
## Changelog
Notable changes are documented in [CHANGELOG.md](CHANGELOG.md).
## Learn more
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/apps/getting-started/quick-start)
- [twenty-sdk CLI reference](https://www.npmjs.com/package/twenty-sdk)
- [Discord](https://discord.gg/cx5n4Jzs57)

View File

@@ -0,0 +1,47 @@
# Setup
Follow these steps to get your app running locally.
## Prerequisites
- Node.js (version specified in `.nvmrc`)
- Yarn 4
- Docker (to run the local Twenty server)
## Steps
1. Install dependencies:
```bash
yarn install
```
The committed `yarn.lock` pins exact, integrity-checked versions, so this
resolves nothing from the registry.
2. Start the local Twenty server:
```bash
yarn twenty docker:start
```
Check the server status at any time with `yarn twenty docker:status`.
3. Start the development server and sync your app:
```bash
yarn twenty dev
```
4. Open [http://localhost:2020](http://localhost:2020) and log in with the default development credentials: `tim@apple.dev` / `tim@apple.dev`.
## Verifying your setup
- `yarn lint` - Lint the project with oxlint
- `yarn typecheck` - Type-check the project
- `yarn test:unit` - Run unit tests
- `yarn test` - Run integration tests
## Troubleshooting
See the [troubleshooting guide](https://docs.twenty.com/developers/extend/apps/getting-started/troubleshooting) or ask on [Discord](https://discord.gg/cx5n4Jzs57).

View File

@@ -0,0 +1,42 @@
name: CD
on:
push:
branches:
- main
pull_request:
types: [labeled]
permissions:
contents: read
env:
TWENTY_DEPLOY_URL: http://localhost:2020
concurrency:
group: cd-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy-and-install:
if: >-
github.event_name == 'push' ||
(github.event_name == 'pull_request' && github.event.label.name == 'deploy')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Deploy
uses: twentyhq/twenty/.github/actions/deploy-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
- name: Install
uses: twentyhq/twenty/.github/actions/install-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}

View File

@@ -0,0 +1,57 @@
name: CI
on:
push:
branches:
- main
pull_request: {}
permissions:
contents: read
env:
TWENTY_VERSION: latest
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Spawn Twenty test instance
id: twenty
uses: twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main
with:
twenty-version: ${{ env.TWENTY_VERSION }}
- name: Enable Corepack
run: corepack enable
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Lint
run: yarn lint
- name: Typecheck
run: yarn typecheck
- name: Unit tests
run: yarn test:unit
- name: Run integration tests
run: yarn test
env:
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }}

View File

@@ -0,0 +1,52 @@
# Publishes the app to npm with provenance (npm trusted publishing).
#
# One-time setup:
# 1. On npmjs.com open your package > Settings > Trusted Publisher and register
# this repository and this workflow file (publish.yml).
# See https://docs.npmjs.com/trusted-publishers
# 2. Push a version tag (e.g. `git tag v1.0.0 && git push --tags`) or run the
# workflow manually from the Actions tab.
#
# Publishing with provenance certifies which GitHub repository the package is
# built from. This is also how you claim ownership of your app in a Twenty
# marketplace: the claim flow verifies you own the GitHub account or
# organization the provenance points to.
name: Publish
on:
push:
tags:
- 'v*'
workflow_dispatch:
permissions:
contents: read
# Required for npm trusted publishing (OIDC provenance).
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
registry-url: https://registry.npmjs.org
- name: Update npm
# Trusted publishing requires npm 11.5.1 or later.
run: npm install -g npm@latest
- name: Install dependencies
run: yarn install --immutable
- name: Publish to npm
# Uncomment if publishing from a private source repo: npm rejects
# OIDC provenance for private repos
# env:
# TWENTY_APP_PUBLISH_DISABLE_PROVENANCE: 'true'
run: yarn twenty app:publish

View File

@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# codegen
generated
# testing
/coverage
# dev
/dist/
.twenty
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# typescript
*.tsbuildinfo
*.d.ts

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1,36 @@
{
"name": "TO-BE-GENERATED",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"keywords": [],
"packageManager": "yarn@4.13.0",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"typecheck": "tsgo --noEmit -p tsconfig.spec.json",
"test": "vitest run",
"test:watch": "vitest",
"test:unit": "vitest run --config vitest.unit.config.ts"
},
"dependencies": {},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^19.0.0",
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
"oxlint": "^0.16.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"twenty-client-sdk": "TO-BE-GENERATED",
"twenty-sdk": "TO-BE-GENERATED",
"twenty-ui": "TO-BE-GENERATED",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^4.0.0"
}
}

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" fill="none">
<rect width="80" height="80" rx="16" fill="#141414"/>
<rect x="20" y="20" width="16" height="16" rx="4" fill="#FAFAFA"/>
<rect x="44" y="20" width="16" height="16" rx="4" fill="#FAFAFA" opacity="0.6"/>
<rect x="20" y="44" width="16" height="16" rx="4" fill="#FAFAFA" opacity="0.6"/>
<rect x="44" y="44" width="16" height="16" rx="4" fill="#FAFAFA" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 454 B

View File

@@ -0,0 +1,14 @@
import {
APP_DESCRIPTION,
APP_DISPLAY_NAME,
APPLICATION_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
import { describe, expect, it } from 'vitest';
describe('application identifiers', () => {
it('should expose the application metadata constants', () => {
expect(APP_DISPLAY_NAME).toBeTruthy();
expect(typeof APP_DESCRIPTION).toBe('string');
expect(APPLICATION_UNIVERSAL_IDENTIFIER).toBeTruthy();
});
});

View File

@@ -0,0 +1,87 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
const APP_PATH = process.cwd();
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
function validateEnv(): { apiUrl: string; apiKey: string } {
const apiUrl = process.env.TWENTY_API_URL;
const apiKey = process.env.TWENTY_API_KEY;
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty docker:start\n' +
'Or set them in vitest env config.',
);
}
return { apiUrl, apiKey };
}
async function checkServer(apiUrl: string) {
let response: Response;
try {
response = await fetch(`${apiUrl}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${apiUrl}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
}
}
function writeConfig(apiUrl: string, apiKey: string) {
const payload = JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey, accessToken: apiKey },
},
defaultRemote: 'local',
},
null,
2,
);
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(path.join(CONFIG_DIR, 'config.test.json'), payload);
}
export async function setup() {
const { apiUrl, apiKey } = validateEnv();
await checkServer(apiUrl);
writeConfig(apiUrl, apiKey);
await appUninstall({ appPath: APP_PATH }).catch(() => {});
const result = await appDevOnce({
appPath: APP_PATH,
onProgress: (message: string) => console.log(`[dev] ${message}`),
});
if (!result.success) {
throw new Error(
`Dev sync failed: ${result.error?.message ?? 'Unknown error'}`,
);
}
}
export async function teardown() {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
}

View File

@@ -0,0 +1,46 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { describe, expect, it } from 'vitest';
describe('App installation', () => {
it('should find the installed app in the applications list', async () => {
const client = new MetadataApiClient();
const result = await client.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const app = result.findManyApplications.find(
(a: { universalIdentifier: string }) =>
a.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(app).toBeDefined();
});
});
describe('CoreApiClient', () => {
it('should support CRUD on standard objects', async () => {
const client = new CoreApiClient();
const created = await client.mutation({
createNote: {
__args: { data: { title: 'Integration test note' } },
id: true,
},
});
expect(created.createNote.id).toBeDefined();
await client.mutation({
destroyNote: {
__args: { id: created.createNote.id },
id: true,
},
});
});
});

View File

@@ -0,0 +1,13 @@
import { defineApplication } from 'twenty-sdk/define';
import {
APP_DESCRIPTION,
APP_DISPLAY_NAME,
APPLICATION_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: APP_DISPLAY_NAME,
description: APP_DESCRIPTION,
});

View File

@@ -0,0 +1,11 @@
export const APP_DISPLAY_NAME = 'DISPLAY-NAME-TO-BE-GENERATED';
export const APP_DESCRIPTION = 'DESCRIPTION-TO-BE-GENERATED';
export const APPLICATION_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_WIDGET_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER =
'UUID-TO-BE-GENERATED';

View File

@@ -0,0 +1,16 @@
import { defineApplicationRole } from 'twenty-sdk/define';
import {
APP_DISPLAY_NAME,
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: `${APP_DISPLAY_NAME} default function role`,
description: `${APP_DISPLAY_NAME} default function role`,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: false,
});

View File

@@ -0,0 +1,287 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { useState } from 'react';
import { Avatar } from 'twenty-ui/primitives/data-display';
import {
IconBox,
IconHierarchy,
IconLayout,
IconSettingsAutomation,
} from 'twenty-ui/icon';
import {
APP_DISPLAY_NAME,
MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
const DOCS_BASE_URL = 'https://docs.twenty.com/developers/extend/apps';
const CATEGORIES = [
{
title: 'Data model',
color: '#73D08D',
items: [
{ label: 'CUSTOM OBJECT', href: `${DOCS_BASE_URL}/data/objects` },
{
label: 'CUSTOM FIELDS',
href: `${DOCS_BASE_URL}/data/extending-objects`,
},
],
rotation: '2.4deg',
},
{
title: 'Logic',
color: '#F4D345',
items: [
{
label: 'TOOLS',
href: `${DOCS_BASE_URL}/logic/logic-functions`,
},
{
label: 'LOGIC FUNCTION',
href: `${DOCS_BASE_URL}/logic/logic-functions`,
},
{
label: 'SKILLS',
href: `${DOCS_BASE_URL}/logic/skills-and-agents`,
},
],
rotation: '0deg',
},
{
title: 'Layout',
color: '#C4A2E0',
items: [
{ label: 'VIEWS', href: `${DOCS_BASE_URL}/layout/views` },
{ label: 'WIDGETS', href: `${DOCS_BASE_URL}/layout/page-layouts` },
{
label: 'LAYOUT PAGES',
href: `${DOCS_BASE_URL}/layout/page-layouts`,
},
{
label: 'COMMANDS',
href: `${DOCS_BASE_URL}/layout/command-menu-items`,
},
],
rotation: '-2.8deg',
},
] as const;
const ArrowUpRight = ({ color = '#999' }: { color?: string }) => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
d="M4.5 3.5H10.5V9.5M10.5 3.5L3.5 10.5"
stroke={color}
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
const CategoryCard = ({
title,
color,
items,
rotation,
}: {
title: string;
color: string;
items: ReadonlyArray<{ label: string; href: string }>;
rotation: string;
}) => {
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
const CategoryIcon = () => {
if (title === 'Data model') {
return <IconHierarchy color={color} size="20px" />;
}
if (title === 'Logic') {
return <IconSettingsAutomation color={color} size="20px" />;
}
if (title === 'Layout') {
return <IconLayout color={color} size="20px" />;
}
};
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
border: `1px solid ${color}80`,
borderRadius: '12px',
overflow: 'hidden',
width: '240px',
background: '#FFFFFF',
transform: `rotate(${rotation})`,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)',
}}
>
<div
style={{
padding: '16px 20px',
background: `${color}22`,
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<CategoryIcon />
<span
style={{
fontSize: '16px',
fontWeight: 600,
color: color,
}}
>
{title}
</span>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
padding: '8px',
gap: '4px',
}}
>
{items.map((item) => {
const isHovered = hoveredItem === item.label;
return (
<a
key={item.label}
href={item.href}
target="_blank"
rel="noopener noreferrer"
onMouseEnter={() => setHoveredItem(item.label)}
onMouseLeave={() => setHoveredItem(null)}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
textDecoration: 'none',
cursor: 'pointer',
padding: '10px 12px',
borderRadius: '8px',
background: isHovered ? '#0000000A' : 'transparent',
transition: 'background 0.15s',
}}
>
<IconBox color={color} size="20px" />
<span
style={{
fontSize: '13px',
fontWeight: 300,
color: '#333',
letterSpacing: '0.5px',
flex: 1,
}}
>
{item.label}
</span>
{isHovered && <ArrowUpRight />}
</a>
);
})}
</div>
</div>
);
};
const MainPage = () => {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
gap: '8px',
padding: '40px',
}}
>
<Avatar name={APP_DISPLAY_NAME} colorSeed={APP_DISPLAY_NAME} size="xl" />
<span
style={{
fontSize: '24px',
fontWeight: 600,
color: '#333',
marginTop: '8px',
}}
>
{APP_DISPLAY_NAME}
</span>
<span
style={{
fontSize: '13px',
color: '#888',
textAlign: 'center',
lineHeight: '1.5',
}}
>
Was installed successfully.
<br />
You can now add content to your app.
</span>
<a
href="/settings/applications#installed"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
marginTop: '16px',
fontSize: '13px',
color: '#333',
textDecoration: 'none',
padding: '8px 16px',
borderRadius: '8px',
border: '1px solid #e0e0e0',
background: '#fafafa',
transition: 'background 0.15s, border-color 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f0f0f0';
e.currentTarget.style.borderColor = '#ccc';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#fafafa';
e.currentTarget.style.borderColor = '#e0e0e0';
}}
>
Open app settings
<ArrowUpRight color="#333" />
</a>
<div
style={{
display: 'flex',
gap: '16px',
marginTop: '32px',
flexWrap: 'wrap',
justifyContent: 'center',
alignItems: 'flex-start',
}}
>
{CATEGORIES.map((category) => (
<CategoryCard
key={category.title}
title={category.title}
color={category.color}
items={category.items}
rotation={category.rotation}
/>
))}
</div>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: APP_DISPLAY_NAME,
description: `${APP_DISPLAY_NAME} front component displaying the app logo and name`,
component: MainPage,
});

View File

@@ -0,0 +1,19 @@
import {
defineNavigationMenuItem,
NavigationMenuItemType,
} from 'twenty-sdk/define';
import {
APP_DISPLAY_NAME,
MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
MAIN_PAGE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: MAIN_PAGE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
name: APP_DISPLAY_NAME,
icon: 'IconFile',
position: -1,
type: NavigationMenuItemType.PAGE_LAYOUT,
pageLayoutUniversalIdentifier: MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
});

View File

@@ -0,0 +1,40 @@
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
import {
APP_DISPLAY_NAME,
MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
MAIN_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
MAIN_PAGE_WIDGET_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default definePageLayout({
universalIdentifier: MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
name: APP_DISPLAY_NAME,
type: 'STANDALONE_PAGE',
tabs: [
{
universalIdentifier: MAIN_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
title: 'Overview',
position: 0,
icon: 'IconApps',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier: MAIN_PAGE_WIDGET_UNIVERSAL_IDENTIFIER,
title: ' ',
type: 'FRONT_COMPONENT',
position: {
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
],
});

View File

@@ -0,0 +1,42 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"jsx": "react-jsx",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"allowUnreachableCode": false,
"strict": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"target": "es2020",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
"paths": {
"src/*": ["./src/*"],
"~/*": ["./*"]
}
},
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts",
"**/*.integration-test.ts"
],
"references": [
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
"types": ["vitest/globals", "node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,31 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
const TWENTY_API_KEY =
process.env.TWENTY_API_KEY ??
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc';
// Make env vars available to globalSetup (test.env only applies to workers)
process.env.TWENTY_API_URL = TWENTY_API_URL;
process.env.TWENTY_API_KEY = TWENTY_API_KEY;
export default defineConfig({
plugins: [
tsconfigPaths({
projects: ['tsconfig.spec.json'],
ignoreConfigErrors: true,
}),
],
test: {
testTimeout: 120_000,
hookTimeout: 120_000,
fileParallelism: false,
include: ['src/**/*.integration-test.ts'],
globalSetup: ['src/__tests__/global-setup.ts'],
env: {
TWENTY_API_URL,
TWENTY_API_KEY,
},
},
});

View File

@@ -0,0 +1,14 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [
tsconfigPaths({
projects: ['tsconfig.spec.json'],
ignoreConfigErrors: true,
}),
],
test: {
include: ['src/**/*.test.ts'],
},
});

View File

@@ -0,0 +1 @@
nodeLinker: node-modules

View File

@@ -0,0 +1,675 @@
import { copyBaseApplicationProject } from '@/utils/app-template';
import { convertToLabel } from '@/utils/convert-to-label';
import { install } from '@/utils/install';
import { tryGitInit } from '@/utils/try-git-init';
import chalk from 'chalk';
import * as fs from 'fs-extra';
import kebabCase from 'lodash.kebabcase';
import * as path from 'path';
import { basename } from 'path';
import { spawn } from 'node:child_process';
import {
authLogin,
authLoginOAuth,
checkDockerRunning,
ConfigService,
DEV_API_KEY,
DEV_API_URL,
serverStart,
} from 'twenty-sdk/cli';
import { isDefined, normalizeUrl } from 'twenty-shared/utils';
import {
getDockerInstallInstructions,
isDockerInstalled,
} from '@/utils/docker-install';
const CURRENT_EXECUTION_DIRECTORY = process.env.INIT_CWD || process.cwd();
const IMAGE = 'twentycrm/twenty-app-dev:latest';
export type AuthenticationMethod = 'oauth' | 'apiKey';
type CreateAppOptions = {
directory?: string;
name?: string;
displayName?: string;
description?: string;
serverUrl?: string;
authenticationMethod?: AuthenticationMethod;
};
export class CreateAppCommand {
private stepCounter = 0;
private totalSteps = 0;
async execute(options: CreateAppOptions = {}): Promise<void> {
const { appName, appDisplayName, appDirectory, appDescription } =
this.getAppInfos(options);
const serverUrl = options.serverUrl ?? DEV_API_URL;
const skipLocalInstance = serverUrl !== DEV_API_URL;
if (!skipLocalInstance && !isDockerInstalled()) {
console.log(chalk.yellow('\n' + getDockerInstallInstructions() + '\n'));
process.exit(1);
}
if (skipLocalInstance && options.authenticationMethod === 'apiKey') {
console.log(
chalk.yellow(
'API key authentication is only supported on a local Docker instance. Ignoring and switching to OAuth authentication.',
),
);
}
const authenticationMethod = skipLocalInstance
? 'oauth'
: (options.authenticationMethod ?? 'apiKey');
try {
await this.validateDirectory(appDirectory);
this.totalSteps = this.computeTotalSteps({
skipLocalInstance,
});
this.stepCounter = 0;
const dockerPullPromise =
!skipLocalInstance && checkDockerRunning()
? this.pullImageInBackground()
: Promise.resolve(false);
this.logPlan({ appName, appDisplayName, appDescription, appDirectory });
this.logNextStep('Creating project directory');
await fs.ensureDir(appDirectory);
this.logDetail(appDirectory);
this.logNextStep('Scaffolding project files');
await copyBaseApplicationProject({
appName,
appDisplayName,
appDescription,
appDirectory,
onProgress: (message) => this.logDetail(message),
});
this.logNextStep('Installing dependencies');
await install(appDirectory, (message) => this.logDetail(message));
this.logNextStep('Initializing Git repository');
const gitInitialized = await tryGitInit(appDirectory);
if (gitInitialized) {
this.logDetail('Initialized on branch main');
this.logDetail('Created initial commit');
} else {
this.logDetail(
'Skipped (Git unavailable, initialization failed, or already in a repository)',
);
}
console.log('');
let authSucceeded = false;
let resolvedServerUrl = serverUrl;
let serverReady = skipLocalInstance;
if (!skipLocalInstance) {
this.logNextStep('Starting Twenty server');
const serverResult = await this.ensureDockerServer(dockerPullPromise);
if (isDefined(serverResult.url)) {
resolvedServerUrl = serverResult.url;
serverReady = true;
}
}
if (serverReady) {
this.logNextStep('Authenticating');
authSucceeded = await this.tryExistingAuth(resolvedServerUrl);
if (authSucceeded) {
this.logDetail('Reusing existing credentials');
} else if (authenticationMethod === 'oauth') {
this.logDetail('Starting OAuth flow');
authSucceeded = await this.authenticateWithOAuth(resolvedServerUrl);
} else {
this.logDetail('Using development API key');
authSucceeded = await this.authenticateWithDevKey(resolvedServerUrl);
}
}
this.logNextStep('Installing application');
let syncSucceeded = false;
if (serverReady && authSucceeded) {
syncSucceeded = await this.syncApplication(appDirectory);
if (!syncSucceeded) {
this.logDetail('Sync failed. Run `yarn twenty dev --once` manually.');
return;
}
} else {
this.logDetail('Skipped (server or authentication not available)');
}
if (syncSucceeded) {
await this.openMainPage(appDirectory, resolvedServerUrl);
}
this.logSuccess(appDirectory, resolvedServerUrl, authSucceeded);
} catch (error) {
console.error(
chalk.red('\nCreate application failed:'),
error instanceof Error ? error.message : error,
);
process.exit(1);
}
}
private computeTotalSteps({
skipLocalInstance,
}: {
skipLocalInstance: boolean;
}): number {
let steps = 4; // directory, scaffold, install, git
if (!skipLocalInstance) {
steps += 1; // start server
}
steps += 1; // authenticate (oauth or apiKey)
steps += 1; // sync application
return steps;
}
private getAppInfos(options: CreateAppOptions): {
appName: string;
appDisplayName: string;
appDescription: string;
appDirectory: string;
} {
const appName = (
options.name ??
options.directory ??
'my-twenty-app'
).trim();
const appDisplayName =
options.displayName?.trim() || convertToLabel(appName);
const appDescription = (options.description ?? '').trim();
const appDirectory = options.directory
? path.join(CURRENT_EXECUTION_DIRECTORY, options.directory)
: path.join(CURRENT_EXECUTION_DIRECTORY, kebabCase(appName));
return { appName, appDisplayName, appDirectory, appDescription };
}
private async validateDirectory(appDirectory: string): Promise<void> {
if (!(await fs.pathExists(appDirectory))) {
return;
}
const files = await fs.readdir(appDirectory);
if (files.length > 0) {
throw new Error(
`Directory ${appDirectory} already exists and is not empty`,
);
}
}
private logPlan({
appName,
appDisplayName,
appDescription,
appDirectory,
}: {
appName: string;
appDisplayName: string;
appDescription: string;
appDirectory: string;
}): void {
console.log(chalk.blue('\nCreating Twenty Application\n'));
console.log(chalk.white(` Name: ${appName}`));
console.log(chalk.white(` Display name: ${appDisplayName}`));
if (appDescription) {
console.log(chalk.white(` Description: ${appDescription}`));
}
console.log(chalk.white(` Directory: ${appDirectory}`));
console.log('');
}
private logNextStep(title: string): void {
this.stepCounter++;
console.log(
chalk.blue(`\n[${this.stepCounter}/${this.totalSteps}]`) +
chalk.white(` ${title}...`),
);
}
private logDetail(message: string): void {
console.log(chalk.gray(` → ${message}`));
}
private pullImageInBackground(): Promise<boolean> {
return new Promise((resolve) => {
const child = spawn('docker', ['pull', IMAGE], { stdio: 'ignore' });
child.on('close', (code) => resolve(code === 0));
child.on('error', () => resolve(false));
});
}
private async ensureDockerServer(
dockerPullPromise: Promise<boolean>,
): Promise<{ url?: string }> {
if (!checkDockerRunning()) {
console.log(
chalk.yellow(
'\n Docker is installed but not running.\n' +
' Please start Docker and run this command again.\n',
),
);
return {};
}
this.logDetail('Ensuring latest Twenty server image...');
const pullSucceeded = await dockerPullPromise;
if (!pullSucceeded) {
this.logDetail(
'Image pull failed, continuing with cached image if available...',
);
}
const startResult = await serverStart({
onProgress: (message: string) => this.logDetail(message),
});
if (startResult.success) {
return { url: startResult.data.url };
}
console.log(chalk.yellow(`\n ${startResult.error.message}`));
return {};
}
private async openMainPage(
appDirectory: string,
serverUrl: string,
): Promise<void> {
try {
const configService = new ConfigService();
const config = await configService.getConfig();
const token = config.twentyCLIAccessToken ?? config.apiKey;
if (!token) {
return;
}
const [universalIdentifier, frontUrl] = await Promise.all([
this.readMainPageLayoutUniversalIdentifier(appDirectory),
this.resolveWorkspaceFrontUrl(serverUrl, token),
]);
if (!universalIdentifier || !frontUrl) {
return;
}
const pageLayoutId = await this.resolvePageLayoutId(
serverUrl,
universalIdentifier,
token,
);
if (!pageLayoutId) {
return;
}
const url = `${frontUrl}/page/${pageLayoutId}`;
this.logDetail(`Opening app welcome page: ${url}`);
this.openInBrowser(url);
} catch {
// Best-effort — don't fail the scaffold if browser open fails
}
}
private async resolveWorkspaceFrontUrl(
serverUrl: string,
token: string,
): Promise<string | null> {
const query = `{ currentWorkspace { workspaceUrls { subdomainUrl customUrl } } }`;
const response = await fetch(`${serverUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ query }),
});
if (!response.ok) {
return null;
}
const body = (await response.json()) as {
data?: {
currentWorkspace?: {
workspaceUrls?: { subdomainUrl?: string; customUrl?: string };
};
};
};
const urls = body.data?.currentWorkspace?.workspaceUrls;
if (!urls) {
return null;
}
const frontUrl = urls.customUrl ?? urls.subdomainUrl;
return frontUrl ? normalizeUrl(frontUrl) : null;
}
private async readMainPageLayoutUniversalIdentifier(
appDirectory: string,
): Promise<string | null> {
const filePath = path.join(
appDirectory,
'src',
'constants',
'universal-identifiers.ts',
);
const content = await fs.readFile(filePath, 'utf-8');
const match = content.match(
/MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER\s*=\s*'([^']+)'/,
);
return match?.[1] ?? null;
}
private async resolvePageLayoutId(
serverUrl: string,
universalIdentifier: string,
token: string,
): Promise<string | null> {
const query = `{ getPageLayouts { id universalIdentifier } }`;
const response = await fetch(`${serverUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ query }),
});
if (!response.ok) {
return null;
}
const body = (await response.json()) as {
data?: {
getPageLayouts?: { id: string; universalIdentifier: string }[];
};
};
const matching = body.data?.getPageLayouts?.find(
(layout) => layout.universalIdentifier === universalIdentifier,
);
return matching?.id ?? null;
}
private sanitizeBrowserUrl(url: string): string | null {
if (/[^\u0020-\u007E]/.test(url)) {
return null;
}
try {
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
return parsed.toString();
} catch {
return null;
}
}
private openInBrowser(url: string): void {
const safeUrl = this.sanitizeBrowserUrl(url);
if (!safeUrl) {
return;
}
const isWindows = process.platform === 'win32';
const command = isWindows
? 'rundll32'
: process.platform === 'darwin'
? 'open'
: 'xdg-open';
const args = isWindows
? ['url.dll,FileProtocolHandler', safeUrl]
: [safeUrl];
const child = spawn(command, args, {
stdio: 'ignore',
detached: !isWindows,
});
child.on('error', () => undefined);
if (!isWindows) {
child.unref();
}
}
private async syncApplication(appDirectory: string): Promise<boolean> {
this.logDetail('Running `yarn twenty dev --once`...');
return new Promise((resolve) => {
const child = spawn('yarn', ['twenty', 'dev', '--once'], {
cwd: appDirectory,
stdio: ['inherit', 'pipe', 'pipe'],
});
child.stdout?.resume();
child.stderr?.resume();
child.on('close', (code) => resolve(code === 0));
child.on('error', () => resolve(false));
});
}
private async tryExistingAuth(serverUrl: string): Promise<boolean> {
try {
const configService = new ConfigService();
const remoteNames = await configService.getRemotes();
for (const remoteName of remoteNames) {
const remoteConfig = await configService.getConfigForRemote(remoteName);
if (remoteConfig.apiUrl !== serverUrl) {
continue;
}
const token = remoteConfig.twentyCLIAccessToken ?? remoteConfig.apiKey;
if (!token) {
continue;
}
const response = await fetch(`${serverUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
query: '{ currentWorkspace { id } }',
}),
});
if (!response.ok) {
continue;
}
const body = (await response.json()) as {
data?: { currentWorkspace?: { id: string } };
errors?: unknown[];
};
if (isDefined(body.data?.currentWorkspace) && !body.errors) {
ConfigService.setActiveRemote(remoteName);
await configService.setDefaultRemote(remoteName);
return true;
}
}
return false;
} catch {
return false;
}
}
private async authenticateWithDevKey(serverUrl: string): Promise<boolean> {
try {
const result = await authLogin({
apiKey: DEV_API_KEY,
apiUrl: serverUrl,
remote: 'local',
});
if (result.success) {
const configService = new ConfigService();
await configService.setDefaultRemote('local');
this.logDetail('Authenticated as tim@apple.dev (development API key)');
return true;
}
console.log(
chalk.yellow(
' Authentication failed. Run `yarn twenty remote:add` manually.',
),
);
return false;
} catch {
console.log(
chalk.yellow(
' Authentication failed. Run `yarn twenty remote:add` manually.',
),
);
return false;
}
}
private deriveRemoteName(url: string): string {
try {
return new URL(url).hostname.replace(/\./g, '-');
} catch {
return 'remote';
}
}
private async authenticateWithOAuth(serverUrl: string): Promise<boolean> {
try {
const remoteName = this.deriveRemoteName(serverUrl);
ConfigService.setActiveRemote(remoteName);
this.logDetail('Opening browser for OAuth...');
const result = await authLoginOAuth({ apiUrl: serverUrl });
if (result.success) {
const configService = new ConfigService();
await configService.setDefaultRemote(remoteName);
this.logDetail(`Authenticated via OAuth to ${serverUrl}`);
return true;
}
console.log(
chalk.yellow(
` OAuth failed: ${result.error.message}\n` +
` Run \`yarn twenty remote:add --url ${serverUrl}\` manually.`,
),
);
return false;
} catch {
console.log(
chalk.yellow(
` Authentication failed. Run \`yarn twenty remote:add --url ${serverUrl}\` manually.`,
),
);
return false;
}
}
private logSuccess(
appDirectory: string,
serverUrl: string,
authSucceeded: boolean,
): void {
const dirName = basename(appDirectory);
console.log(chalk.green('\n✔ Application created successfully!\n'));
console.log(chalk.white(' Next steps:\n'));
let stepNumber = 1;
console.log(chalk.white(` ${stepNumber}. Navigate to your project`));
console.log(chalk.cyan(` cd ${dirName}\n`));
stepNumber++;
if (!authSucceeded) {
console.log(chalk.white(` ${stepNumber}. Connect to a Twenty instance`));
console.log(
chalk.cyan(' yarn twenty remote:add --url <your-instance-url>\n'),
);
stepNumber++;
}
console.log(chalk.white(` ${stepNumber}. Start developing`));
console.log(chalk.cyan(' yarn twenty dev\n'));
stepNumber++;
console.log(chalk.white(` ${stepNumber}. Open your twenty instance`));
console.log(chalk.cyan(` ${serverUrl}\n`));
console.log(
chalk.gray(
' Documentation: https://docs.twenty.com/developers/extend/apps/getting-started/quick-start',
),
);
}
}

View File

@@ -0,0 +1,255 @@
// Generates the yarn.lock shipped inside the scaffold template.
//
// Without it, a generated project resolves every dependency from scratch on its
// first install, which fails outright when the user's package manager enforces a
// minimum release age (Yarn's npmMinimalAgeGate, pnpm's minimumReleaseAge, npm's
// min-release-age): the first-party packages are only minutes old at that point,
// and an exact pin leaves the resolver no older candidate to fall back to. A
// lockfile removes the resolution step entirely, so the gate never applies — and
// unlike loosening the pins or disabling the gate, it leaves the user's policy
// in force for every dependency they add later.
//
// Run this AFTER `nx build create-twenty-app` and AFTER the libraries are live on
// the registry, then publish; the publish workflow in twentyhq/twenty-infra runs
// it in that order.
import { execFileSync } from 'child_process';
import * as fs from 'fs-extra';
import { tmpdir } from 'os';
import { basename, dirname, join, resolve } from 'path';
import { isDefined } from 'twenty-shared/utils';
import { TEMPLATE_FIRST_PARTY_PACKAGES } from '@/constants/template-packages';
import createTwentyAppPackageJson from 'package.json';
const PACKAGE_ROOT = resolve(__dirname, '..', '..');
const REPO_ROOT = resolve(PACKAGE_ROOT, '..', '..');
const TEMPLATE_MANIFEST_PATH = join(
PACKAGE_ROOT,
'src/constants/template/package.json',
);
const DEFAULT_OUTPUT_PATH = join(
PACKAGE_ROOT,
'dist/constants/template/yarn.lock',
);
const PUBLIC_REGISTRY = 'https://registry.npmjs.org';
// Matches the monorepo's own npmMinimalAgeGate. Stated explicitly because nothing
// is inherited here: the lockfile is resolved in a temp directory outside the
// repo, with YARN_* stripped, so the effective gate would otherwise be zero.
const RELEASE_MINIMAL_AGE_GATE = '3d';
type Options = {
version: string;
registry: string;
outputPath: string;
};
const parseOptions = (argv: string[]): Options => {
const read = (name: string) => {
const prefix = `--${name}=`;
const flag = argv.find((argument) => argument.startsWith(prefix));
const value = flag?.slice(prefix.length);
// nx interpolates an unsupplied {args.foo} to an empty string.
return value === '' ? undefined : value;
};
return {
version: read('version') ?? createTwentyAppPackageJson.version,
registry: (read('registry') ?? PUBLIC_REGISTRY).replace(/\/$/, ''),
outputPath: read('out') ?? DEFAULT_OUTPUT_PATH,
};
};
// The generated project runs the Yarn its package.json pins. Generating the
// lockfile with a different Yarn can produce a lockfile that release pins reject
// with YN0028 on the user's first CI run, so refuse to guess.
const resolveYarnBinary = (templatePackageManager: string) => {
const yarnrc = fs.readFileSync(join(REPO_ROOT, '.yarnrc.yml'), 'utf8');
const yarnPath = /^yarnPath:\s*(.+)$/m.exec(yarnrc)?.[1]?.trim();
if (!isDefined(yarnPath)) {
throw new Error('No yarnPath found in the monorepo .yarnrc.yml');
}
const repoYarnVersion = /yarn-(.+)\.cjs$/.exec(basename(yarnPath))?.[1];
const templateYarnVersion = templatePackageManager.replace(/^yarn@/, '');
if (repoYarnVersion !== templateYarnVersion) {
throw new Error(
`The scaffold template pins yarn@${templateYarnVersion} but the monorepo runs yarn ${repoYarnVersion}. ` +
'Align them before generating the template lockfile.',
);
}
return join(REPO_ROOT, yarnPath);
};
const buildTemplateManifest = (version: string) => {
const manifest = fs.readJsonSync(TEMPLATE_MANIFEST_PATH);
for (const packageName of TEMPLATE_FIRST_PARTY_PACKAGES) {
manifest.devDependencies[packageName] = version;
}
return manifest;
};
const buildYarnrc = ({
registry,
version,
}: {
registry: string;
version: string;
}) => {
const { protocol, hostname } = new URL(registry);
return [
'nodeLinker: node-modules',
'enableTelemetry: false',
'enableScripts: false',
`npmRegistryServer: "${registry}"`,
// Yarn refuses plain HTTP unless the host is whitelisted by name, so take it
// from the registry rather than assuming localhost.
...(protocol === 'http:'
? ['unsafeHttpWhitelist:', ` - ${hostname}`]
: []),
// Scaffolded projects install this lockfile without re-resolving, which is
// exactly what lets them satisfy a consumer's age gate -- and equally what
// stops that gate from ever inspecting these versions. This resolution is
// therefore the only point at which an age gate applies to the tree a
// generated project receives, so hold one here, and waive it for nothing
// beyond the exact first-party versions this release is publishing.
`npmMinimalAgeGate: ${RELEASE_MINIMAL_AGE_GATE}`,
'npmPreapprovedPackages:',
...TEMPLATE_FIRST_PARTY_PACKAGES.map((name) => ` - "${name}@${version}"`),
'',
].join('\n');
};
const assertResolvedWithIntegrity = ({
lockfile,
packageName,
version,
}: {
lockfile: string;
packageName: string;
version: string;
}) => {
// Entry keys merge when several descriptors share one resolution
// ("pkg@npm:1.0.0, pkg@npm:^1.0.0":), so match the resolution line instead.
const resolution = `resolution: "${packageName}@npm:${version}"`;
const resolutionIndex = lockfile.indexOf(resolution);
if (resolutionIndex === -1) {
throw new Error(
`${packageName}@${version} is missing from the generated lockfile. Is it published yet?`,
);
}
const entryEnd = lockfile.indexOf('\n\n', resolutionIndex);
const entry = lockfile.slice(
resolutionIndex,
entryEnd === -1 ? undefined : entryEnd,
);
if (!entry.includes('checksum:')) {
throw new Error(
`${packageName}@${version} resolved without an integrity checksum.`,
);
}
};
// A registry serving tarballs from non-conventional URLs makes Yarn pin each
// entry to that exact host via __archiveUrl, which would not resolve for anyone
// else. Only the public registry produces a lockfile we can ship.
const assertNoRegistryPinning = ({
lockfile,
registry,
}: {
lockfile: string;
registry: string;
}) => {
if (registry !== PUBLIC_REGISTRY) {
// The release workflow always regenerates against the public registry right
// before publishing, so a lockfile built here is only ever local scaffolding.
// Say so out loud rather than passing silently.
console.warn(
`Generated against ${registry}, not ${PUBLIC_REGISTRY}: this lockfile is for local testing and must not be published.`,
);
return;
}
if (lockfile.includes('__archiveUrl')) {
throw new Error(
'The generated lockfile pins entries to a specific registry host. Regenerate it against the public registry.',
);
}
};
const generateTemplateLock = async ({
version,
registry,
outputPath,
}: Options) => {
const manifest = buildTemplateManifest(version);
const yarnBinary = resolveYarnBinary(manifest.packageManager);
const workingDirectory = await fs.mkdtemp(
join(tmpdir(), 'twenty-template-lock-'),
);
try {
await fs.writeJson(join(workingDirectory, 'package.json'), manifest, {
spaces: 2,
});
await fs.writeFile(
join(workingDirectory, '.yarnrc.yml'),
buildYarnrc({ registry, version }),
);
// YARN_* variables outrank the .yarnrc.yml written above, so a registry or
// gate exported by the surrounding CI job would silently change the result.
const environment = Object.fromEntries(
Object.entries(process.env).filter(([key]) => !key.startsWith('YARN_')),
);
execFileSync(
process.execPath,
[yarnBinary, 'install', '--mode=update-lockfile'],
{ cwd: workingDirectory, stdio: 'inherit', env: environment },
);
const lockfile = await fs.readFile(
join(workingDirectory, 'yarn.lock'),
'utf8',
);
for (const packageName of TEMPLATE_FIRST_PARTY_PACKAGES) {
assertResolvedWithIntegrity({ lockfile, packageName, version });
}
assertNoRegistryPinning({ lockfile, registry });
await fs.ensureDir(dirname(outputPath));
await fs.writeFile(outputPath, lockfile);
const resolutionCount = lockfile.match(/^ {2}resolution:/gm)?.length ?? 0;
console.log(
`Wrote ${outputPath} (${resolutionCount} resolutions, ${TEMPLATE_FIRST_PARTY_PACKAGES.join(', ')} @ ${version})`,
);
} finally {
await fs.remove(workingDirectory);
}
};
generateTemplateLock(parseOptions(process.argv.slice(2))).catch(
(error: unknown) => {
console.error(
`Failed to generate the template lockfile: ${error instanceof Error ? error.message : error}`,
);
process.exit(1);
},
);

View File

@@ -0,0 +1,289 @@
import { copyBaseApplicationProject } from '@/utils/app-template';
import * as fs from 'fs-extra';
import { tmpdir } from 'os';
import createTwentyAppPackageJson from 'package.json';
import { join } from 'path';
jest.mock('fs-extra', () => {
const actual = jest.requireActual('fs-extra');
return {
...actual,
copy: jest.fn().mockResolvedValue(undefined),
};
});
const UNIVERSAL_IDENTIFIERS_PATH = join(
'src',
'constants',
'universal-identifiers.ts',
);
const YARNRC_PATH = 'yarnrc.yml';
// Template content matching template/src/constants/universal-identifiers.ts
const TEMPLATE_UNIVERSAL_IDENTIFIERS = `export const APP_DISPLAY_NAME = 'DISPLAY-NAME-TO-BE-GENERATED';
export const APP_DESCRIPTION = 'DESCRIPTION-TO-BE-GENERATED';
export const APPLICATION_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
`;
// Template package.json matching template/package.json
const TEMPLATE_PACKAGE_JSON = {
name: 'template-app',
version: '0.1.0',
license: 'MIT',
scripts: { twenty: 'twenty' },
dependencies: {},
devDependencies: {
'twenty-client-sdk': '0.0.0',
'twenty-sdk': '0.0.0',
'twenty-ui': '0.0.0',
},
};
describe('copyBaseApplicationProject', () => {
let testAppDirectory: string;
beforeEach(async () => {
testAppDirectory = join(
tmpdir(),
`test-twenty-app-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
await fs.ensureDir(testAppDirectory);
// Seed the files that generateUniversalIdentifiers and updatePackageJson
// expect to find (since fs.copy is mocked and won't actually create them)
await fs.ensureDir(join(testAppDirectory, 'src', 'constants'));
await fs.writeFile(
join(testAppDirectory, UNIVERSAL_IDENTIFIERS_PATH),
TEMPLATE_UNIVERSAL_IDENTIFIERS,
);
await fs.writeJson(
join(testAppDirectory, 'package.json'),
TEMPLATE_PACKAGE_JSON,
);
jest.clearAllMocks();
});
afterEach(async () => {
if (testAppDirectory && (await fs.pathExists(testAppDirectory))) {
await fs.remove(testAppDirectory);
}
});
it('should call fs.copy to copy base application template', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
// Two fs.copy calls: (1) the template directory, (2) AGENTS.md → CLAUDE.md
expect(fs.copy).toHaveBeenCalledTimes(2);
expect(fs.copy).toHaveBeenCalledWith(
expect.stringContaining('template'),
testAppDirectory,
);
expect(fs.copy).toHaveBeenCalledWith(
join(testAppDirectory, 'AGENTS.md'),
join(testAppDirectory, 'CLAUDE.md'),
);
});
it('should replace placeholders in universal-identifiers.ts with real values', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
const content = await fs.readFile(
join(testAppDirectory, UNIVERSAL_IDENTIFIERS_PATH),
'utf8',
);
expect(content).toContain("APP_DISPLAY_NAME = 'My Test App'");
expect(content).toContain("APP_DESCRIPTION = 'A test application'");
expect(content).not.toContain('DISPLAY-NAME-TO-BE-GENERATED');
expect(content).not.toContain('DESCRIPTION-TO-BE-GENERATED');
expect(content).not.toContain('UUID-TO-BE-GENERATED');
// Both UUIDs should be valid v4 format
const uuidMatches = content.match(
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g,
);
expect(uuidMatches).toHaveLength(2);
});
it('should generate different UUIDs for each identifier', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
const content = await fs.readFile(
join(testAppDirectory, UNIVERSAL_IDENTIFIERS_PATH),
'utf8',
);
const uuidMatches = content.match(
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g,
);
expect(uuidMatches).toHaveLength(2);
expect(uuidMatches![0]).not.toBe(uuidMatches![1]);
});
it('should update package.json with app name and matching package versions', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
const packageJson = await fs.readJson(
join(testAppDirectory, 'package.json'),
);
expect(packageJson.name).toBe('my-test-app');
expect(packageJson.devDependencies['twenty-sdk']).toBe(
createTwentyAppPackageJson.version,
);
expect(packageJson.devDependencies['twenty-client-sdk']).toBe(
createTwentyAppPackageJson.version,
);
expect(packageJson.devDependencies['twenty-ui']).toBe(
createTwentyAppPackageJson.version,
);
});
it('should create an empty public directory in the scaffolded project', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
const publicDirectoryPath = join(testAppDirectory, 'public');
expect(await fs.pathExists(publicDirectoryPath)).toBe(true);
const publicDirectoryStats = await fs.stat(publicDirectoryPath);
expect(publicDirectoryStats.isDirectory()).toBe(true);
const publicDirectoryContents = await fs.readdir(publicDirectoryPath);
expect(publicDirectoryContents).toHaveLength(0);
});
it('should rename yarnrc.yml to .yarnrc.yml in the scaffolded project', async () => {
await fs.writeFile(
join(testAppDirectory, YARNRC_PATH),
'nodeLinker: node-modules',
);
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
expect(await fs.pathExists(join(testAppDirectory, YARNRC_PATH))).toBe(
false,
);
expect(await fs.pathExists(join(testAppDirectory, '.yarnrc.yml'))).toBe(
true,
);
});
it('should handle empty description', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: '',
appDirectory: testAppDirectory,
});
const content = await fs.readFile(
join(testAppDirectory, UNIVERSAL_IDENTIFIERS_PATH),
'utf8',
);
expect(content).toContain("APP_DESCRIPTION = ''");
});
it.each(['CHANGELOG.md', 'SETUP.md', 'locales/en.json'])(
'should seed %s in the base template',
async (seedFileName) => {
const templateDirectory = join(
__dirname,
'..',
'..',
'constants',
'template',
);
expect(await fs.pathExists(join(templateDirectory, seedFileName))).toBe(
true,
);
},
);
it('should generate unique UUIDs across different scaffolds', async () => {
const firstAppDir = join(testAppDirectory, 'app1');
await fs.ensureDir(join(firstAppDir, 'src', 'constants'));
await fs.writeFile(
join(firstAppDir, UNIVERSAL_IDENTIFIERS_PATH),
TEMPLATE_UNIVERSAL_IDENTIFIERS,
);
await fs.writeJson(
join(firstAppDir, 'package.json'),
TEMPLATE_PACKAGE_JSON,
);
await copyBaseApplicationProject({
appName: 'app-one',
appDisplayName: 'App One',
appDescription: 'First app',
appDirectory: firstAppDir,
});
const secondAppDir = join(testAppDirectory, 'app2');
await fs.ensureDir(join(secondAppDir, 'src', 'constants'));
await fs.writeFile(
join(secondAppDir, UNIVERSAL_IDENTIFIERS_PATH),
TEMPLATE_UNIVERSAL_IDENTIFIERS,
);
await fs.writeJson(
join(secondAppDir, 'package.json'),
TEMPLATE_PACKAGE_JSON,
);
await copyBaseApplicationProject({
appName: 'app-two',
appDisplayName: 'App Two',
appDescription: 'Second app',
appDirectory: secondAppDir,
});
const firstConstants = await fs.readFile(
join(firstAppDir, UNIVERSAL_IDENTIFIERS_PATH),
'utf8',
);
const secondConstants = await fs.readFile(
join(secondAppDir, UNIVERSAL_IDENTIFIERS_PATH),
'utf8',
);
const uuidRegex =
/APPLICATION_UNIVERSAL_IDENTIFIER = '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
const firstUuid = firstConstants.match(uuidRegex)?.[1];
const secondUuid = secondConstants.match(uuidRegex)?.[1];
expect(firstUuid).toBeDefined();
expect(secondUuid).toBeDefined();
expect(firstUuid).not.toBe(secondUuid);
});
});

View File

@@ -0,0 +1,10 @@
import { convertToLabel } from '@/utils/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

@@ -0,0 +1,211 @@
import { parse } from 'yaml';
import { install } from '@/utils/install';
type ExecCallback = (error: Error | null, stdout?: string) => void;
jest.mock('child_process', () => ({ exec: jest.fn() }));
// node's `exec` is overloaded and its widest overload does not accept this
// callback shape, so take the mock from the module registry under the shape the
// promisified call actually uses, rather than casting the imported binding.
const { exec: mockExec } = jest.requireMock<{
exec: jest.Mock<void, [string, unknown, ExecCallback]>;
}>('child_process');
type CommandOutcome = { stdout?: string; stderr?: string; fails?: boolean };
const mockCommands = (outcomes: Record<string, CommandOutcome>) => {
mockExec.mockImplementation(
(
command: string,
_options: unknown,
callback: (error: Error | null, stdout?: string) => void,
) => {
const outcome =
Object.entries(outcomes).find(([prefix]) =>
command.startsWith(prefix),
)?.[1] ?? {};
if (outcome.fails !== true) {
callback(null, outcome.stdout ?? '');
return;
}
const error = Object.assign(new Error(`Command failed: ${command}`), {
stdout: outcome.stdout ?? '',
stderr: outcome.stderr ?? '',
});
callback(error);
},
);
};
const quarantineOutput = ({
descriptor,
selector,
}: {
descriptor: string;
selector: string;
}) =>
`➤ YN0016: │ ${descriptor}@npm:${selector}: All versions satisfying "${selector}" are quarantined`;
const APP_DIRECTORY = '/tmp/some-scaffolded-app';
// The remediation is YAML the reader pastes into .yarnrc.yml, so assert it parses
// rather than that it reads correctly: a scoped descriptor left unquoted looks
// right in a substring check and is a YAML error in the file.
const parseSuggestedYarnrc = (message: string) => {
const lines = message.split('\n');
const blockStart = lines.findIndex(
(line) => line.trim() === 'npmPreapprovedPackages:',
);
if (blockStart === -1) {
throw new Error(`No npmPreapprovedPackages block in message:\n${message}`);
}
const block: string[] = [];
for (const line of lines.slice(blockStart)) {
if (line.trim().length === 0) {
break;
}
block.push(line.slice(' '.length));
}
const parsed: { npmPreapprovedPackages: string[] } = parse(block.join('\n'));
return parsed;
};
const installAndCatch = () =>
install(APP_DIRECTORY).then(
() => '',
(error: Error) => error.message,
);
describe('install', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('installs without immutable mode so the template lockfile can be finalised', async () => {
mockCommands({});
await install(APP_DIRECTORY);
expect(mockExec).toHaveBeenCalledWith(
'yarn install --no-immutable',
{ cwd: APP_DIRECTORY },
expect.any(Function),
);
});
it('reports the failure instead of leaving a project without dependencies', async () => {
mockCommands({
'yarn install': { fails: true, stdout: 'YN0001: Something exploded' },
});
await expect(install(APP_DIRECTORY)).rejects.toThrow(
/Dependency installation failed/,
);
});
it('says how to recover, since the scaffolded project itself is intact', async () => {
mockCommands({
'yarn install': { fails: true, stdout: 'YN0001: Something exploded' },
});
const message = await installAndCatch();
expect(message).toContain('YN0001: Something exploded');
expect(message).toContain('run `yarn install` there');
});
it('names the packages the gate actually quarantined', async () => {
mockCommands({
'yarn install': {
fails: true,
stdout: quarantineOutput({
descriptor: 'twenty-ui',
selector: '2.41.0',
}),
},
});
const message = await installAndCatch();
expect(message).toContain('minimum');
expect(message).toContain(APP_DIRECTORY);
expect(parseSuggestedYarnrc(message)).toEqual({
npmPreapprovedPackages: ['twenty-ui@2.41.0'],
});
// Never advise lowering the gate itself.
expect(message).not.toContain('npmMinimalAgeGate');
});
it('waives the third-party package Yarn named, not the first-party ones', async () => {
mockCommands({
'yarn install': {
fails: true,
stdout: quarantineOutput({
descriptor: '@scope/some-tool',
selector: '^1.2.3',
}),
},
});
const message = await installAndCatch();
expect(parseSuggestedYarnrc(message)).toEqual({
npmPreapprovedPackages: ['@scope/some-tool@^1.2.3'],
});
expect(message).not.toContain('twenty-ui');
expect(message).not.toContain('twenty-sdk');
});
it('waives a quarantined dist-tag by name, since a tag is not a usable range', async () => {
mockCommands({
'yarn install': {
fails: true,
stdout:
'➤ YN0016: │ twenty-ui@npm:latest: The version for tag "latest" is quarantined, and no lower version is available',
},
});
const message = await installAndCatch();
expect(parseSuggestedYarnrc(message)).toEqual({
npmPreapprovedPackages: ['twenty-ui'],
});
expect(message).not.toContain('twenty-ui@latest');
});
it('falls back to the yarn output when no package can be identified', async () => {
mockCommands({
'yarn install': {
fails: true,
stdout: '➤ YN0016: │ something unparseable happened',
},
});
const message = await installAndCatch();
expect(message).toContain('something unparseable happened');
expect(message).not.toContain('npmPreapprovedPackages');
});
it('tolerates corepack being unavailable when yarn still installs', async () => {
mockCommands({ 'corepack enable': { fails: true, stderr: 'no corepack' } });
await expect(install(APP_DIRECTORY)).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,139 @@
import * as fs from 'fs-extra';
import { join } from 'path';
import { v4 } from 'uuid';
import { TEMPLATE_FIRST_PARTY_PACKAGES } from '@/constants/template-packages';
import createTwentyAppPackageJson from 'package.json';
const SRC_FOLDER = 'src';
export const copyBaseApplicationProject = async ({
appName,
appDisplayName,
appDescription,
appDirectory,
onProgress,
}: {
appName: string;
appDisplayName: string;
appDescription: string;
appDirectory: string;
onProgress?: (message: string) => void;
}) => {
onProgress?.('Copying base template');
await fs.copy(join(__dirname, './constants/template'), appDirectory);
onProgress?.('Configuring dotfiles (.gitignore, .github, .yarnrc.yml)');
await renameDotfiles({ appDirectory });
onProgress?.('Mirroring AGENTS.md to CLAUDE.md');
await mirrorAgentsToClaude({ appDirectory });
await addEmptyPublicDirectory({ appDirectory });
onProgress?.('Generating unique application identifiers');
await generateUniversalIdentifiers({
appDisplayName,
appDescription,
appDirectory,
});
onProgress?.('Updating package.json');
await updatePackageJson({ appName, appDirectory });
};
// npm strips dotfiles/dotdirs (.gitignore, .github/) from published packages,
// so we store them without the leading dot and rename after copying.
const renameDotfiles = async ({ appDirectory }: { appDirectory: string }) => {
const renames = [
{ from: 'gitignore', to: '.gitignore' },
{ from: 'github', to: '.github' },
{ from: 'yarnrc.yml', to: '.yarnrc.yml' },
];
for (const { from, to } of renames) {
const sourcePath = join(appDirectory, from);
if (await fs.pathExists(sourcePath)) {
await fs.rename(sourcePath, join(appDirectory, to));
}
}
};
// AGENTS.md is the cross-tool standard; Claude Code prefers CLAUDE.md and only
// falls back to AGENTS.md, so we mirror the file to keep a single source of truth.
const mirrorAgentsToClaude = async ({
appDirectory,
}: {
appDirectory: string;
}) => {
await fs.copy(
join(appDirectory, 'AGENTS.md'),
join(appDirectory, 'CLAUDE.md'),
);
};
const addEmptyPublicDirectory = async ({
appDirectory,
}: {
appDirectory: string;
}) => {
await fs.ensureDir(join(appDirectory, 'public'));
};
const generateUniversalIdentifiers = async ({
appDisplayName,
appDescription,
appDirectory,
}: {
appDisplayName: string;
appDescription: string;
appDirectory: string;
}) => {
const universalIdentifiersPath = join(
appDirectory,
SRC_FOLDER,
'constants',
'universal-identifiers.ts',
);
const universalIdentifiersFileContent = await fs.readFile(
universalIdentifiersPath,
'utf-8',
);
await fs.writeFile(
universalIdentifiersPath,
universalIdentifiersFileContent
.replace('DISPLAY-NAME-TO-BE-GENERATED', appDisplayName)
.replace('DESCRIPTION-TO-BE-GENERATED', appDescription)
.replace(/UUID-TO-BE-GENERATED/g, () => v4()),
);
};
const updatePackageJson = async ({
appName,
appDirectory,
}: {
appName: string;
appDirectory: string;
}) => {
const packageJson = await fs.readJson(join(appDirectory, 'package.json'));
// The template yarn.lock keeps its placeholder workspace name: the only entry
// naming the project is the workspace root, which the scaffolder's `yarn install`
// rewrites from package.json without re-resolving a single dependency. Renaming
// it here would move the entry out of sort order and break `--immutable`.
packageJson.name = appName;
for (const packageName of TEMPLATE_FIRST_PARTY_PACKAGES) {
packageJson.devDependencies[packageName] =
createTwentyAppPackageJson.version;
}
await fs.writeFile(
join(appDirectory, 'package.json'),
JSON.stringify(packageJson, null, 2),
'utf8',
);
};

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

@@ -0,0 +1,33 @@
import { execSync } from 'node:child_process';
const DOCKER_DOWNLOAD_URLS: Record<string, string> = {
darwin: 'https://docs.docker.com/desktop/setup/install/mac-install/',
win32: 'https://docs.docker.com/desktop/setup/install/windows-install/',
linux: 'https://docs.docker.com/engine/install/',
};
export const isDockerInstalled = (): boolean => {
try {
execSync('docker --version', { stdio: 'ignore' });
return true;
} catch {
return false;
}
};
export const getDockerInstallInstructions = (): string => {
const url =
DOCKER_DOWNLOAD_URLS[process.platform] ?? DOCKER_DOWNLOAD_URLS.linux;
return [
' Docker is required but not installed.',
'',
` Install Docker: ${url}`,
'',
' Then run this command again.',
'',
' Alternatively, connect to an existing Twenty instance:',
' npx create-twenty-app@latest my-twenty-app --url <your-instance-url>',
].join('\n');
};

View File

@@ -0,0 +1,110 @@
import chalk from 'chalk';
import { exec } from 'child_process';
import { promisify } from 'util';
const execPromise = promisify(exec);
// Raised as "All versions satisfying <range> are quarantined", or for a tag as
// "The version for tag <tag> is quarantined", when every candidate version is
// younger than the configured npmMinimalAgeGate.
const YARN_QUARANTINE_ERROR_CODE = 'YN0016';
const QUARANTINED_PACKAGE_PATTERN =
/(@?[\w.-]+(?:\/[\w.-]+)?)@npm:(\S+?): (?:All versions satisfying|The version for tag)/g;
const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g;
// A selector Yarn can test against a version works as a preapproval entry; a
// dist-tag does not, and only the bare package name will waive it.
const VERSION_SELECTOR_PATTERN = /^[\^~>=<\s]*\d/;
const OUTPUT_TAIL_LINES = 20;
const tail = (output: string) =>
output.trim().split('\n').slice(-OUTPUT_TAIL_LINES).join('\n');
const parseQuarantinedPackages = (output: string) => {
const matches = output
.replace(ANSI_ESCAPE_PATTERN, '')
.matchAll(QUARANTINED_PACKAGE_PATTERN);
return [
...new Set(
[...matches].map(([, packageName, selector]) =>
VERSION_SELECTOR_PATTERN.test(selector)
? `${packageName}@${selector}`
: packageName,
),
),
];
};
const buildQuarantineMessage = ({
appDirectory,
quarantinedPackages,
}: {
appDirectory: string;
quarantinedPackages: string[];
}) =>
[
'Dependency installation failed: your package manager enforces a minimum',
'release age, and these packages were published too recently to satisfy it:',
'',
...quarantinedPackages.map((descriptor) => ` ${descriptor}`),
'',
`Your project was created at ${appDirectory} and does not need to be scaffolded again.`,
'You can either wait until those packages are older than your configured gate',
'and run `yarn install` there, or waive the gate for just them by adding to',
"that project's .yarnrc.yml:",
'',
' npmPreapprovedPackages:',
// Quoted because a scoped descriptor starts with "@", which YAML reserves:
// an unquoted `- @scope/pkg@^1.2.3` is a parse error, not a value.
...quarantinedPackages.map((descriptor) => ` - "${descriptor}"`),
'',
'That leaves the gate in force for every other dependency.',
].join('\n');
export const install = async (
root: string,
onProgress?: (message: string) => void,
) => {
onProgress?.('Enabling corepack');
try {
await execPromise('corepack enable', { cwd: root });
} catch (error: any) {
console.warn(chalk.yellow('corepack enable failed:'), error.stderr);
}
onProgress?.('Running yarn install');
try {
// Yarn installs immutably by default when CI is set, but this install is what
// finalises the template's lockfile: it rewrites the workspace root entry to
// the project's real name. Immutable mode rejects that with YN0028.
await execPromise('yarn install --no-immutable', { cwd: root });
} catch (error: any) {
const output = `${error.stdout ?? ''}\n${error.stderr ?? ''}`;
const quarantinedPackages = output.includes(YARN_QUARANTINE_ERROR_CODE)
? parseQuarantinedPackages(output)
: [];
// Continuing here would authenticate, sync and report success over a project
// that has no node_modules, so surface the failure instead.
throw new Error(
quarantinedPackages.length > 0
? buildQuarantineMessage({
appDirectory: root,
quarantinedPackages,
})
: [
`Dependency installation failed in ${root}:`,
'',
tail(output),
'',
// The project itself is intact: package.json carries its real name and
// the lockfile is the shipped one, which a plain install reconciles.
'The project was created. Fix the problem above and run `yarn install` there.',
].join('\n'),
);
}
};

View File

@@ -0,0 +1,70 @@
import * as fs from 'fs-extra';
import { join } from 'path';
import { promisify } from 'util';
import { exec } from 'child_process';
const execPromise = promisify(exec);
const isInGitRepository = async (root: string): Promise<boolean> => {
try {
await execPromise('git rev-parse --is-inside-work-tree', { cwd: root });
return true;
} catch {
// Empty catch block
}
return false;
};
const isInMercurialRepository = async (root: string): Promise<boolean> => {
try {
await execPromise('hg --cwd . root', { cwd: root });
return true;
} catch {
// Empty catch block
}
return false;
};
const isDefaultBranchSet = async (root: string): Promise<boolean> => {
try {
await execPromise('git config init.defaultBranch', { cwd: root });
return true;
} catch {
// Empty catch block
}
return false;
};
export const tryGitInit = async (root: string): Promise<boolean> => {
try {
await execPromise('git --version', { cwd: root });
if (
(await isInGitRepository(root)) ||
(await isInMercurialRepository(root))
) {
return false;
}
await execPromise('git init', { cwd: root });
try {
if (!(await isDefaultBranchSet(root))) {
await execPromise('git checkout -b main', { cwd: root });
}
await execPromise('git add -A', { cwd: root });
await execPromise(
'git commit -m "Initial commit from Create Twenty App"',
{
cwd: root,
},
);
return true;
} catch {
fs.rm(join(root, '.git'), { recursive: true, force: true });
return false;
}
} catch {
return false;
}
};

View File

@@ -0,0 +1,33 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"allowJs": false,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strictNullChecks": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"noEmit": true,
"types": ["jest", "node"],
"paths": {
"@/*": ["./src/*"],
"package.json": ["./package.json"]
},
"jsx": "react"
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.d.ts",
"**/__mocks__/**/*",
"vite.config.ts",
"jest.config.mjs"
],
"exclude": [
"src/constants/template/vitest.config.ts",
"src/constants/template/vitest.unit.config.ts",
"src/constants/template/src/**",
"src/constants/template/tsconfig.spec.json"
]
}

View File

@@ -0,0 +1,27 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true
},
"include": ["src"],
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts",
"**/__tests__/**",
"src/constants/template/src/**",
"src/constants/template/vitest.config.ts",
"src/constants/template/vitest.unit.config.ts",
"src/constants/template/tsconfig.spec.json"
]
}

View File

@@ -0,0 +1,106 @@
import fs from 'fs-extra';
import path from 'path';
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';
import packageJson from './package.json';
import type { PackageJson } from 'type-fest';
const moduleEntries = Object.keys((packageJson as any).exports || {})
.filter(
(key) => key !== './style.css' && key !== '.' && !key.startsWith('./src/'),
)
.map((module) => `src/${module.replace(/^\.\//, '')}/index.ts`);
const entries = ['src/cli.ts', ...moduleEntries];
const entryFileNames = (chunk: any, extension: 'cjs' | 'mjs') => {
if (!chunk.isEntry) {
throw new Error(
`Should never occurs, encountered a non entry chunk ${chunk.facadeModuleId}`,
);
}
const splitFaceModuleId = chunk.facadeModuleId?.split('/');
if (splitFaceModuleId === undefined) {
throw new Error(
`Should never occurs splitFaceModuleId is undefined ${chunk.facadeModuleId}`,
);
}
const moduleDirectory = splitFaceModuleId[splitFaceModuleId?.length - 2];
if (moduleDirectory === 'src') {
return `${chunk.name}.${extension}`;
}
return `${moduleDirectory}.${extension}`;
};
const copyAssetPlugin = (targets: { src: string; dest: string }[]) => {
return {
name: 'copy-assets',
closeBundle: async () => {
for (const target of targets) {
await fs.copy(
path.resolve(__dirname, target.src),
path.resolve(__dirname, target.dest),
);
}
},
};
};
export default defineConfig(() => {
const tsConfigPath = path.resolve(__dirname, './tsconfig.lib.json');
return {
root: __dirname,
cacheDir: '../../node_modules/.vite/packages/create-twenty-app',
resolve: {
tsconfigPaths: true,
},
plugins: [
dts({ entryRoot: './src', tsconfigPath: tsConfigPath }),
copyAssetPlugin([
{
src: 'src/constants/template',
dest: 'dist/constants/template',
},
]),
],
build: {
outDir: 'dist',
lib: { entry: entries, name: 'create-twenty-app' },
rollupOptions: {
external: (id: string) => {
if (/^node:/.test(id)) {
return true;
}
const builtins = ['path', 'fs', 'child_process', 'util'];
if (builtins.includes(id)) {
return true;
}
const deps = Object.keys(
(packageJson as PackageJson).dependencies || {},
);
return deps.some((dep) => id === dep || id.startsWith(dep + '/'));
},
output: [
{
format: 'es',
entryFileNames: (chunk) => entryFileNames(chunk, 'mjs'),
},
{
format: 'cjs',
esModule: true,
exports: 'named',
entryFileNames: (chunk) => entryFileNames(chunk, 'cjs'),
},
],
},
},
logLevel: 'warn',
};
});