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

6
packages/twenty-sdk/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules
.twenty
/generated
storybook-static
src/front-component-renderer/__stories__/example-sources-built
src/front-component-renderer/__stories__/example-sources-built-preact

View File

@@ -0,0 +1,57 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "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": "^_"
}
],
"react/no-unescaped-entities": "off",
"react/prop-types": "off",
"react/jsx-key": "off",
"react/display-name": "off",
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
"react/jsx-no-useless-fragment": "off",
"react/jsx-props-no-spreading": ["error", { "explicitSpread": "ignore" }],
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}

View File

@@ -0,0 +1,3 @@
dist
storybook-static
coverage

View File

@@ -0,0 +1,59 @@
# Changelog
All notable changes to the [Twenty SDK](https://www.npmjs.com/package/twenty-sdk) are documented in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- **`enqueueJobs` in `twenty-sdk/logic-function`.** Enqueues one run per payload of a single logic function in one call (up to 200 payloads per batch). `retryLimit` and `delayMs` apply to every run in the batch.
```ts
import { enqueueJobs } from 'twenty-sdk/logic-function';
await enqueueJobs({
logicFunctionUniversalIdentifier: PROCESS_BATCH,
payloads: batches.map((batch, batchIndex) => ({ batchIndex })),
});
```
### Deprecated
- **`enqueueJob` in `twenty-sdk/logic-function`.** Use `enqueueJobs` with a one-element `payloads` list instead. `enqueueJob` keeps working for now and will be removed in a future major version.
### Changed
- **`twenty-client-sdk` should now be a dev dependency too.** Although app code imports it (`CoreApiClient`, `MetadataApiClient`, `RestApiClient`), Twenty provides it at runtime — logic functions get it from a generated SDK layer and front components resolve it from server-served modules — so the installed copy is only needed for typechecking and the deploy-time build. Newly scaffolded apps now place it under `devDependencies`. Moving it is recommended (not required: the server already strips it from the deployed runtime), and keeps the installed app leaner:
```diff
"dependencies": {
- "twenty-client-sdk": "^2.13.0"
},
"devDependencies": {
+ "twenty-client-sdk": "^2.13.0"
}
```
`twenty build` now also emits a warning when `twenty-client-sdk` is still listed under `dependencies`.
## [2.8.0]
### Breaking Changes
- **`twenty-sdk` must now be a dev dependency.** It ships the `twenty` CLI and the build/scaffolding tooling, which only run at development and build time — it is never imported by a published app's runtime. Newly scaffolded apps already place it under `devDependencies`. Apps created before `2.8.0` must move it when upgrading:
```diff
"dependencies": {
"twenty-client-sdk": "^2.8.0"
- "twenty-sdk": "^2.8.0"
},
"devDependencies": {
+ "twenty-sdk": "^2.8.0"
}
```
Then reinstall with `rm -rf node_modules && yarn install`. `twenty-client-sdk` stays under `dependencies` because app code imports it at runtime.
`twenty build` now emits a warning when `twenty-sdk` is still listed under `dependencies`, so existing apps are flagged automatically.

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,100 @@
<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>Twenty SDK</h1>
<a href="https://www.npmjs.com/package/twenty-sdk"><img alt="NPM version" src="https://img.shields.io/npm/v/twenty-sdk.svg?style=for-the-badge&labelColor=000000"></a>
<a href="https://github.com/twentyhq/twenty/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/npm/l/next.svg?style=for-the-badge&labelColor=000000"></a>
<a href="https://discord.gg/cx5n4Jzs57"><img alt="Join the community on Discord" src="https://img.shields.io/badge/Join%20the%20community-blueviolet.svg?style=for-the-badge&logo=Twenty&labelColor=000000&logoWidth=20"></a>
</div>
A CLI and SDK to develop, build, and publish applications that extend [Twenty CRM](https://twenty.com).
## Quick start
The recommended way to start is with [create-twenty-app](https://www.npmjs.com/package/create-twenty-app):
```bash
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
yarn twenty dev
```
## Documentation
Full documentation is available at **[docs.twenty.com/developers/extend/apps](https://docs.twenty.com/developers/extend/apps/getting-started)**:
- [Getting Started](https://docs.twenty.com/developers/extend/apps/getting-started) — scaffolding, local server, authentication, dev mode
- [Building Apps](https://docs.twenty.com/developers/extend/apps/building) — entity definitions, API clients, testing, CLI reference
- [Publishing](https://docs.twenty.com/developers/extend/apps/publishing) — deploy, npm publish, marketplace
Guides in this repository:
- [Logic function inputs](./docs/logic-function-inputs.md) — input schema inference, record-typed inputs, and the id contract
## Manual installation
If you are adding `twenty-sdk` to an existing project instead of using `create-twenty-app`:
```bash
yarn add twenty-sdk twenty-client-sdk
```
Then add a `twenty` script to your `package.json`:
```json
{
"scripts": {
"twenty": "twenty"
}
}
```
Run `yarn twenty help` to see all available commands.
## Configuration
The CLI stores credentials per remote in `~/.twenty/config.json`. Run `yarn twenty remote:add` to configure a remote, or `yarn twenty remote:list` to see existing ones.
## Troubleshooting
- Auth errors: run `yarn twenty remote:add` to re-authenticate.
- Typings out of date: restart `yarn twenty dev` to refresh the client and types.
- Not seeing changes in dev: make sure dev mode is running (`yarn twenty dev`).
## Contributing
### Development setup
```bash
git clone https://github.com/twentyhq/twenty.git
cd twenty
yarn install
```
### Development mode
```bash
npx nx run twenty-sdk:dev
```
### Production build
```bash
npx nx run twenty-sdk:build
```
### Running the CLI locally
```bash
npx nx run twenty-sdk:start -- <command>
```
### Resources
- See our [GitHub](https://github.com/twentyhq/twenty)
- Join our [Discord](https://discord.gg/cx5n4Jzs57)

View File

@@ -0,0 +1,66 @@
# Logic function inputs
When a logic function opts into the workflow action or AI tool surface but does
not declare an explicit `inputSchema`, the SDK infers one from the handler's
parameter type during the manifest build. The workflow builder uses that schema
to render an input form, and record-typed inputs render as record pickers.
## How inference works
Inference reads the handler's single `params` object type and maps each property:
- `string` / `number` / `boolean` map to the matching scalar input.
- String literal unions (`'a' | 'b'`) map to a select input.
- `T[]` / `Array<T>` map to array inputs.
- `TwentyRecord<'objectUniversalIdentifier'>` maps to a record input (see below).
Inference runs only when the trigger settings omit `inputSchema`. Providing an
explicit `inputSchema` disables inference for that surface entirely — this is the
escape hatch when a handler type cannot be expressed inline.
## Record-typed inputs
To bind an input to a workspace object, type it with `TwentyRecord`, passing the
object's universal identifier as a string literal:
```ts
import { defineLogicFunction, type TwentyRecord } from 'twenty-sdk/define';
const handler = async (params: {
companyId: TwentyRecord<'20202020-b374-4779-a561-80086cb2e17f'>;
postCardIds: TwentyRecord<'54b589ca-eeed-4950-a176-358418b85c05'>[];
}) => {
return {
companyId: params.companyId,
postCardCount: params.postCardIds.length,
};
};
```
The universal identifier is the source of truth and is read directly from the
literal — there is no name matching, so an unrelated type can never be mistaken
for a record.
- **Standard objects**: get the identifier from `STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS`
(exported from `twenty-sdk/define`), e.g.
`STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier`.
- **App objects**: use the `universalIdentifier` you set on the object's
`defineObject(...)`.
Only a string-literal argument resolves. `TwentyRecord` with no argument, or with
a non-literal argument, is treated as an unknown input.
## What the handler receives
`TwentyRecord<TUid>` is a branded `string`: `companyId` is a record id, and
`postCardIds` is an array of record ids. This matches what the runtime delivers —
the workflow action passes the selected record ids (or the value a bound
`{{variable}}` resolves to) straight to the handler. Handlers must therefore
accept ids. The People Data Labs functions model this:
```ts
export type RecordInput = string | { id?: string | null };
```
and normalize the input with an `extractRecordIds` helper before use. If a
handler needs full records, it fetches them by id with the Core API client.

View File

@@ -0,0 +1,142 @@
{
"name": "twenty-sdk",
"version": "2.42.0",
"sideEffects": false,
"bin": {
"twenty": "dist/cli.cjs"
},
"files": [
"dist",
"README.md",
"CHANGELOG.md",
"package.json"
],
"scripts": {
"build": "npx rimraf dist && npx vite build"
},
"keywords": [
"twenty",
"cli",
"sdk",
"crm",
"application",
"development"
],
"exports": {
"./billing": {
"types": "./dist/billing/index.d.ts",
"import": "./dist/billing/index.mjs",
"require": "./dist/billing/index.cjs"
},
"./define": {
"types": "./dist/define/index.d.ts",
"import": "./dist/define/index.mjs",
"require": "./dist/define/index.cjs"
},
"./front-component": {
"types": "./dist/front-component/index.d.ts",
"import": "./dist/front-component/index.mjs",
"require": "./dist/front-component/index.cjs"
},
"./logic-function": {
"types": "./dist/logic-function/index.d.ts",
"import": "./dist/logic-function/index.mjs",
"require": "./dist/logic-function/index.cjs"
},
"./utils": {
"types": "./dist/utils/index.d.ts",
"import": "./dist/utils/index.mjs",
"require": "./dist/utils/index.cjs"
},
"./cli": {
"types": "./dist/cli/operations/index.d.ts",
"import": "./dist/operations.mjs",
"require": "./dist/operations.cjs"
},
"./front-component-renderer": {
"types": "./dist/front-component-renderer/index.d.ts",
"import": "./dist/front-component-renderer.mjs",
"require": "./dist/front-component-renderer.cjs"
},
"./front-component-renderer/build": {
"types": "./dist/front-component-renderer/build/index.d.ts",
"import": "./dist/front-component-renderer/build.mjs",
"require": "./dist/front-component-renderer/build.cjs"
}
},
"license": "MIT",
"dependencies": {
"@sniptt/guards": "^0.2.0",
"axios": "^1.16.0",
"chalk": "^5.3.0",
"chokidar": "^4.0.0",
"commander": "^12.0.0",
"esbuild": "^0.28.1",
"graphql": "^16.8.1",
"graphql-sse": "^2.5.4",
"ink": "^6.8.0",
"inquirer": "^14.0.0",
"jsonc-parser": "^3.2.0",
"preact": "^10.28.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"semver": "7.6.3",
"sharp": "^0.35.3",
"tinyglobby": "^0.2.15",
"twenty-client-sdk": "workspace:*",
"typescript": "^5.9.3",
"uuid": "^13.0.2"
},
"devDependencies": {
"@prettier/sync": "^0.5.2",
"@types/node": "^24.0.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/semver": "^7.5.8",
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
"@vitest/coverage-v8": "^4.0.18",
"prettier": "^3.1.1",
"rollup-plugin-dts": "^6.4.1",
"ts-morph": "^25.0.0",
"tsc-alias": "^1.8.16",
"tsx": "^4.7.0",
"twenty-shared": "workspace:*",
"twenty-ui": "workspace:*",
"vite": "^8.0.0",
"vite-plugin-dts": "^4.5.4",
"wait-on": "^9.0.10"
},
"engines": {
"node": "^24.5.0",
"yarn": "^4.0.2",
"twenty": ">=2.40.0"
},
"typesVersions": {
"*": {
"billing": [
"dist/billing/index.d.ts"
],
"define": [
"dist/define/index.d.ts"
],
"front-component": [
"dist/front-component/index.d.ts"
],
"logic-function": [
"dist/logic-function/index.d.ts"
],
"utils": [
"dist/utils/index.d.ts"
],
"cli": [
"dist/cli/operations/index.d.ts"
],
"front-component-renderer": [
"dist/front-component-renderer/index.d.ts"
],
"front-component-renderer/build": [
"dist/front-component-renderer/build/index.d.ts"
]
}
}
}

View File

@@ -0,0 +1,107 @@
{
"name": "twenty-sdk",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/twenty-sdk/src",
"projectType": "library",
"tags": ["scope:sdk"],
"targets": {
"build": {
"executor": "nx:run-commands",
"cache": true,
"inputs": ["production", "^production"],
"dependsOn": ["^build"],
"outputs": ["{projectRoot}/dist"],
"options": {
"cwd": "{projectRoot}",
"commands": [
"npx rimraf dist && npx vite build -c vite.config.node.ts && npx vite build -c vite.config.define.ts && npx vite build -c vite.config.billing.ts && npx vite build -c vite.config.front-component.ts && npx vite build -c vite.config.logic-function.ts && npx vite build -c vite.config.utils.ts && npx vite build -c vite.config.browser.ts",
"tsgo -p tsconfig.lib.json --declaration --emitDeclarationOnly --noEmit false --outDir dist --rootDir src && npx tsc-alias -p tsconfig.lib.json --outDir dist",
"npx rimraf 'dist/sdk' 'dist/define/**/*.d.ts' 'dist/define/**/*.d.ts.map' 'dist/billing/**/*.d.ts' 'dist/billing/**/*.d.ts.map' 'dist/front-component/**/*.d.ts' 'dist/front-component/**/*.d.ts.map' 'dist/logic-function/**/*.d.ts' 'dist/logic-function/**/*.d.ts.map' 'dist/utils/**/*.d.ts' 'dist/utils/**/*.d.ts.map' && npx rollup -c rollup.config.sdk-dts.mjs"
],
"parallel": false
}
},
"dev": {
"executor": "nx:run-commands",
"dependsOn": ["^build"],
"options": {
"cwd": "packages/twenty-sdk",
"command": "npx rimraf dist && npx vite build -c vite.config.node.ts && npx vite build -c vite.config.define.ts && npx vite build -c vite.config.billing.ts && npx vite build -c vite.config.front-component.ts && npx vite build -c vite.config.logic-function.ts && npx vite build -c vite.config.utils.ts && npx vite build -c vite.config.browser.ts && tsgo -p tsconfig.lib.json --declaration --emitDeclarationOnly --noEmit false --outDir dist --rootDir src && npx tsc-alias -p tsconfig.lib.json --outDir dist && npx rimraf 'dist/sdk' 'dist/define/**/*.d.ts' 'dist/define/**/*.d.ts.map' 'dist/billing/**/*.d.ts' 'dist/billing/**/*.d.ts.map' 'dist/front-component/**/*.d.ts' 'dist/front-component/**/*.d.ts.map' 'dist/logic-function/**/*.d.ts' 'dist/logic-function/**/*.d.ts.map' 'dist/utils/**/*.d.ts' 'dist/utils/**/*.d.ts.map' && npx rollup -c rollup.config.sdk-dts.mjs && npx vite build -c vite.config.node.ts --watch & npx vite build -c vite.config.define.ts --watch & npx vite build -c vite.config.billing.ts --watch & npx vite build -c vite.config.front-component.ts --watch & npx vite build -c vite.config.logic-function.ts --watch & npx vite build -c vite.config.utils.ts --watch & npx vite build -c vite.config.browser.ts --watch"
}
},
"start": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"cwd": "packages/twenty-sdk",
"command": "node dist/cli.cjs"
}
},
"set-local-version": {},
"typecheck": {},
"lint": {},
"test": {
"executor": "@nx/vitest:test",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"config": "{projectRoot}/vitest.config.ts"
},
"configurations": {
"ci": {
"coverage": true,
"watch": false
}
}
},
"test:unit": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-sdk",
"command": "npx vitest run --config vitest.unit.config.ts"
}
},
"test:integration": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-sdk",
"command": "npx vitest run --config vitest.integration.config.ts"
}
},
"test:e2e": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-sdk",
"command": "npx wait-on http://localhost:3000/healthz --timeout 600000 --interval 1000 --log && NODE_ENV=test npx vitest run --config ./vitest.e2e.config.ts"
},
"parallel": false,
"dependsOn": [
"build",
{
"target": "database:reset",
"projects": "twenty-server"
},
{
"target": "start:ci-if-needed",
"projects": "twenty-server"
}
]
},
"build:sdk": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": ["^build"],
"inputs": ["{projectRoot}/src/sdk/**/*", "^production"],
"outputs": [
"{projectRoot}/dist/billing",
"{projectRoot}/dist/define",
"{projectRoot}/dist/front-component",
"{projectRoot}/dist/logic-function",
"{projectRoot}/dist/utils"
],
"options": {
"cwd": "{projectRoot}",
"command": "npx vite build -c vite.config.define.ts && npx vite build -c vite.config.billing.ts && npx vite build -c vite.config.front-component.ts && npx vite build -c vite.config.logic-function.ts && npx vite build -c vite.config.utils.ts && npx rollup -c rollup.config.sdk-dts.mjs"
}
}
}
}

View File

@@ -0,0 +1,51 @@
import dts from 'rollup-plugin-dts';
const external = (id) => {
if (id === 'twenty-shared' || id.startsWith('twenty-shared/')) {
return false;
}
if (id.startsWith('@/')) {
return false;
}
return !id.startsWith('.') && !id.startsWith('/');
};
const plugins = [
dts({
tsconfig: './tsconfig.lib.json',
respectExternal: true,
}),
];
export default [
{
input: 'src/sdk/define/index.ts',
output: { file: 'dist/define/index.d.ts', format: 'es' },
external,
plugins,
},
{
input: 'src/sdk/front-component/index.ts',
output: { file: 'dist/front-component/index.d.ts', format: 'es' },
external,
plugins,
},
{
input: 'src/sdk/billing/index.ts',
output: { file: 'dist/billing/index.d.ts', format: 'es' },
external,
plugins,
},
{
input: 'src/sdk/logic-function/index.ts',
output: { file: 'dist/logic-function/index.d.ts', format: 'es' },
external,
plugins,
},
{
input: 'src/sdk/utils/index.ts',
output: { file: 'dist/utils/index.d.ts', format: 'es' },
external,
plugins,
},
];

View File

@@ -0,0 +1,15 @@
import { resolve } from 'path';
const FIXTURES_ROOT = resolve(__dirname, '../../../../../twenty-apps/fixtures');
export const RICH_APP_PATH = resolve(FIXTURES_ROOT, 'rich-app');
export const MINIMAL_APP_PATH = resolve(FIXTURES_ROOT, 'minimal-app');
export const INVALID_APP_PATH = resolve(FIXTURES_ROOT, 'invalid-app');
export const SHARED_DEPENDENCIES_APP_PATH = resolve(
FIXTURES_ROOT,
'shared-dependencies-app',
);
export const FUNCTION_EXECUTE_APP_PATH = resolve(
FIXTURES_ROOT,
'function-execute-app',
);

View File

@@ -0,0 +1,97 @@
import { vi } from 'vitest';
import { appBuild } from '@/cli/operations/build';
import { appDeploy } from '@/cli/operations/deploy';
import { appInstall } from '@/cli/operations/install';
import { appUninstall } from '@/cli/operations/uninstall';
import { functionExecute } from '@/cli/operations/execute';
import { FUNCTION_EXECUTE_APP_PATH } from '@/cli/__tests__/apps/fixture-paths';
const ADD_NUMBERS_UNIVERSAL_IDENTIFIER = 'f9e5589c-e951-4d99-85db-0a305ab53502';
const APP_PATH = FUNCTION_EXECUTE_APP_PATH;
describe('functionExecute E2E', () => {
beforeAll(async () => {
const buildResult = await appBuild({ appPath: APP_PATH, tarball: true });
if (!buildResult.success) {
throw new Error(
`appBuild failed: ${buildResult.error.code} – ${buildResult.error.message}`,
);
}
const deployResult = await appDeploy({
tarballPath: buildResult.data.tarballPath!,
});
if (!deployResult.success) {
throw new Error(
`appDeploy failed: ${deployResult.error.code} – ${deployResult.error.message}`,
);
}
const installResult = await appInstall({ appPath: APP_PATH });
if (!installResult.success) {
throw new Error(
`appInstall failed: ${installResult.error.code} – ${installResult.error.message}`,
);
}
await vi.waitFor(
async () => {
const result = await functionExecute({
appPath: APP_PATH,
functionName: 'add-numbers',
payload: { a: 0, b: 0 },
});
expect(result.success).toBe(true);
},
{ timeout: 30_000, interval: 1_000 },
);
}, 60_000);
afterAll(async () => {
await appUninstall({ appPath: APP_PATH });
}, 30_000);
it('should execute a function by name and return the computed result', async () => {
const result = await functionExecute({
appPath: APP_PATH,
functionName: 'add-numbers',
payload: { a: 3, b: 7 },
});
expect(result).toMatchObject({
success: true,
data: { functionName: 'add-numbers', status: 'SUCCESS', data: 10 },
});
});
it('should execute a function by universalIdentifier', async () => {
const result = await functionExecute({
appPath: APP_PATH,
functionUniversalIdentifier: ADD_NUMBERS_UNIVERSAL_IDENTIFIER,
payload: { a: 100, b: 200 },
});
expect(result).toMatchObject({
success: true,
data: { functionName: 'add-numbers', status: 'SUCCESS', data: 300 },
});
});
it('should return FUNCTION_NOT_FOUND for a non-existent function name', async () => {
const result = await functionExecute({
appPath: APP_PATH,
functionName: 'non-existent-function',
});
expect(result).toMatchObject({
success: false,
error: { code: 'FUNCTION_NOT_FOUND' },
});
});
});

View File

@@ -0,0 +1,27 @@
import { join } from 'path';
import { OUTPUT_DIR } from 'twenty-shared/application';
import { runAppDevInProcess } from '@/cli/__tests__/integration/utils/run-app-dev-in-process.util';
import { pathExists } from '@/cli/utilities/file/fs-utils';
import { INVALID_APP_PATH } from '@/cli/__tests__/apps/fixture-paths';
const MANIFEST_OUTPUT_PATH = join(
INVALID_APP_PATH,
OUTPUT_DIR,
'manifest.json',
);
describe('invalid-app manifest', () => {
it('should fail to build manifest due to duplicate universalIdentifier', async () => {
const result = await runAppDevInProcess({
appPath: INVALID_APP_PATH,
timeout: 10000,
});
expect(result.success).toBe(false);
const manifestExists = await pathExists(MANIFEST_OUTPUT_PATH);
expect(manifestExists).toBe(false);
}, 30000);
});

View File

@@ -0,0 +1,135 @@
import { readdir } from 'node:fs/promises';
import { join } from 'path';
import { type Manifest, OUTPUT_DIR } from 'twenty-shared/application';
import { MINIMAL_APP_PATH } from '@/cli/__tests__/apps/fixture-paths';
import { EXPECTED_MANIFEST } from '@/cli/__tests__/apps/minimal-app/__integration__/app-dev/expected-manifest';
import { normalizeManifestForComparison } from '@/cli/__tests__/integration/utils/normalize-manifest.util';
import { appDevOnce, type AppDevOnceResult } from '@/cli/operations/dev-once';
import { type CommandResult } from '@/cli/types';
import { pathExists, readJson, remove } from '@/cli/utilities/file/fs-utils';
const OUTPUT_PATH = join(MINIMAL_APP_PATH, OUTPUT_DIR);
const MANIFEST_PATH = join(OUTPUT_PATH, 'manifest.json');
describe('minimal-app dev-once', () => {
let result: CommandResult<AppDevOnceResult>;
beforeAll(async () => {
// Make sure we are starting from a clean slate so we know the
// generated files come from this run, not a previous one.
await remove(OUTPUT_PATH);
result = await appDevOnce({ appPath: MINIMAL_APP_PATH });
if (!result.success) {
throw new Error(
`appDevOnce did not succeed: ${result.error.code} - ${result.error.message}`,
);
}
}, 60000);
describe('result', () => {
it('should return success', () => {
expect(result.success).toBe(true);
});
it('should report the application display name from the manifest', () => {
if (!result.success) {
throw new Error('expected success');
}
expect(result.data.applicationDisplayName).toBe(
EXPECTED_MANIFEST.application.displayName,
);
});
it('should report the application universal identifier', () => {
if (!result.success) {
throw new Error('expected success');
}
expect(result.data.applicationUniversalIdentifier).toBe(
EXPECTED_MANIFEST.application.universalIdentifier,
);
});
it('should report a non-zero file count', () => {
if (!result.success) {
throw new Error('expected success');
}
expect(result.data.fileCount).toBeGreaterThan(0);
});
it('should report the output directory', () => {
if (!result.success) {
throw new Error('expected success');
}
expect(result.data.outputDir).toBe(OUTPUT_PATH);
});
});
describe('manifest', () => {
it('should have generated manifest.json', async () => {
expect(await pathExists(MANIFEST_PATH)).toBe(true);
});
it('should write the same manifest content as `dev`', async () => {
const manifest = normalizeManifestForComparison(
await readJson<Manifest>(MANIFEST_PATH),
);
expect(manifest).toEqual(
normalizeManifestForComparison(EXPECTED_MANIFEST),
);
});
});
describe('built files', () => {
it('should have built the logic function', async () => {
const files = (await readdir(OUTPUT_PATH, { recursive: true })).map(
(file) => file.toString(),
);
expect(
files
.filter(
(file) =>
file.endsWith('.function.mjs') ||
file.endsWith('.function.mjs.map'),
)
.sort(),
).toEqual(['my.function.mjs', 'my.function.mjs.map']);
});
it('should have built the front component', async () => {
const files = (await readdir(OUTPUT_PATH, { recursive: true })).map(
(file) => file.toString(),
);
expect(
files
.filter(
(file) =>
file.endsWith('.front-component.mjs') ||
file.endsWith('.front-component.mjs.map'),
)
.sort(),
).toEqual(['my.front-component.mjs', 'my.front-component.mjs.map']);
});
});
describe('source files', () => {
it('should include the logic function source', async () => {
expect(await pathExists(join(OUTPUT_PATH, 'my.function.ts'))).toBe(true);
});
it('should include the front component source', async () => {
expect(
await pathExists(join(OUTPUT_PATH, 'my.front-component.tsx')),
).toBe(true);
});
});
});

View File

@@ -0,0 +1,28 @@
import { runAppDevInProcess } from '@/cli/__tests__/integration/utils/run-app-dev-in-process.util';
import { MINIMAL_APP_PATH } from '@/cli/__tests__/apps/fixture-paths';
import { defineFrontComponentsTests } from './tests/front-components.tests';
import { defineLogicFunctionsTests } from './tests/logic-functions.tests';
import { defineManifestTests } from './tests/manifest.tests';
describe('minimal-app dev', () => {
beforeAll(async () => {
const result = await runAppDevInProcess({ appPath: MINIMAL_APP_PATH });
if (!result.success) {
const diagnostics = JSON.stringify(
{ events: result.events, stepStatuses: result.stepStatuses },
null,
2,
);
throw new Error(
`dev did not produce manifest.json within timeout.\n${diagnostics}`,
);
}
}, 60000);
defineManifestTests(MINIMAL_APP_PATH);
defineLogicFunctionsTests(MINIMAL_APP_PATH);
defineFrontComponentsTests(MINIMAL_APP_PATH);
});

View File

@@ -0,0 +1,110 @@
import { FieldType } from '@/sdk/define';
import type { Manifest } from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
export const EXPECTED_MANIFEST: Manifest = {
commandMenuItems: [],
timelineActivityTypes: [],
application: {
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000001',
displayName: 'Root App',
description: 'An app with all entities at root level',
galleryImages: [],
defaultRoleUniversalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000002',
packageJsonChecksum: '[checksum]',
yarnLockChecksum: '[checksum]',
requiredServerVersionRange: null,
},
permissionFlags: [],
skills: [],
agents: [],
publicAssets: [],
indexes: [],
fields: [],
objects: [
{
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000030',
nameSingular: 'myNote',
namePlural: 'myNotes',
labelSingular: 'My note',
labelPlural: 'My notes',
description: 'A simple root-level object',
icon: 'IconNote',
labelIdentifierFieldMetadataUniversalIdentifier:
'e1e2e3e4-e5e6-4000-8000-000000000031',
fields: [
{
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000031',
type: FieldType.TEXT,
label: 'Title',
name: 'title',
},
{
defaultValue: null,
description: 'Name',
icon: 'IconAbc',
isNullable: true,
label: 'Name',
name: 'name',
type: FieldMetadataType.TEXT,
universalIdentifier: '9a7f457a-6c15-581e-8f28-e2c680f75f97',
},
],
},
],
logicFunctions: [
{
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000010',
name: 'my-function',
timeoutSeconds: 5,
httpRouteTriggerSettings: {
path: '/my-function',
httpMethod: 'GET',
isAuthRequired: false,
},
handlerName: 'default.config.handler',
sourceHandlerPath: 'my.function.ts',
builtHandlerPath: 'my.function.mjs',
builtHandlerChecksum: '[checksum]',
},
],
frontComponents: [
{
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000020',
name: 'my-component',
description: 'A root-level front component',
componentName: 'MyComponent',
sourceComponentPath: 'my.front-component.tsx',
builtComponentPath: 'my.front-component.mjs',
builtComponentChecksum: '[checksum]',
isHeadless: false,
usesSdkClient: false,
},
],
views: [],
viewFields: [],
navigationMenuItems: [],
pageLayouts: [],
pageLayoutTabs: [],
pageLayoutWidgets: [],
roles: [
{
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000040',
label: 'My role',
description: 'A simple root-level role',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: true,
canBeAssignedToApiKeys: false,
fieldPermissions: [],
objectPermissions: [],
rowLevelPermissionPredicateGroups: [],
rowLevelPermissionPredicates: [],
permissionFlagUniversalIdentifiers: [],
},
],
};

View File

@@ -0,0 +1,70 @@
import { MINIMAL_APP_PATH } from '@/cli/__tests__/apps/fixture-paths';
import { mockApiService } from '@/cli/__tests__/integration/utils/setup-app-dev-mocks';
import { AppDevCommand } from '@/cli/commands/dev/dev';
const DEPENDENCIES_SIZE_ERROR_CODE =
'LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED';
const DEPENDENCIES_SIZE_ERROR_MESSAGE =
'Production dependencies are too large to install. Move packages that are not imported by your logic functions (UI libraries, dev tooling) out of "dependencies".';
describe('minimal-app dev sync error surfacing', () => {
it('should render the dependencies size error through the validation error report when the sync fails', async () => {
mockApiService.syncApplication.mockResolvedValue({
success: false,
error: {
summary: { totalErrors: 1, logicFunction: 1 },
errors: {
logicFunction: [
{
type: 'update',
metadataName: 'logicFunction',
errors: [
{
code: DEPENDENCIES_SIZE_ERROR_CODE,
message: DEPENDENCIES_SIZE_ERROR_MESSAGE,
value:
"Dependency layer 'deps-abc' exceeds the Lambda layer size limit: Unzipped size must be smaller than 262144000 bytes",
},
],
flatEntityMinimalInformation: {},
},
],
},
},
message: 'Validation failed for 1 logicFunction',
});
const command = new AppDevCommand();
await command.execute({ appPath: MINIMAL_APP_PATH, headless: true });
try {
const deadline = Date.now() + 30_000;
let syncStepStatus: string | undefined;
while (Date.now() < deadline) {
syncStepStatus = command.getOrchestrator()?.getState()?.steps
.syncApplication.status;
if (syncStepStatus === 'error') {
break;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
expect(syncStepStatus).toBe('error');
const eventMessages = (
command.getOrchestrator()?.getState()?.events ?? []
)
.map((event) => event.message)
.join('\n');
expect(eventMessages).toContain(DEPENDENCIES_SIZE_ERROR_CODE);
expect(eventMessages).toContain(DEPENDENCIES_SIZE_ERROR_MESSAGE);
} finally {
await command.close();
}
}, 60_000);
});

View File

@@ -0,0 +1,35 @@
import { type RunCliCommandResult } from '@/cli/__tests__/integration/utils/run-cli-command.util';
import { sanitizeAnsi } from '@/cli/__tests__/integration/utils/sanitize-ansi.util';
export const defineConsoleOutputTests = (
getResult: () => RunCliCommandResult,
): void => {
describe('console output', () => {
it('should contain init messages', () => {
const output = sanitizeAnsi(getResult().output);
expect(output).toContain('Application');
expect(output).toContain('Name: Loading...');
expect(output).toContain('Status: Idle');
});
it('should contain dev-mode build messages', () => {
const output = sanitizeAnsi(getResult().output);
expect(output).toContain('Building manifest');
expect(output).toContain('Successfully built manifest');
});
it('should contain dev-mode function build messages', () => {
const output = sanitizeAnsi(getResult().output);
expect(output).toContain('Successfully built');
});
it('should contain dev-mode sync messages', () => {
const output = sanitizeAnsi(getResult().output);
expect(output).toContain('✓ Synced');
});
});
};

View File

@@ -0,0 +1,20 @@
import { readdir } from 'node:fs/promises';
import { join } from 'path';
export const defineFrontComponentsTests = (appPath: string): void => {
describe('front-components', () => {
it('should have built front components at root level', async () => {
const outputDir = join(appPath, '.twenty/output');
const files = await readdir(outputDir, { recursive: true });
const componentFiles = files
.map((f) => f.toString())
.filter((f) => f.includes('.front-component.'))
.sort();
expect(componentFiles).toEqual([
'my.front-component.mjs',
'my.front-component.mjs.map',
]);
});
});
};

View File

@@ -0,0 +1,17 @@
import { readdir } from 'node:fs/promises';
import { join } from 'path';
export const defineLogicFunctionsTests = (appPath: string): void => {
describe('logicFunctions', () => {
it('should have built logicFunctions at root level', async () => {
const outputDir = join(appPath, '.twenty/output');
const files = await readdir(outputDir, { recursive: true });
const functionFiles = files
.map((f) => f.toString())
.filter((f) => f.includes('.function.'))
.sort();
expect(functionFiles).toEqual(['my.function.mjs', 'my.function.mjs.map']);
});
});
};

View File

@@ -0,0 +1,28 @@
import { join } from 'path';
import { type Manifest } from 'twenty-shared/application';
import { EXPECTED_MANIFEST } from '@/cli/__tests__/apps/minimal-app/__integration__/app-dev/expected-manifest';
import { normalizeManifestForComparison } from '@/cli/__tests__/integration/utils/normalize-manifest.util';
import { pathExists, readJson } from '@/cli/utilities/file/fs-utils';
export const defineManifestTests = (appPath: string): void => {
describe('manifest', () => {
it('should have generated manifest.json', async () => {
const manifestPath = join(appPath, '.twenty/output/manifest.json');
const exists = await pathExists(manifestPath);
expect(exists).toBe(true);
});
it('should have correct manifest content', async () => {
const manifestPath = join(appPath, '.twenty/output/manifest.json');
const manifest = normalizeManifestForComparison(
await readJson<Manifest>(manifestPath),
);
expect(manifest).toEqual(
normalizeManifestForComparison(EXPECTED_MANIFEST),
);
});
});
};

View File

@@ -0,0 +1,54 @@
import { OUTPUT_DIR } from 'twenty-shared/application';
import { existsSync } from 'fs';
import { join } from 'path';
import { inspect } from 'util';
import { runCliCommand } from '@/cli/__tests__/integration/utils/run-cli-command.util';
import { RICH_APP_PATH } from '@/cli/__tests__/apps/fixture-paths';
inspect.defaultOptions.depth = 10;
describe('Application: install delete and reinstall rich-app', () => {
const applicationName = 'rich-app';
const appPath = RICH_APP_PATH;
beforeAll(async () => {
expect(existsSync(appPath)).toBe(true);
const result = await runCliCommand({
command: 'remote',
args: ['status'],
timeout: 5_000,
waitForOutput: '(valid)',
});
expect(result.success).toBe(true);
});
it(`should successfully install ${applicationName} application`, async () => {
await runCliCommand({
command: 'dev',
args: [appPath],
waitForOutput: '✓ Synced',
});
expect(existsSync(join(appPath, OUTPUT_DIR, 'manifest.json'))).toBe(true);
});
it(`should successfully delete ${applicationName} application`, async () => {
await runCliCommand({
command: 'uninstall',
args: [appPath, '-y'],
waitForOutput: 'Application uninstalled successfully',
});
});
it(`should successfully re-install ${applicationName} application`, async () => {
await runCliCommand({
command: 'dev',
args: [appPath],
waitForOutput: '✓ Synced',
});
expect(existsSync(join(appPath, OUTPUT_DIR, 'manifest.json'))).toBe(true);
});
});

View File

@@ -0,0 +1,18 @@
import axios from 'axios';
import { getServerUrl } from '@/cli/__tests__/constants/server-url.constant';
describe('Twenty Server Health Check (E2E)', () => {
let healthEndpoint: string;
beforeAll(async () => {
const serverUrl = await getServerUrl();
healthEndpoint = `${serverUrl}/healthz`;
});
it('should return 200 for health', async () => {
const response = await axios.get(healthEndpoint);
expect(response.status).toBe(200);
expect(response.data).toBeDefined();
});
});

View File

@@ -0,0 +1,28 @@
import { runAppDevInProcess } from '@/cli/__tests__/integration/utils/run-app-dev-in-process.util';
import { RICH_APP_PATH } from '@/cli/__tests__/apps/fixture-paths';
import { defineEntitiesTests } from './tests/entities.tests';
import { defineFieldOptionIdsTests } from './tests/field-option-ids.tests';
import { defineManifestTests } from './tests/manifest.tests';
describe('rich-app dev', () => {
beforeAll(async () => {
const result = await runAppDevInProcess({ appPath: RICH_APP_PATH });
if (!result.success) {
const diagnostics = JSON.stringify(
{ events: result.events, stepStatuses: result.stepStatuses },
null,
2,
);
throw new Error(
`dev did not produce manifest.json within timeout.\n${diagnostics}`,
);
}
}, 60000);
defineManifestTests(RICH_APP_PATH);
defineEntitiesTests(RICH_APP_PATH);
defineFieldOptionIdsTests(RICH_APP_PATH);
});

View File

@@ -0,0 +1,35 @@
import { type RunCliCommandResult } from '@/cli/__tests__/integration/utils/run-cli-command.util';
import { sanitizeAnsi } from '@/cli/__tests__/integration/utils/sanitize-ansi.util';
export const defineConsoleOutputTests = (
getResult: () => RunCliCommandResult,
): void => {
describe('console output', () => {
it('should contain init messages', () => {
const output = sanitizeAnsi(getResult().output);
expect(output).toContain('Application');
expect(output).toContain('Name: Loading...');
expect(output).toContain('Status: Idle');
});
it('should contain dev-mode build messages', () => {
const output = sanitizeAnsi(getResult().output);
expect(output).toContain('Building manifest');
expect(output).toContain('Successfully built manifest');
});
it('should contain dev-mode function build messages', () => {
const output = sanitizeAnsi(getResult().output);
expect(output).toContain('Successfully built');
});
it('should contain dev-mode sync messages', () => {
const output = sanitizeAnsi(getResult().output);
expect(output).toContain('✓ Synced');
});
});
};

View File

@@ -0,0 +1,64 @@
import { readdir } from 'node:fs/promises';
import { join } from 'path';
import { OUTPUT_DIR } from 'twenty-shared/application';
export const defineEntitiesTests = (appPath: string): void => {
const outputDir = join(appPath, OUTPUT_DIR);
describe('logicFunctions', () => {
it('should have built logicFunctions preserving source path structure', async () => {
const files = await readdir(outputDir, { recursive: true });
// api-client is generated post-sync and depends on server schema availability
const sortedFiles = files
.map((f) => f.toString())
.filter((f) => !f.startsWith('api-client'))
.sort();
expect(sortedFiles).toEqual([
'manifest.json',
'package.json',
'public',
'public/favicon.png',
'src',
'src/components',
'src/components/card.front-component.mjs',
'src/components/card.front-component.mjs.map',
'src/components/greeting.front-component.mjs',
'src/components/greeting.front-component.mjs.map',
'src/components/test.front-component.mjs',
'src/components/test.front-component.mjs.map',
'src/logic-functions',
'src/logic-functions/enrich-post-cards.function.mjs',
'src/logic-functions/enrich-post-cards.function.mjs.map',
'src/logic-functions/greeting.function.mjs',
'src/logic-functions/greeting.function.mjs.map',
'src/logic-functions/lookup-recipient.function.mjs',
'src/logic-functions/lookup-recipient.function.mjs.map',
'src/logic-functions/on-post-card-created.function.mjs',
'src/logic-functions/on-post-card-created.function.mjs.map',
'src/logic-functions/test-function-2.function.mjs',
'src/logic-functions/test-function-2.function.mjs.map',
'src/logic-functions/test-function.function.mjs',
'src/logic-functions/test-function.function.mjs.map',
'src/root.front-component.mjs',
'src/root.front-component.mjs.map',
'src/root.function.mjs',
'src/root.function.mjs.map',
'yarn.lock',
]);
});
it('should not create shared chunk files for utilities', async () => {
const files = await readdir(outputDir, { recursive: true });
// Chunk files have a hash suffix like "greeting.util-CipJsYK0.mjs"
const chunkFiles = files
.map((f) => f.toString())
.filter((f) => f.endsWith('.mjs') && !f.includes('.function.mjs'))
.filter(
(f) => f.endsWith('.mjs') && !f.includes('.front-component.mjs'),
);
expect(chunkFiles).toEqual([]);
});
});
};

View File

@@ -0,0 +1,123 @@
import { join } from 'path';
import { readJson } from '@/cli/utilities/file/fs-utils';
import {
type FieldManifest,
type Manifest,
type ObjectFieldManifest,
} from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { validate as isUuid } from 'uuid';
const POST_CARD_OBJECT_UNIVERSAL_IDENTIFIER =
'54b589ca-eeed-4950-a176-358418b85c05';
const POST_CARD_STATUS_FIELD_UNIVERSAL_IDENTIFIER =
'87b675b8-dd8c-4448-b4ca-20e5a2234a1e';
const POST_CARD_CATEGORY_FIELD_UNIVERSAL_IDENTIFIER =
'b602dbd9-e511-49ce-b6d3-b697218dc69c';
export const defineFieldOptionIdsTests = (appPath: string): void => {
const manifestOutputPath = join(appPath, '.twenty/output/manifest.json');
let manifest: Manifest;
beforeAll(async () => {
manifest = await readJson<Manifest>(manifestOutputPath);
});
describe('select option id injection (object inline branch)', () => {
let statusField: ObjectFieldManifest | undefined;
beforeAll(() => {
const postCard = manifest.objects.find(
(object) =>
object.universalIdentifier === POST_CARD_OBJECT_UNIVERSAL_IDENTIFIER,
);
statusField = postCard?.fields.find(
(field) =>
field.universalIdentifier ===
POST_CARD_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
);
});
it('should find the inline status SELECT field with all 5 options', () => {
expect(statusField).toBeDefined();
expect(statusField?.type).toBe(FieldMetadataType.SELECT);
expect(statusField?.options).toHaveLength(5);
});
it('should give every option a defined, valid UUID id', () => {
for (const option of statusField?.options ?? []) {
expect(option.id).toBeDefined();
expect(typeof option.id).toBe('string');
expect(isUuid(option.id)).toBe(true);
}
});
});
describe('select option id injection (standalone field branch)', () => {
let categoryField: FieldManifest | undefined;
beforeAll(() => {
categoryField = manifest.fields.find(
(field) =>
field.universalIdentifier ===
POST_CARD_CATEGORY_FIELD_UNIVERSAL_IDENTIFIER,
);
});
it('should find the standalone category SELECT field with all 4 options', () => {
expect(categoryField).toBeDefined();
expect(categoryField?.type).toBe(FieldMetadataType.SELECT);
expect(categoryField?.options).toHaveLength(4);
});
it('should give every option a defined, valid UUID id', () => {
for (const option of categoryField?.options ?? []) {
expect(option.id).toBeDefined();
expect(typeof option.id).toBe('string');
expect(isUuid(option.id)).toBe(true);
}
});
it('should inject a fresh UUID for the OTHER option that has no source id', () => {
const otherOption = categoryField?.options?.find(
(option) => option.value === 'OTHER',
);
expect(otherOption).toBeDefined();
expect(otherOption?.id).toBeDefined();
expect(isUuid(otherOption?.id ?? '')).toBe(true);
});
});
describe('select option id injection (manifest-wide invariant)', () => {
it('should ensure every SELECT/MULTI_SELECT option in the manifest has a defined id', () => {
const collectSelectOptions = (
field: ObjectFieldManifest | FieldManifest,
) => {
if (
field.type !== FieldMetadataType.SELECT &&
field.type !== FieldMetadataType.MULTI_SELECT
) {
return [];
}
return field.options ?? [];
};
const allOptions = [
...manifest.objects.flatMap((object) =>
object.fields.flatMap(collectSelectOptions),
),
...manifest.fields.flatMap(collectSelectOptions),
];
expect(allOptions.length).toBeGreaterThan(0);
for (const option of allOptions) {
expect(option.id).toBeDefined();
expect(isUuid(option.id)).toBe(true);
}
});
});
};

View File

@@ -0,0 +1,78 @@
import { join } from 'path';
import { normalizeManifestForComparison } from '@/cli/__tests__/integration/utils/normalize-manifest.util';
import { readJson } from '@/cli/utilities/file/fs-utils';
import { type Manifest } from 'twenty-shared/application';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { EXPECTED_MANIFEST } from '../expected-manifest';
export const defineManifestTests = (appPath: string): void => {
const manifestOutputPath = join(appPath, '.twenty/output/manifest.json');
let manifest: Manifest;
beforeAll(async () => {
manifest = await readJson<Manifest>(manifestOutputPath);
});
describe('manifest', () => {
it('should build manifest matching expected JSON', () => {
expect(manifest).not.toBeNull();
expect(manifest.objects).toHaveLength(4);
expect(manifest.logicFunctions).toHaveLength(7);
expect(manifest.frontComponents).toHaveLength(4);
expect(manifest.roles).toHaveLength(2);
expect(manifest.fields).toHaveLength(7);
expect(manifest.views).toHaveLength(5);
expect(manifest.navigationMenuItems).toHaveLength(3);
expect(manifest.pageLayoutTabs).toHaveLength(1);
expect(manifest.pageLayoutWidgets).toHaveLength(1);
expect(normalizeManifestForComparison(manifest)).toEqual(
normalizeManifestForComparison(EXPECTED_MANIFEST),
);
});
});
describe('navigationMenuItems', () => {
it('should include all navigation menu items with correct types', () => {
expect(manifest.navigationMenuItems).toHaveLength(3);
for (const item of manifest.navigationMenuItems) {
expect(item.type).toBe(NavigationMenuItemType.OBJECT);
expect(item.universalIdentifier).toBeDefined();
expect(typeof item.position).toBe('number');
expect(item.targetObjectUniversalIdentifier).toBeDefined();
}
});
it('should have unique positions', () => {
const positions = manifest.navigationMenuItems.map(
(item) => item.position,
);
expect(new Set(positions).size).toBe(positions.length);
});
it('should have unique universal identifiers', () => {
const identifiers = manifest.navigationMenuItems.map(
(item) => item.universalIdentifier,
);
expect(new Set(identifiers).size).toBe(identifiers.length);
});
it('should reference valid object universal identifiers', () => {
const objectIdentifiers = new Set(
manifest.objects.map((obj) => obj.universalIdentifier),
);
for (const item of manifest.navigationMenuItems) {
expect(objectIdentifiers).toContain(
item.targetObjectUniversalIdentifier,
);
}
});
});
};

View File

@@ -0,0 +1,36 @@
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const currentDir = dirname(fileURLToPath(import.meta.url));
const packageRoot = resolve(currentDir, '../../../../..');
const bundledCliEntry = resolve(packageRoot, 'dist/cli.mjs');
const isBundleBuilt = existsSync(bundledCliEntry);
describe('CLI ESM bundle startup', () => {
const runOrSkip = isBundleBuilt || process.env.CI ? it : it.skip;
runOrSkip(
'runs `--help` from the bundled .mjs entry without a CJS-in-ESM crash',
() => {
if (!isBundleBuilt) {
throw new Error(
`Expected the built ESM entry at ${bundledCliEntry}. Run \`npx nx build twenty-sdk\` before this test.`,
);
}
const result = spawnSync('node', [bundledCliEntry, '--help'], {
encoding: 'utf8',
timeout: 30_000,
});
const combinedOutput = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
expect(combinedOutput).not.toMatch(/Calling `require` for/);
expect(combinedOutput).not.toMatch(/is not defined in ES module scope/);
expect(result.status).toBe(0);
expect(result.stdout).toContain('Usage: twenty');
},
);
});

View File

@@ -0,0 +1,7 @@
import { ConfigService } from '@/cli/utilities/config/config-service';
export const getServerUrl = async (): Promise<string> => {
const config = await new ConfigService().getConfig();
return config.apiUrl;
};

View File

@@ -0,0 +1,46 @@
import { writeFile } from 'node:fs/promises';
import * as path from 'path';
import { beforeAll } from 'vitest';
import { ensureDir } from '@/cli/utilities/file/fs-utils';
import { getConfigPath } from '@/cli/utilities/config/get-config-path';
const testConfigPath = getConfigPath(true);
beforeAll(async () => {
const apiUrl = process.env.TWENTY_API_URL;
const token = process.env.TWENTY_API_KEY;
if (!apiUrl || !token) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Run: twenty docker:start --test\n' +
'Or set them in vitest env config.',
);
}
const response = await fetch(`${apiUrl}/healthz`).catch(() => null);
if (!response?.ok) {
throw new Error(
`Twenty server not reachable at ${apiUrl}. ` +
'Run: twenty docker:start --test',
);
}
await ensureDir(path.dirname(testConfigPath));
await writeFile(
testConfigPath,
JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey: token },
},
defaultRemote: 'local',
},
null,
2,
),
);
});

View File

@@ -0,0 +1,13 @@
import { exec } from 'child_process';
export default async () =>
new Promise<void>((resolve) => {
exec('pkill -f "nest start" || true', (error: unknown) => {
if (error) {
console.log('No server processes to kill');
} else {
console.log('✅ Server processes cleaned up');
}
resolve();
});
});

View File

@@ -0,0 +1,23 @@
export type LogPrefix =
| 'init'
| 'dev-mode'
| 'manifest-watch'
| 'logicFunctions-watch'
| 'front-components-watch';
export const getOutputByPrefix = (
output: string,
prefix: LogPrefix,
): string => {
const prefixPattern = `[${prefix}]`;
const lines = output.split('\n');
return lines
.filter((line) => line.includes(prefixPattern))
.map((line) => {
// Remove ANSI color codes for snapshot comparison
const cleanLine = line.replace(/\x1B\[[0-9;]*m/g, '');
return cleanLine.trim();
})
.join('\n');
};

View File

@@ -0,0 +1,52 @@
import { type Manifest } from 'twenty-shared/application';
const sortById = <T extends { universalIdentifier: string }>(items: T[]): T[] =>
[...items].sort((a, b) =>
a.universalIdentifier.localeCompare(b.universalIdentifier),
);
export const normalizeManifestForComparison = <T extends Manifest>(
manifest: T,
): T => ({
...manifest,
application: {
...manifest.application,
yarnLockChecksum: manifest.application.yarnLockChecksum
? '[checksum]'
: null,
packageJsonChecksum: manifest.application.packageJsonChecksum
? '[checksum]'
: null,
},
objects: sortById(
manifest.objects.map((object) => ({
...object,
fields: sortById(object.fields),
})),
),
fields: sortById(manifest.fields),
roles: sortById(manifest.roles),
skills: sortById(manifest.skills),
agents: sortById(manifest.agents),
connectionProviders: sortById(manifest.connectionProviders ?? []),
views: sortById(manifest.views),
navigationMenuItems: sortById(manifest.navigationMenuItems),
pageLayouts: sortById(manifest.pageLayouts),
pageLayoutTabs: sortById(manifest.pageLayoutTabs ?? []),
pageLayoutWidgets: sortById(manifest.pageLayoutWidgets ?? []),
commandMenuItems: sortById(manifest.commandMenuItems ?? []),
logicFunctions: sortById(
manifest.logicFunctions?.map((fn) => ({
...fn,
builtHandlerChecksum: fn.builtHandlerChecksum ? '[checksum]' : null,
})),
),
frontComponents: sortById(
manifest.frontComponents?.map((component) => ({
...component,
builtComponentChecksum: component.builtComponentChecksum
? '[checksum]'
: '',
})),
),
});

View File

@@ -0,0 +1,52 @@
import { join } from 'path';
import { OUTPUT_DIR } from 'twenty-shared/application';
import { AppDevCommand } from '@/cli/commands/dev/dev';
import { pathExists } from '@/cli/utilities/file/fs-utils';
export type RunAppDevResult = {
success: boolean;
events?: { message: string; status: string }[];
stepStatuses?: Record<string, string>;
};
export const runAppDevInProcess = async (options: {
appPath: string;
timeout?: number;
}): Promise<RunAppDevResult> => {
const { appPath, timeout = 30_000 } = options;
const manifestPath = join(appPath, OUTPUT_DIR, 'manifest.json');
const command = new AppDevCommand();
await command.execute({ appPath, headless: true });
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
if (await pathExists(manifestPath)) {
await new Promise((resolve) => setTimeout(resolve, 500));
await command.close();
return { success: true };
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
const state = command.getOrchestrator()?.getState();
const events = state?.events.map((event) => ({
message: event.message,
status: event.status,
}));
const stepStatuses = state
? Object.fromEntries(
Object.entries(state.steps).map(([key, step]) => [key, step.status]),
)
: undefined;
await command.close();
return { success: false, events, stepStatuses };
};

View File

@@ -0,0 +1,88 @@
import { spawn, type ChildProcess } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
// CLI path and working directory (twenty-sdk src directory)
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const CLI_DIR = path.resolve(__dirname, '../../../..');
const CLI_PATH = path.resolve(CLI_DIR, 'cli/cli.ts');
export type RunCliCommandOptions = {
command: string;
args?: string[];
waitForOutput?: string | string[];
timeout?: number;
};
export type RunCliCommandResult = {
success: boolean;
output: string;
};
export const runCliCommand = (
options: RunCliCommandOptions,
): Promise<RunCliCommandResult> => {
const { command, args = [], waitForOutput, timeout = 30_000 } = options;
return new Promise((resolve) => {
// Run from CLI directory to use twenty-sdk's tsconfig paths
const child: ChildProcess = spawn(
'npx',
['tsx', CLI_PATH, command, ...args],
{
cwd: CLI_DIR,
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
FORCE_COLOR: '0',
},
},
);
let output = '';
const timeoutId = setTimeout(() => {
child.kill();
console.log(`RunCliCommand ${command} timeout after ${timeout / 1_000}s`);
resolve({ success: false, output });
}, timeout);
const waitForOutputs = Array.isArray(waitForOutput)
? waitForOutput
: waitForOutput
? [waitForOutput]
: [];
let isResolved = false;
child.stdout?.on('data', (data: Buffer) => {
output += data.toString();
if (
!isResolved &&
waitForOutputs.length > 0 &&
waitForOutputs.every((w) => output.includes(w))
) {
isResolved = true;
clearTimeout(timeoutId);
// Wait a bit before killing to allow any in-progress file writes to complete
setTimeout(() => {
child.kill();
resolve({ success: true, output });
}, 1500);
}
});
child.stderr?.on('data', (data: Buffer) => {
output += data.toString();
});
child.on('close', (code) => {
clearTimeout(timeoutId);
resolve({ success: code === 0, output });
});
child.on('error', () => {
clearTimeout(timeoutId);
resolve({ success: false, output });
});
});
};

View File

@@ -0,0 +1,2 @@
export const sanitizeAnsi = (output: string): string =>
output.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');

View File

@@ -0,0 +1,18 @@
export const sanitizeOutput = (output: string): string => {
return (
output
// Remove ANSI color codes
.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '')
// Normalize file paths (replace any absolute path to a test app)
.replace(/\/[^\s]+\/__tests__\/apps\/[^/]+/g, '<APP_PATH>')
// Normalize timestamps
.replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/g, '<TIMESTAMP>')
// Normalize durations
.replace(/\d+ms/g, '<DURATION>')
// Trim trailing whitespace from each line
.split('\n')
.map((line) => line.trimEnd())
.join('\n')
.trim()
);
};

View File

@@ -0,0 +1,82 @@
import { vi } from 'vitest';
export const mockApiService = {
validateAuth: vi.fn().mockResolvedValue({ authValid: true, serverUp: true }),
getWorkspaceFrontendUrl: vi.fn().mockResolvedValue('http://localhost:3000'),
generateApplicationToken: vi.fn().mockResolvedValue({
success: true,
data: {
accessToken: { token: 'mock-access-token', expiresAt: '' },
refreshToken: { token: 'mock-refresh-token', expiresAt: '' },
},
}),
refreshToken: vi.fn().mockResolvedValue('mock-renewed-access-token'),
findApplicationRegistrationByUniversalIdentifier: vi
.fn()
.mockResolvedValue({ success: true, data: null }),
createApplicationRegistration: vi.fn().mockResolvedValue({
success: true,
data: {
applicationRegistration: {
id: 'mock-registration-id',
oAuthClientId: 'mock-client-id',
},
clientSecret: 'mock-client-secret',
},
}),
createDevelopmentApplication: vi.fn().mockResolvedValue({
success: true,
data: { id: 'mock-app-id', universalIdentifier: 'mock-uid' },
}),
syncApplication: vi.fn().mockResolvedValue({ success: true, data: true }),
getSchema: vi
.fn()
.mockResolvedValue({ success: true, data: 'mock-core-schema' }),
};
vi.mock('@/cli/utilities/api/api-service', () => ({
ApiService: class {
validateAuth = mockApiService.validateAuth;
getWorkspaceFrontendUrl = mockApiService.getWorkspaceFrontendUrl;
generateApplicationToken = mockApiService.generateApplicationToken;
refreshToken = mockApiService.refreshToken;
findApplicationRegistrationByUniversalIdentifier =
mockApiService.findApplicationRegistrationByUniversalIdentifier;
createApplicationRegistration =
mockApiService.createApplicationRegistration;
createDevelopmentApplication = mockApiService.createDevelopmentApplication;
syncApplication = mockApiService.syncApplication;
getSchema = mockApiService.getSchema;
},
}));
vi.mock('@/cli/utilities/file/file-uploader', () => ({
FileUploader: class {
uploadFiles = vi.fn().mockResolvedValue([]);
},
}));
vi.mock('@/cli/utilities/auth', () => ({
ensureAppAccessTokenIsValidOrRefresh: vi
.fn()
.mockResolvedValue('mock-app-access-token'),
exchangeCredentialsForTokens: vi.fn().mockResolvedValue({
accessToken: 'mock-app-access-token',
refreshToken: 'mock-app-refresh-token',
}),
ensureAppRegistration: vi.fn().mockResolvedValue({
clientId: 'mock-client-id',
clientSecret: 'mock-client-secret',
isNewRegistration: true,
}),
}));
vi.mock('@/cli/utilities/client/client-service', () => ({
ClientService: class {
generateCoreClient = vi.fn().mockResolvedValue(undefined);
},
}));
vi.mock('@/cli/utilities/dev/ui/components/dev-ui', () => ({
renderDevUI: vi.fn().mockResolvedValue({ unmount: vi.fn() }),
}));

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env node
import { registerCommands } from '@/cli/commands';
import { ConfigService } from '@/cli/utilities/config/config-service';
import chalk from 'chalk';
import { Command, CommanderError } from 'commander';
import { inspect } from 'util';
import packageJson from '../../package.json';
inspect.defaultOptions.depth = 10;
const program = new Command();
program
.name('twenty')
.description('CLI for Twenty application development')
.version(packageJson.version);
program.option(
'-r, --remote <name>',
'Use a specific remote (overrides the default set by remote:use)',
);
program.hook('preAction', async (thisCommand) => {
const opts = (thisCommand as any).optsWithGlobals
? (thisCommand as any).optsWithGlobals()
: thisCommand.opts();
let remote = opts.remote;
if (!remote) {
const configService = new ConfigService();
remote = await configService.getDefaultRemote();
} else {
console.log(chalk.gray(`Using remote: ${remote}`));
}
ConfigService.setActiveRemote(remote);
});
registerCommands(program);
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,96 @@
import path from 'path';
import { appBuild } from '@/cli/operations/build';
import { appDeploy } from '@/cli/operations/deploy';
import { appPublish } from '@/cli/operations/publish';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import { readJson } from '@/cli/utilities/file/fs-utils';
import { checkSdkVersionCompatibility } from '@/cli/utilities/version/check-sdk-version-compatibility';
import chalk from 'chalk';
export type DeployCommandOptions = {
appPath?: string;
remote?: string;
private?: boolean;
tag?: string;
};
export class DeployCommand {
async execute(options: DeployCommandOptions): Promise<void> {
if (options.private) {
await this.executePrivate(options);
} else {
await this.executeNpm(options);
}
}
private async executeNpm(options: DeployCommandOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
await checkSdkVersionCompatibility(appPath);
console.log(chalk.blue('Publishing to npm...'));
console.log(chalk.gray(`App path: ${appPath}\n`));
const result = await appPublish({
appPath,
npmTag: options.tag,
onProgress: (message) => console.log(chalk.gray(message)),
});
if (!result.success) {
console.error(chalk.red(result.error.message));
process.exit(1);
}
console.log(chalk.green('✓ Published to npm successfully'));
}
private async executePrivate(options: DeployCommandOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
await checkSdkVersionCompatibility(appPath);
const remoteName = options.remote ?? ConfigService.getActiveRemote();
console.log(chalk.blue(`Deploying application on ${remoteName}...`));
console.log(chalk.gray(`App path: ${appPath}\n`));
const onProgress = (message: string) => console.log(chalk.gray(message));
const buildResult = await appBuild({
appPath,
tarball: true,
onProgress,
});
if (!buildResult.success) {
console.error(chalk.red(buildResult.error.message));
process.exit(1);
}
const result = await appDeploy({
tarballPath: buildResult.data.tarballPath!,
remote: options.remote,
onProgress,
});
if (!result.success) {
console.error(chalk.red(result.error.message));
process.exit(1);
}
const packageJson = await readJson<{ name?: string; version?: string }>(
path.join(appPath, 'package.json'),
).catch(() => undefined);
const appName = packageJson?.name ?? result.data.name;
const appVersion = packageJson?.version ?? 'unknown';
console.log(
chalk.green(`\n✓ Published ${appName} v${appVersion} to ${remoteName}\n`),
);
console.log(' To install deployed application: `yarn twenty app:install`');
}
}

View File

@@ -0,0 +1,56 @@
import { formatPath } from '@/cli/utilities/file/file-path';
import type { Command } from 'commander';
import { DeployCommand } from './deploy';
import { AppInstallCommand } from './install';
import { AppUninstallCommand } from './uninstall';
export const registerAppCommands = (program: Command): void => {
const deployCommand = new DeployCommand();
const installCommand = new AppInstallCommand();
const uninstallCommand = new AppUninstallCommand();
program
.command('app:publish [appPath]')
.description('Build and publish to npm (default) or server registry')
.option('--private', "Push to a Twenty server's registry instead of npm")
.option(
'-r, --remote <name>',
'Publish to a specific remote (with --private)',
)
.option('--tag <tag>', 'npm dist-tag (e.g. beta, next)')
.action(async (appPath, options) => {
await deployCommand.execute({
appPath: formatPath(appPath),
private: options.private,
remote: options.remote,
tag: options.tag,
});
});
program
.command('app:install [appPath]')
.description('Install a deployed app on the server')
.option('-r, --remote <name>', 'Install on a specific remote')
.action(async (appPath, options) => {
await installCommand.execute({
appPath: formatPath(appPath),
remote: options.remote,
});
});
program
.command('app:uninstall [appPath]')
.description('Uninstall app from server')
.option('-y, --yes', 'Skip confirmation prompt')
.action(async (appPath?: string, options?: { yes?: boolean }) => {
try {
const result = await uninstallCommand.execute({
appPath: formatPath(appPath),
askForConfirmation: !options?.yes,
});
process.exit(result.success ? 0 : 1);
} catch {
process.exit(1);
}
});
};

View File

@@ -0,0 +1,37 @@
import { appInstall } from '@/cli/operations/install';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import { checkSdkVersionCompatibility } from '@/cli/utilities/version/check-sdk-version-compatibility';
import { checkServerVersionCompatibility } from '@/cli/utilities/version/check-server-version-compatibility';
import chalk from 'chalk';
export type AppInstallCommandOptions = {
appPath?: string;
remote?: string;
};
export class AppInstallCommand {
async execute(options: AppInstallCommandOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
await checkSdkVersionCompatibility(appPath);
await checkServerVersionCompatibility();
const remoteName = options.remote ?? ConfigService.getActiveRemote();
console.log(chalk.blue(`Installing application on ${remoteName}...`));
console.log(chalk.gray(`App path: ${appPath}\n`));
const result = await appInstall({
appPath,
remote: options.remote,
});
if (!result.success) {
console.error(chalk.red(result.error.message));
process.exit(1);
}
console.log(chalk.green('✓ Application installed'));
}
}

View File

@@ -0,0 +1,33 @@
import { appPublish } from '@/cli/operations/publish';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import { checkSdkVersionCompatibility } from '@/cli/utilities/version/check-sdk-version-compatibility';
import chalk from 'chalk';
export type AppPublishCommandOptions = {
appPath?: string;
tag?: string;
};
export class AppPublishCommand {
async execute(options: AppPublishCommandOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
await checkSdkVersionCompatibility(appPath);
console.log(chalk.blue('Publishing to npm...'));
console.log(chalk.gray(`App path: ${appPath}\n`));
const result = await appPublish({
appPath,
npmTag: options.tag,
onProgress: (message) => console.log(chalk.gray(message)),
});
if (!result.success) {
console.error(chalk.red(result.error.message));
process.exit(1);
}
console.log(chalk.green('✓ Published to npm successfully'));
}
}

View File

@@ -0,0 +1,49 @@
import { appUninstall } from '@/cli/operations/uninstall';
import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import chalk from 'chalk';
import inquirer from 'inquirer';
export class AppUninstallCommand {
async execute({
appPath = CURRENT_EXECUTION_DIRECTORY,
askForConfirmation,
}: {
appPath?: string;
askForConfirmation: boolean;
}): Promise<ApiResponse<any>> {
const remoteName = ConfigService.getActiveRemote();
console.log(chalk.blue(`🚀 Uninstall Twenty Application on ${remoteName}`));
console.log(chalk.gray(`📁 App Path: ${appPath}\n`));
if (askForConfirmation && !(await this.confirmationPrompt())) {
console.error(chalk.red('⛔️ Aborting uninstall'));
process.exit(1);
}
const result = await appUninstall({ appPath });
if (!result.success) {
console.error(chalk.red('❌ Uninstall failed:'), result.error.message);
return { success: false, error: result.error.message };
}
console.log(chalk.green('✅ Application uninstalled successfully'));
return { success: true, data: undefined };
}
private async confirmationPrompt(): Promise<boolean> {
const { confirmation } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirmation',
message: 'Are you sure you want to uninstall this application?',
default: false,
},
]);
return confirmation;
}
}

View File

@@ -0,0 +1,200 @@
import { formatPath } from '@/cli/utilities/file/file-path';
import chalk from 'chalk';
import type { Command } from 'commander';
import type { SyncableEntity } from 'twenty-shared/application';
import { EntityAddCommand } from './dev/add';
import { AppBuildCommand } from './dev/build';
import { DeployCommand } from './app/deploy';
import { LogicFunctionExecuteCommand } from './dev/exec';
import { AppInstallCommand } from './app/install';
import { LogicFunctionLogsCommand } from './dev/logs';
import { AppTypecheckCommand } from './dev/typecheck';
import { AppUninstallCommand } from './app/uninstall';
const deprecate = (oldCmd: string, newCmd: string) =>
console.warn(
chalk.yellow(
`⚠ \`twenty ${oldCmd}\` is deprecated. Use \`twenty ${newCmd}\` instead.`,
),
);
export const registerDeprecatedCommands = (program: Command): void => {
const buildCommand = new AppBuildCommand();
const typecheckCommand = new AppTypecheckCommand();
const logsCommand = new LogicFunctionLogsCommand();
const executeCommand = new LogicFunctionExecuteCommand();
const addCommand = new EntityAddCommand();
const deployCommand = new DeployCommand();
const installCommand = new AppInstallCommand();
const uninstallCommand = new AppUninstallCommand();
program
.command('build [appPath]', { hidden: true })
.option('--tarball', 'Also pack into a .tgz tarball')
.action(
async (appPath: string | undefined, options: { tarball?: boolean }) => {
deprecate('build', 'dev:build');
await buildCommand.execute({
appPath: formatPath(appPath),
tarball: options.tarball,
});
},
);
program
.command('typecheck [appPath]', { hidden: true })
.action(async (appPath: string | undefined) => {
deprecate('typecheck', 'dev:typecheck');
await typecheckCommand.execute({
appPath: formatPath(appPath),
});
});
program
.command('logs [appPath]', { hidden: true })
.option(
'-u, --functionUniversalIdentifier <functionUniversalIdentifier>',
'Only show logs for the function with this universal ID',
)
.option(
'-n, --functionName <functionName>',
'Only show logs for the function with this name',
)
.action(
async (
appPath?: string,
options?: {
functionUniversalIdentifier?: string;
functionName?: string;
},
) => {
deprecate('logs', 'dev:function:logs');
await logsCommand.execute({
...options,
appPath: formatPath(appPath),
});
},
);
program
.command('exec [appPath]', { hidden: true })
.option('--postInstall', 'Execute post-install logic function if defined')
.option('--preInstall', 'Execute pre-install logic function if defined')
.option(
'-p, --payload <payload>',
'JSON payload to send to the function',
'{}',
)
.option(
'-u, --functionUniversalIdentifier <functionUniversalIdentifier>',
'Universal ID of the function to execute',
)
.option(
'-n, --functionName <functionName>',
'Name of the function to execute',
)
.action(
async (
appPath?: string,
options?: {
postInstall?: boolean;
preInstall?: boolean;
payload?: string;
functionUniversalIdentifier?: string;
functionName?: string;
},
) => {
deprecate('exec', 'dev:function:exec');
if (
!options?.postInstall &&
!options?.preInstall &&
!options?.functionUniversalIdentifier &&
!options?.functionName
) {
console.error(
chalk.red(
'Error: Either --postInstall, --preInstall, --functionName (-n), or --functionUniversalIdentifier (-u) is required.',
),
);
process.exit(1);
}
await executeCommand.execute({
...options,
payload: options?.payload ?? '{}',
appPath: formatPath(appPath),
});
},
);
program
.command('add [entityType]', { hidden: true })
.option('--path <path>', 'Path in which the entity should be created.')
.action(async (entityType?: string, options?: { path?: string }) => {
deprecate('add', 'dev:add');
await addCommand.execute(entityType as SyncableEntity, options?.path);
});
program
.command('publish [appPath]', { hidden: true })
.option('--tag <tag>', 'npm dist-tag (e.g. beta, next)')
.action(async (appPath: string | undefined, options: { tag?: string }) => {
deprecate('publish', 'app:publish');
await deployCommand.execute({
appPath: formatPath(appPath),
tag: options.tag,
});
});
program
.command('deploy [appPath]', { hidden: true })
.option('-r, --remote <name>', 'Deploy to a specific remote')
.action(
async (appPath: string | undefined, options: { remote?: string }) => {
deprecate('deploy', 'app:publish --private');
await deployCommand.execute({
appPath: formatPath(appPath),
private: true,
remote: options.remote,
});
},
);
program
.command('install [appPath]', { hidden: true })
.option('-r, --remote <name>', 'Install on a specific remote')
.action(
async (appPath: string | undefined, options: { remote?: string }) => {
deprecate('install', 'app:install');
await installCommand.execute({
appPath: formatPath(appPath),
remote: options.remote,
});
},
);
program
.command('uninstall [appPath]', { hidden: true })
.option('-y, --yes', 'Skip confirmation prompt')
.action(async (appPath?: string, options?: { yes?: boolean }) => {
deprecate('uninstall', 'app:uninstall');
try {
const result = await uninstallCommand.execute({
appPath: formatPath(appPath),
askForConfirmation: !options?.yes,
});
process.exit(result.success ? 0 : 1);
} catch {
process.exit(1);
}
});
program
.command('catalog-sync', { hidden: true })
.option('-r, --remote <name>', 'Sync on a specific remote')
.action(async (options: { remote?: string }) => {
deprecate('catalog-sync', 'dev:catalog-sync');
const { CatalogSyncCommand } = await import('./dev/catalog-sync');
const cmd = new CatalogSyncCommand();
await cmd.execute({ remote: options.remote });
});
};

View File

@@ -0,0 +1,851 @@
import chalk from 'chalk';
import inquirer from 'inquirer';
import { writeFile } from 'node:fs/promises';
import { join, relative } from 'path';
import { SyncableEntity } from 'twenty-shared/application';
import {
FieldMetadataType,
PageLayoutType,
RelationOnDeleteAction,
RelationType,
ViewType,
} from 'twenty-shared/types';
import { assertUnreachable } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { getApplicationUniversalIdentifierOrThrow } from '@/cli/utilities/application/get-application-universal-identifier-or-throw';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import { convertToLabel } from '@/cli/utilities/entity/entity-label';
import { appendServerVariablesToAppConfig } from '@/cli/utilities/file/append-server-variables.util';
import { getFieldBaseFile } from '@/cli/utilities/entity/entity-field-template';
import { getFrontComponentBaseFile } from '@/cli/utilities/entity/entity-front-component-template';
import { getLogicFunctionBaseFile } from '@/cli/utilities/entity/entity-logic-function-template';
import { getNavigationMenuItemBaseFile } from '@/cli/utilities/entity/entity-navigation-menu-item-template';
import { getObjectBaseFile } from '@/cli/utilities/entity/entity-object-template';
import { getCommandMenuItemBaseFile } from '@/cli/utilities/entity/entity-command-menu-item-template';
import { getPageLayoutBaseFile } from '@/cli/utilities/entity/entity-page-layout-template';
import { getPageLayoutTabBaseFile } from '@/cli/utilities/entity/entity-page-layout-tab-template';
import { getPageLayoutWidgetBaseFile } from '@/cli/utilities/entity/entity-page-layout-widget-template';
import { getRecordPageLayoutBaseFile } from '@/cli/utilities/entity/entity-record-page-layout-template';
import { getRoleBaseFile } from '@/cli/utilities/entity/entity-role-template';
import { getAgentBaseFile } from '@/cli/utilities/entity/entity-agent-template';
import { getConnectionProviderBaseFile } from '@/cli/utilities/entity/entity-connection-provider-template';
import { getSkillBaseFile } from '@/cli/utilities/entity/entity-skill-template';
import { getViewBaseFile } from '@/cli/utilities/entity/entity-view-template';
import { getViewFieldBaseFile } from '@/cli/utilities/entity/entity-view-field-template';
import { getTimelineActivityTypeBaseFile } from '@/cli/utilities/entity/entity-timeline-activity-type-template';
import { ensureDir, pathExists } from '@/cli/utilities/file/fs-utils';
import { kebabCase } from '@/cli/utilities/string/kebab-case';
const APP_CONFIG_HINT_PATH = 'src/application.config.ts';
const APP_FOLDER = 'src';
export class EntityAddCommand {
private lastObjectUniversalIdentifier: string | undefined;
private lastNameFieldUniversalIdentifier: string | undefined;
private lastObjectLabelSingular: string | undefined;
async execute(entityType?: SyncableEntity, path?: string): Promise<void> {
try {
// Generated files embed identifiers derived from the application
// universal identifier, so scaffolding is blocked until it is defined.
const applicationUniversalIdentifier =
await getApplicationUniversalIdentifierOrThrow(
CURRENT_EXECUTION_DIRECTORY,
);
const entity = entityType ?? (await this.getEntity());
const entityName = this.getFolderName(entity);
const appPath = path
? join(CURRENT_EXECUTION_DIRECTORY, path)
: join(CURRENT_EXECUTION_DIRECTORY, APP_FOLDER, entityName);
await ensureDir(appPath);
const { name, file } = await this.getEntityData(
entity,
applicationUniversalIdentifier,
);
const filePath = join(appPath, this.getFileName(name, entity));
if (await pathExists(filePath)) {
const { overwrite } = await this.handleFileExist();
if (!overwrite) {
return;
}
}
await writeFile(filePath, file);
console.log(
chalk.green(`✓ Created ${entityName}:`),
chalk.cyan(relative(CURRENT_EXECUTION_DIRECTORY, filePath)),
);
if (entity === SyncableEntity.Object) {
await this.promptAndCreateObjectCompanions({
objectName: name,
applicationUniversalIdentifier,
customPath: path,
});
}
if (entity === SyncableEntity.ConnectionProvider) {
await this.registerConnectionProviderServerVariables(name);
}
} catch (error) {
console.error(
chalk.red(`Add new entity failed:`),
error instanceof Error ? error.message : error,
);
process.exit(1);
}
}
private async getEntityData(
entity: SyncableEntity,
applicationUniversalIdentifier: string,
) {
switch (entity) {
case SyncableEntity.Object: {
const entityData = await this.getObjectData();
const name = entityData.nameSingular;
const objectUniversalIdentifier = v4();
const nameFieldUniversalIdentifier = v4();
this.lastObjectUniversalIdentifier = objectUniversalIdentifier;
this.lastNameFieldUniversalIdentifier = nameFieldUniversalIdentifier;
this.lastObjectLabelSingular = entityData.labelSingular;
const file = getObjectBaseFile({
data: entityData,
name,
universalIdentifier: objectUniversalIdentifier,
nameFieldUniversalIdentifier,
});
return { name, file };
}
case SyncableEntity.Field: {
const entityData = await this.getFieldData();
const name = entityData.name;
const file = getFieldBaseFile({
data: entityData,
name,
});
return { name, file };
}
case SyncableEntity.LogicFunction: {
const name = await this.getEntityName(entity);
const file = getLogicFunctionBaseFile({
name,
});
return { name, file };
}
case SyncableEntity.FrontComponent: {
const name = await this.getEntityName(entity);
const file = getFrontComponentBaseFile({
name,
});
return { name, file };
}
case SyncableEntity.Role: {
const name = await this.getEntityName(entity);
const file = getRoleBaseFile({
name,
});
return { name, file };
}
case SyncableEntity.Skill: {
const name = await this.getEntityName(entity);
const file = getSkillBaseFile({
name,
});
return { name, file };
}
case SyncableEntity.Agent: {
const name = await this.getEntityName(entity);
const file = getAgentBaseFile({
name,
});
return { name, file };
}
case SyncableEntity.ConnectionProvider: {
const name = await this.getEntityName(entity);
const file = getConnectionProviderBaseFile({
name,
});
return { name, file };
}
case SyncableEntity.View: {
const entityData = await this.getViewData();
const name = entityData.name;
const file = getViewBaseFile({
name,
applicationUniversalIdentifier,
});
return { name, file };
}
case SyncableEntity.ViewField: {
const name = await this.getEntityName(entity);
const file = getViewFieldBaseFile({});
return { name, file };
}
case SyncableEntity.NavigationMenuItem: {
const name = await this.getEntityName(entity);
const file = getNavigationMenuItemBaseFile({
name,
});
return { name, file };
}
case SyncableEntity.PageLayout: {
const name = await this.getEntityName(entity);
const { type } = await inquirer.prompt<{
type: PageLayoutType;
}>([
{
type: 'select',
name: 'type',
message: 'Select the page layout type:',
choices: Object.values(PageLayoutType),
},
]);
const file = getPageLayoutBaseFile({
name,
type,
});
return { name, file };
}
case SyncableEntity.PageLayoutTab: {
const name = await this.getEntityName(entity);
const file = getPageLayoutTabBaseFile({
name,
});
return { name, file };
}
case SyncableEntity.PageLayoutWidget: {
const name = await this.getEntityName(entity);
const file = getPageLayoutWidgetBaseFile({
name,
});
return { name, file };
}
case SyncableEntity.CommandMenuItem: {
const name = await this.getEntityName(entity);
const file = getCommandMenuItemBaseFile({
name,
});
return { name, file };
}
case SyncableEntity.TimelineActivityType: {
const name = await this.getEntityName(entity);
const file = getTimelineActivityTypeBaseFile({ name });
return { name, file };
}
default:
assertUnreachable(entity);
}
}
private async promptAndCreateObjectCompanions({
objectName,
applicationUniversalIdentifier,
customPath,
}: {
objectName: string;
applicationUniversalIdentifier: string;
customPath?: string;
}): Promise<void> {
const { createCompanions } = await inquirer.prompt<{
createCompanions: boolean;
}>([
{
type: 'confirm',
name: 'createCompanions',
message:
'Also create a view, navigation menu item, and record page layout for this object? (recommended)',
default: true,
},
]);
if (!createCompanions || !this.lastObjectUniversalIdentifier) {
return;
}
const viewUniversalIdentifier = v4();
const fieldsWidgetViewUniversalIdentifier = v4();
const viewFile = getViewBaseFile({
name: `all-${kebabCase(objectName)}`,
universalIdentifier: viewUniversalIdentifier,
objectUniversalIdentifier: this.lastObjectUniversalIdentifier,
applicationUniversalIdentifier,
fields: this.lastNameFieldUniversalIdentifier
? [
{
fieldMetadataUniversalIdentifier:
this.lastNameFieldUniversalIdentifier,
position: 0,
isVisible: true,
size: 200,
},
]
: [],
});
const viewFolderPath = customPath
? join(CURRENT_EXECUTION_DIRECTORY, customPath)
: join(
CURRENT_EXECUTION_DIRECTORY,
APP_FOLDER,
this.getFolderName(SyncableEntity.View),
);
await ensureDir(viewFolderPath);
const viewFileName = `all-${kebabCase(objectName)}.ts`;
const viewFilePath = join(viewFolderPath, viewFileName);
if (await pathExists(viewFilePath)) {
const { overwrite } = await this.handleFileExist();
if (!overwrite) {
return;
}
}
await writeFile(viewFilePath, viewFile);
console.log(
chalk.green(`✓ Created view:`),
chalk.cyan(relative(CURRENT_EXECUTION_DIRECTORY, viewFilePath)),
);
const recordPageFieldsViewFields = this.buildRecordPageFieldsViewFields(
this.lastNameFieldUniversalIdentifier,
);
const recordPageFieldsViewFile = getViewBaseFile({
name: `${kebabCase(objectName)}-record-page-fields`,
universalIdentifier: fieldsWidgetViewUniversalIdentifier,
objectUniversalIdentifier: this.lastObjectUniversalIdentifier,
applicationUniversalIdentifier,
type: ViewType.FIELDS_WIDGET,
fields: recordPageFieldsViewFields,
});
const recordPageFieldsViewFileName = `${kebabCase(objectName)}-record-page-fields.ts`;
const recordPageFieldsViewFilePath = join(
viewFolderPath,
recordPageFieldsViewFileName,
);
if (await pathExists(recordPageFieldsViewFilePath)) {
const { overwrite } = await this.handleFileExist();
if (!overwrite) {
return;
}
}
await writeFile(recordPageFieldsViewFilePath, recordPageFieldsViewFile);
console.log(
chalk.green(`✓ Created record-page-fields view:`),
chalk.cyan(
relative(CURRENT_EXECUTION_DIRECTORY, recordPageFieldsViewFilePath),
),
);
const navFile = getNavigationMenuItemBaseFile({
name: objectName,
type: 'OBJECT',
targetObjectUniversalIdentifier: this.lastObjectUniversalIdentifier,
});
const navFolderPath = customPath
? join(CURRENT_EXECUTION_DIRECTORY, customPath)
: join(
CURRENT_EXECUTION_DIRECTORY,
APP_FOLDER,
this.getFolderName(SyncableEntity.NavigationMenuItem),
);
await ensureDir(navFolderPath);
const navFileName = `${kebabCase(objectName)}.ts`;
const navFilePath = join(navFolderPath, navFileName);
if (await pathExists(navFilePath)) {
const { overwrite } = await this.handleFileExist();
if (!overwrite) {
return;
}
}
await writeFile(navFilePath, navFile);
console.log(
chalk.green(`✓ Created navigation menu item:`),
chalk.cyan(relative(CURRENT_EXECUTION_DIRECTORY, navFilePath)),
);
const recordPageLayoutFile = getRecordPageLayoutBaseFile({
objectLabelSingular: this.lastObjectLabelSingular ?? objectName,
objectUniversalIdentifier: this.lastObjectUniversalIdentifier,
fieldsWidgetViewUniversalIdentifier,
});
const pageLayoutFolderPath = customPath
? join(CURRENT_EXECUTION_DIRECTORY, customPath)
: join(
CURRENT_EXECUTION_DIRECTORY,
APP_FOLDER,
this.getFolderName(SyncableEntity.PageLayout),
);
await ensureDir(pageLayoutFolderPath);
const recordPageLayoutFileName = `${kebabCase(objectName)}-record-page-layout.ts`;
const recordPageLayoutFilePath = join(
pageLayoutFolderPath,
recordPageLayoutFileName,
);
if (await pathExists(recordPageLayoutFilePath)) {
const { overwrite } = await this.handleFileExist();
if (!overwrite) {
return;
}
}
await writeFile(recordPageLayoutFilePath, recordPageLayoutFile);
console.log(
chalk.green(`✓ Created record page layout:`),
chalk.cyan(
relative(CURRENT_EXECUTION_DIRECTORY, recordPageLayoutFilePath),
),
);
}
// Connection providers reference two serverVariables (`<NAME>_CLIENT_ID`
// and `<NAME>_CLIENT_SECRET`) that the dev needs to declare on
// `defineApplication.serverVariables`. Auto-append them so the dev
// doesn't have to remember the wiring after `twenty dev:add connection-provider`.
// The util is best-effort: it handles the common file shapes and falls
// back to a printed snippet for anything it can't safely modify.
private async registerConnectionProviderServerVariables(
name: string,
): Promise<void> {
const upperKey = kebabCase(name).toUpperCase().replace(/-/g, '_');
const variables = [
{
name: `${upperKey}_CLIENT_ID`,
description:
'OAuth client ID issued by the third-party provider. Filled in once by the server admin on the application registration.',
isSecret: false,
},
{
name: `${upperKey}_CLIENT_SECRET`,
description:
'OAuth client secret issued by the third-party provider. Stored encrypted; never echoed in API responses.',
isSecret: true,
},
];
const result = await appendServerVariablesToAppConfig({
projectRoot: CURRENT_EXECUTION_DIRECTORY,
variables,
});
switch (result.status) {
case 'appended':
case 'created': {
console.log(
chalk.green(
`✓ Added ${variables.map((v) => v.name).join(' + ')} to defineApplication.serverVariables:`,
),
chalk.cyan(relative(CURRENT_EXECUTION_DIRECTORY, result.file)),
);
break;
}
case 'skipped-existing':
console.log(
chalk.dim(
` (${variables.map((v) => v.name).join(' / ')} already declared on defineApplication.serverVariables)`,
),
);
break;
case 'skipped-no-config':
case 'skipped-no-app-call':
console.log(
chalk.yellow(
`! Couldn't auto-update ${APP_CONFIG_HINT_PATH}. Add these manually to defineApplication.serverVariables:\n` +
variables
.map(
(v) =>
` ${v.name}: { description: '...', isSecret: ${v.isSecret}, isRequired: true },`,
)
.join('\n'),
),
);
break;
}
}
private buildRecordPageFieldsViewFields(
nameFieldUniversalIdentifier: string | undefined,
) {
const autoGeneratedFieldNames = [
'createdAt',
'updatedAt',
'createdBy',
'updatedBy',
] as const;
const autoGeneratedFields = autoGeneratedFieldNames.map((fieldName) => ({
defaultFieldName: fieldName,
isVisible: true,
size: 200,
}));
const fields = nameFieldUniversalIdentifier
? [
{
fieldMetadataUniversalIdentifier: nameFieldUniversalIdentifier,
isVisible: true,
size: 200,
},
...autoGeneratedFields,
]
: autoGeneratedFields;
return fields.map((field, index) => ({ ...field, position: index }));
}
private async getEntity() {
const { entity } = await inquirer.prompt<{ entity: SyncableEntity }>([
{
type: 'select',
name: 'entity',
message: `What entity do you want to create?`,
default: '',
choices: Object.values(SyncableEntity),
},
]);
return entity;
}
private async handleFileExist() {
return await inquirer.prompt<{ overwrite: boolean }>([
{
type: 'confirm',
name: 'overwrite',
message: `File already exists. Do you want to overwrite it?`,
default: false,
},
]);
}
private async getEntityName(entity: SyncableEntity) {
const { name } = await inquirer.prompt<{ name: string }>([
{
type: 'input',
name: 'name',
message: `Enter a name for your new ${entity}:`,
default: '',
validate: (input) => {
if (input.length === 0) {
return `${entity} name is required`;
}
return true;
},
},
]);
return name;
}
private async getFieldData() {
const isRelationFieldType = (type: FieldMetadataType | undefined) =>
type === FieldMetadataType.RELATION ||
type === FieldMetadataType.MORPH_RELATION;
return inquirer.prompt<{
name: string;
label: string;
type: FieldMetadataType;
objectUniversalIdentifier: string;
description: string;
relationTargetObjectMetadataUniversalIdentifier?: string;
relationTargetFieldMetadataUniversalIdentifier?: string;
relationType?: RelationType;
onDelete?: RelationOnDeleteAction;
}>([
{
type: 'input',
name: 'name',
message: 'Enter a name for your field:',
default: '',
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'input',
name: 'label',
message: 'Enter a label for your field:',
default: (answers: any) => {
return convertToLabel(answers.name);
},
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'select',
name: 'type',
message: 'Select the field type:',
choices: Object.values(FieldMetadataType),
default: FieldMetadataType.TEXT,
},
{
type: 'input',
name: 'objectUniversalIdentifier',
message:
'Enter the universalIdentifier of the object this field belongs to:',
default: 'fill-later',
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'input',
name: 'description',
message: 'Enter a description for your field (optional):',
default: '',
},
{
type: 'input',
name: 'relationTargetObjectMetadataUniversalIdentifier',
message:
'Enter the universalIdentifier of the target object of the relation:',
default: 'fill-later',
when: (answers) => isRelationFieldType(answers.type),
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'input',
name: 'relationTargetFieldMetadataUniversalIdentifier',
message:
'Enter the universalIdentifier of the target field on the related object:',
default: 'fill-later',
when: (answers) => isRelationFieldType(answers.type),
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'select',
name: 'relationType',
message: 'Select the relation type:',
choices: Object.values(RelationType),
default: RelationType.ONE_TO_MANY,
when: (answers) => isRelationFieldType(answers.type),
},
{
type: 'select',
name: 'onDelete',
message: 'Select the onDelete behavior:',
choices: Object.values(RelationOnDeleteAction),
default: RelationOnDeleteAction.CASCADE,
when: (answers) => isRelationFieldType(answers.type),
},
]);
}
private async getObjectData() {
return inquirer.prompt<{
nameSingular: string;
namePlural: string;
labelSingular: string;
labelPlural: string;
}>([
{
type: 'input',
name: 'nameSingular',
message: 'Enter a name singular for your object (eg: company):',
default: '',
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'input',
name: 'namePlural',
message: 'Enter a name plural for your object (eg: companies):',
default: '',
validate: (input: string, answers?: any) => {
if (input.trim() === answers?.nameSingular.trim()) {
return 'Name plural must be different from name singular';
}
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'input',
name: 'labelSingular',
message: 'Enter a label singular for your object:',
default: (answers: any) => {
return convertToLabel(answers.nameSingular);
},
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'input',
name: 'labelPlural',
message: 'Enter a label plural for your object:',
default: (answers: any) => {
return convertToLabel(answers.namePlural);
},
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
]);
}
private async getViewData() {
return inquirer.prompt<{
name: string;
objectUniversalIdentifier: string;
}>([
{
type: 'input',
name: 'name',
message: 'Enter a name for your view:',
default: '',
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'input',
name: 'objectUniversalIdentifier',
message:
'Enter the universalIdentifier of the object this view belongs to:',
default: 'fill-later',
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
]);
}
getFolderName(entity: SyncableEntity) {
return `${kebabCase(entity)}s`;
}
getFileName(name: string, entity: SyncableEntity) {
switch (entity) {
case SyncableEntity.FrontComponent: {
return `${kebabCase(name)}.tsx`;
}
default: {
return `${kebabCase(name)}.ts`;
}
}
}
}

View File

@@ -0,0 +1,42 @@
import { appBuild } from '@/cli/operations/build';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import { checkSdkVersionCompatibility } from '@/cli/utilities/version/check-sdk-version-compatibility';
import chalk from 'chalk';
export type AppBuildCommandOptions = {
appPath?: string;
tarball?: boolean;
};
export class AppBuildCommand {
async execute(options: AppBuildCommandOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
await checkSdkVersionCompatibility(appPath);
console.log(chalk.blue('Building application...'));
console.log(chalk.gray(`App path: ${appPath}\n`));
const result = await appBuild({
appPath,
tarball: options.tarball,
onProgress: (message) => console.log(chalk.gray(message)),
});
if (!result.success) {
console.error(chalk.red(result.error.message));
process.exit(1);
}
console.log(
chalk.green(
`✓ Build succeeded (${result.data.fileCount} file${result.data.fileCount === 1 ? '' : 's'})`,
),
);
console.log(chalk.gray(`Output: ${result.data.outputDir}`));
if (result.data.tarballPath) {
console.log(chalk.gray(`Tarball: ${result.data.tarballPath}`));
}
}
}

View File

@@ -0,0 +1,34 @@
import { ApiService } from '@/cli/utilities/api/api-service';
import { ConfigService } from '@/cli/utilities/config/config-service';
import chalk from 'chalk';
export type CatalogSyncCommandOptions = {
remote?: string;
};
export class CatalogSyncCommand {
async execute(options: CatalogSyncCommandOptions): Promise<void> {
if (options.remote) {
ConfigService.setActiveRemote(options.remote);
}
const remoteName = ConfigService.getActiveRemote();
console.log(chalk.blue(`Syncing marketplace catalog on ${remoteName}...`));
const apiService = new ApiService();
const result = await apiService.syncMarketplaceCatalog();
if (!result.success) {
console.error(
chalk.red(
`Catalog sync failed: ${result.error instanceof Error ? result.error.message : String(result.error)}`,
),
);
process.exit(1);
}
console.log(chalk.green('✓ Marketplace catalog synced successfully'));
}
}

View File

@@ -0,0 +1,98 @@
import { appDevOnce } from '@/cli/operations/dev-once';
import { APP_ERROR_CODES } from '@/cli/types';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import { confirmDestructiveApply } from '@/cli/utilities/dev/confirm-destructive-apply';
import { hasPullBaseFile } from '@/cli/utilities/pull/pull-base-file';
import { checkSdkVersionCompatibility } from '@/cli/utilities/version/check-sdk-version-compatibility';
import chalk from 'chalk';
export type AppDevOnceCommandOptions = {
appPath?: string;
verbose?: boolean;
apply?: boolean;
force?: boolean;
inferDeletionFromMissingEntities?: boolean;
};
const warnWhenDeletingFromAPulledProject = async (
appPath: string,
): Promise<void> => {
if (!(await hasPullBaseFile({ appPath }))) {
return;
}
console.log(
chalk.yellow(
'This project was written by `twenty pull`, which is experimental and cannot export every entity yet.',
),
);
console.log(
chalk.yellow(
'Entities it could not write are missing from your source, so applying will destroy them in the workspace.',
),
);
console.log(chalk.yellow('Re-run with --no-delete to keep them.\n'));
};
export class AppDevOnceCommand {
async execute(options: AppDevOnceCommandOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
const apply = options.apply ?? false;
await checkSdkVersionCompatibility(appPath);
const remoteName = ConfigService.getActiveRemote();
console.log(
chalk.blue(
`${apply ? 'Applying application manifest' : 'Planning application apply'} on ${remoteName}...`,
),
);
console.log(chalk.gray(`App path: ${appPath}\n`));
if (apply && options.inferDeletionFromMissingEntities !== false) {
await warnWhenDeletingFromAPulledProject(appPath);
}
const result = await appDevOnce({
appPath,
verbose: options.verbose,
apply,
force: options.force,
inferDeletionFromMissingEntities:
options.inferDeletionFromMissingEntities,
onProgress: (message) => console.log(chalk.gray(message)),
onPlan: (text) => console.log(`\n${text}\n`),
confirmApply: (deleteCount) =>
confirmDestructiveApply(deleteCount, { force: options.force }),
});
if (!result.success) {
if (result.error.code === APP_ERROR_CODES.APPLY_ABORTED) {
console.log(chalk.yellow(result.error.message));
process.exit(1);
}
console.error(chalk.red(result.error.message));
process.exit(1);
}
if (!apply) {
console.log(
chalk.green(
`\n✓ Plan complete for ${result.data.applicationDisplayName} — no changes were applied`,
),
);
return;
}
console.log(
chalk.green(
`\n✓ Synced ${result.data.applicationDisplayName} (${result.data.fileCount} file${result.data.fileCount === 1 ? '' : 's'})`,
),
);
console.log(chalk.gray(`Output: ${result.data.outputDir}`));
}
}

View File

@@ -0,0 +1,107 @@
import { ApiService } from '@/cli/utilities/api/api-service';
import { promptForReauthentication } from '@/cli/utilities/auth/reauth-helper';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import { DevModeOrchestrator } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator';
import { OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
import { renderDevUI } from '@/cli/utilities/dev/ui/components/dev-ui';
import { DevUiStateManager } from '@/cli/utilities/dev/ui/dev-ui-state-manager';
import { checkSdkVersionCompatibility } from '@/cli/utilities/version/check-sdk-version-compatibility';
import { checkServerVersionCompatibility } from '@/cli/utilities/version/check-server-version-compatibility';
import { getVersionInfo } from '@/cli/utilities/version/get-version-info';
import chalk from 'chalk';
export type AppDevOptions = {
appPath?: string;
headless?: boolean;
verbose?: boolean;
debounceMs?: number;
force?: boolean;
inferDeletionFromMissingEntities?: boolean;
};
export class AppDevCommand {
private orchestrator: DevModeOrchestrator | null = null;
private unmountUI: (() => void) | null = null;
async close(): Promise<void> {
this.unmountUI?.();
await this.orchestrator?.close();
}
getOrchestrator(): DevModeOrchestrator | null {
return this.orchestrator;
}
private async ensureAuthenticatedBeforeLaunch(): Promise<void> {
const apiService = new ApiService({ disableInterceptors: true });
const { serverUp, authValid } = await apiService.validateAuth();
if (serverUp && !authValid) {
await promptForReauthentication(ConfigService.getActiveRemote());
}
}
async execute(options: AppDevOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
await checkSdkVersionCompatibility(appPath);
if (options.headless) {
await checkServerVersionCompatibility();
}
await this.ensureAuthenticatedBeforeLaunch();
const orchestratorState = new OrchestratorState({
appPath,
});
if (!options.headless) {
const uiStateManager = new DevUiStateManager(orchestratorState);
orchestratorState.onChange = () => uiStateManager.notify();
const { unmount } = await renderDevUI(uiStateManager, options.verbose);
this.unmountUI = unmount;
void getVersionInfo().then((info) =>
orchestratorState.setVersionInfo(info),
);
}
this.orchestrator = new DevModeOrchestrator({
state: orchestratorState,
verbose: options.verbose,
debounceMs: options.debounceMs,
force: options.force,
inferDeletionFromMissingEntities:
options.inferDeletionFromMissingEntities,
interactive: !options.headless && process.stdout.isTTY === true,
onExit: ({ code, message }) => {
if (options.headless) {
return;
}
void this.close().then(() => {
console.log(chalk.yellow(`\n${message}`));
process.exit(code);
});
},
});
await this.orchestrator.start();
if (!options.headless) {
this.setupGracefulShutdown();
}
}
private setupGracefulShutdown(): void {
const shutdown = () => void this.close().then(() => process.exit(0));
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
}

View File

@@ -0,0 +1,154 @@
import { functionExecute } from '@/cli/operations/execute';
import { APP_ERROR_CODES, FUNCTION_ERROR_CODES } from '@/cli/types';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import chalk from 'chalk';
import { isDefined } from 'twenty-shared/utils';
export class LogicFunctionExecuteCommand {
async execute({
appPath = CURRENT_EXECUTION_DIRECTORY,
postInstall = false,
preInstall = false,
uninstall = false,
functionUniversalIdentifier,
functionName,
payload = '{}',
}: {
appPath?: string;
postInstall?: boolean;
preInstall?: boolean;
uninstall?: boolean;
functionUniversalIdentifier?: string;
functionName?: string;
payload?: string;
}): Promise<void> {
let parsedPayload: Record<string, unknown>;
try {
parsedPayload = JSON.parse(payload);
} catch {
console.error(
chalk.red('Invalid JSON payload. Please provide valid JSON.'),
);
process.exit(1);
}
const identifier = postInstall
? 'post install'
: preInstall
? 'pre install'
: uninstall
? 'uninstall'
: (functionUniversalIdentifier ?? functionName);
const remoteName = ConfigService.getActiveRemote();
console.log(
chalk.blue(`🚀 Executing function "${identifier}" on ${remoteName}...`),
);
console.log(chalk.gray(` Payload: ${JSON.stringify(parsedPayload)}\n`));
const executeOptions = postInstall
? { appPath, postInstall: true as const, payload: parsedPayload }
: preInstall
? { appPath, preInstall: true as const, payload: parsedPayload }
: uninstall
? { appPath, uninstall: true as const, payload: parsedPayload }
: functionUniversalIdentifier
? { appPath, functionUniversalIdentifier, payload: parsedPayload }
: { appPath, functionName: functionName!, payload: parsedPayload };
const result = await functionExecute(executeOptions);
if (!result.success) {
switch (result.error.code) {
case APP_ERROR_CODES.MANIFEST_NOT_FOUND: {
console.error(chalk.red('Failed to build manifest.'));
break;
}
case FUNCTION_ERROR_CODES.FETCH_FUNCTIONS_FAILED: {
console.error(
chalk.red('Failed to fetch functions:'),
result.error.message,
);
break;
}
case FUNCTION_ERROR_CODES.FUNCTION_NOT_FOUND: {
console.error(chalk.red(result.error.message), '\n');
const availableFunctions = (result.error.details
?.availableFunctions ?? []) as Array<{
name: string;
universalIdentifier: string;
}>;
if (availableFunctions.length > 0) {
console.log(chalk.cyan('Available functions:'));
availableFunctions.forEach((logicFunction) => {
console.log(
` - ${chalk.white(logicFunction.name)} (${logicFunction.universalIdentifier})`,
);
});
} else {
console.log(
chalk.yellow(
'No functions found for this application. Have you synced your app with `twenty dev`?',
),
);
}
break;
}
case FUNCTION_ERROR_CODES.EXECUTION_FAILED: {
console.error(chalk.red('Execution failed:'), result.error.message);
break;
}
default: {
console.error(chalk.red(result.error.message));
}
}
process.exit(1);
}
const executionResult = result.data;
console.log(chalk.cyan('─'.repeat(60)));
console.log(chalk.cyan('Execution Result'));
console.log(chalk.cyan('─'.repeat(60)));
const statusColor =
executionResult.status === 'SUCCESS' ? chalk.green : chalk.red;
console.log(
`${chalk.bold('Status:')} ${statusColor(executionResult.status)}`,
);
console.log(`${chalk.bold('Duration:')} ${executionResult.duration}ms\n`);
if (isDefined(executionResult.data)) {
console.log(chalk.bold('Data:'));
console.log(chalk.white(JSON.stringify(executionResult.data, null, 2)));
}
if (executionResult.error) {
console.log(chalk.bold.red('Error:'));
console.log(chalk.red(` Type: ${executionResult.error.errorType}`));
console.log(
chalk.red(` Message: ${executionResult.error.errorMessage}\n`),
);
if (executionResult.error.stackTrace) {
console.log(chalk.gray('Stack trace:'));
console.log(chalk.gray(executionResult.error.stackTrace));
}
}
if (executionResult.logs) {
console.log(chalk.bold('Logs:'));
console.log(chalk.gray(executionResult.logs));
}
console.log(chalk.cyan('─'.repeat(60)));
if (executionResult.status !== 'SUCCESS') {
process.exit(1);
}
}
}

View File

@@ -0,0 +1,89 @@
import { formatPath } from '@/cli/utilities/file/file-path';
import chalk from 'chalk';
import type { Command } from 'commander';
import { LogicFunctionExecuteCommand } from '../exec';
import { LogicFunctionLogsCommand } from '../logs';
export const registerDevFunctionCommands = (program: Command): void => {
const logsCommand = new LogicFunctionLogsCommand();
const executeCommand = new LogicFunctionExecuteCommand();
program
.command('dev:function:logs [appPath]')
.description('Stream logic function logs')
.option(
'-u, --functionUniversalIdentifier <functionUniversalIdentifier>',
'Only show logs for the function with this universal ID',
)
.option(
'-n, --functionName <functionName>',
'Only show logs for the function with this name',
)
.action(
async (
appPath?: string,
options?: {
functionUniversalIdentifier?: string;
functionName?: string;
},
) => {
await logsCommand.execute({
...options,
appPath: formatPath(appPath),
});
},
);
program
.command('dev:function:exec [appPath]')
.description('Execute a logic function')
.option('--postInstall', 'Execute post-install logic function if defined')
.option('--preInstall', 'Execute pre-install logic function if defined')
.option('--uninstall', 'Execute uninstall logic function if defined')
.option(
'-p, --payload <payload>',
'JSON payload to send to the function',
'{}',
)
.option(
'-u, --functionUniversalIdentifier <functionUniversalIdentifier>',
'Universal ID of the function to execute',
)
.option(
'-n, --functionName <functionName>',
'Name of the function to execute',
)
.action(
async (
appPath?: string,
options?: {
postInstall?: boolean;
preInstall?: boolean;
uninstall?: boolean;
payload?: string;
functionUniversalIdentifier?: string;
functionName?: string;
},
) => {
if (
!options?.postInstall &&
!options?.preInstall &&
!options?.uninstall &&
!options?.functionUniversalIdentifier &&
!options?.functionName
) {
console.error(
chalk.red(
'Error: Either --postInstall, --preInstall, --uninstall, --functionName (-n), or --functionUniversalIdentifier (-u) is required.',
),
);
process.exit(1);
}
await executeCommand.execute({
...options,
payload: options?.payload ?? '{}',
appPath: formatPath(appPath),
});
},
);
};

View File

@@ -0,0 +1,71 @@
import { join } from 'path';
import { ApiService } from '@/cli/utilities/api/api-service';
import { ClientService } from '@/cli/utilities/client/client-service';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import { serializeError } from '@/cli/utilities/error/serialize-error';
import { pathExists } from '@/cli/utilities/file/fs-utils';
import chalk from 'chalk';
export type AppGenerateClientCommandOptions = {
appPath?: string;
};
export class AppGenerateClientCommand {
async execute(options: AppGenerateClientCommandOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
const clientSdkPath = join(appPath, 'node_modules', 'twenty-client-sdk');
if (!(await pathExists(clientSdkPath))) {
console.error(
chalk.red(
`Cannot find twenty-client-sdk in ${appPath}.\n\n` +
' Install it first:\n' +
' yarn add twenty-client-sdk',
),
);
process.exit(1);
}
const apiService = new ApiService({ disableInterceptors: true });
const validateAuth = await apiService.validateAuth();
if (!validateAuth.serverUp) {
console.error(
chalk.red(
'Cannot reach Twenty server.\n\n' +
' Check your remotes:\n' +
' yarn twenty remote:status',
),
);
process.exit(1);
}
if (!validateAuth.authValid) {
console.error(
chalk.red(
'Authentication failed. Run `yarn twenty remote:add` to authenticate.',
),
);
process.exit(1);
}
console.log(chalk.blue('Generating API client...'));
try {
const clientService = new ClientService({ skipAuth: false });
await clientService.generateCoreClient({ appPath });
} catch (error) {
console.error(
chalk.red(`Failed to generate API client: ${serializeError(error)}`),
);
process.exit(1);
}
console.log(chalk.green('✓ API client generated'));
console.log(
chalk.gray(`Output: ${join(clientSdkPath, 'dist', 'core', 'generated')}`),
);
}
}

View File

@@ -0,0 +1,240 @@
import { formatPath } from '@/cli/utilities/file/file-path';
import chalk from 'chalk';
import type { Command } from 'commander';
import { SyncableEntity } from 'twenty-shared/application';
import { EntityAddCommand } from './add';
import { AppBuildCommand } from './build';
import { AppDevCommand } from './dev';
import { AppDevOnceCommand } from './dev-once';
import { registerDevFunctionCommands } from './function';
import { AppGenerateClientCommand } from './generate-client';
import { AppPullCommand } from './pull';
import { AppTranslationsExtractCommand } from './translations-extract';
import { AppTypecheckCommand } from './typecheck';
export const registerDevCommands = (program: Command): void => {
const buildCommand = new AppBuildCommand();
const devCommand = new AppDevCommand();
const devOnceCommand = new AppDevOnceCommand();
const pullCommand = new AppPullCommand();
const typecheckCommand = new AppTypecheckCommand();
const addCommand = new EntityAddCommand();
const generateClientCommand = new AppGenerateClientCommand();
const translationsExtractCommand = new AppTranslationsExtractCommand();
const devAction = async (
appPath: string | undefined,
options: {
once?: boolean;
verbose?: boolean;
debug?: boolean;
debounceMs?: string;
dryRun?: boolean;
force?: boolean;
delete: boolean;
},
) => {
if (options.dryRun && !options.once) {
console.warn(
chalk.yellow(
'--dry-run only applies with --once. Ignoring it; run `yarn twenty plan` to preview changes.',
),
);
}
const verbose = options.verbose || options.debug;
if (options.once) {
console.warn(
chalk.yellow(
options.dryRun
? '⚠ `twenty dev --once --dry-run` is deprecated. Use `twenty plan` instead.'
: '⚠ `twenty dev --once` is deprecated. Use `twenty apply` instead.',
),
);
await devOnceCommand.execute({
appPath: formatPath(appPath),
verbose,
apply: !options.dryRun,
force: options.force,
inferDeletionFromMissingEntities: options.delete,
});
return;
}
await devCommand.execute({
appPath: formatPath(appPath),
verbose,
debounceMs: options.debounceMs
? parseInt(options.debounceMs, 10)
: undefined,
force: options.force,
inferDeletionFromMissingEntities: options.delete,
});
};
program
.command('dev [appPath]')
.description('Build and sync local changes')
.option(
'-o, --once',
'Build and sync once, then exit (deprecated: use `twenty apply`)',
)
.option(
'--dry-run',
'Preview the metadata changes without applying them (deprecated: use `twenty plan`)',
)
.option(
'-f, --force',
'Apply destructive changes (deletes) without confirmation',
)
.option(
'--no-delete',
'Keep entities that are missing from your source instead of deleting them',
)
.option('--debounceMs <ms>', 'Debounce in ms (default: 1 000)')
.option('-v, --verbose', 'Show detailed logs')
.option('-d, --debug', 'Show detailed logs (alias for --verbose)')
.action(devAction);
program
.command('plan [appPath]')
.description('Preview metadata changes without applying them')
.option(
'--no-delete',
'Keep entities that are missing from your source instead of deleting them',
)
.option('-v, --verbose', 'Show detailed logs')
.action(
async (
appPath: string | undefined,
options: { delete: boolean; verbose?: boolean },
) => {
await devOnceCommand.execute({
appPath: formatPath(appPath),
verbose: options.verbose,
apply: false,
inferDeletionFromMissingEntities: options.delete,
});
},
);
program
.command('apply [appPath]')
.description('Apply local metadata changes after showing the plan')
.option(
'-f, --force',
'Apply destructive changes (deletes) without confirmation',
)
.option(
'--no-delete',
'Keep entities that are missing from your source instead of deleting them',
)
.option('-v, --verbose', 'Show detailed logs')
.action(
async (
appPath: string | undefined,
options: { force?: boolean; delete: boolean; verbose?: boolean },
) => {
await devOnceCommand.execute({
appPath: formatPath(appPath),
verbose: options.verbose,
apply: true,
force: options.force,
inferDeletionFromMissingEntities: options.delete,
});
},
);
program
.command('pull [appPath]')
.description(
'Write the installed application back to local source files (experimental)',
)
.option(
'-u, --universal-identifier <id>',
'Universal identifier of the application to pull',
)
.option('-v, --verbose', 'Show detailed logs')
.action(
async (
appPath: string | undefined,
options: { universalIdentifier?: string; verbose?: boolean },
) => {
await pullCommand.execute({
appPath: formatPath(appPath),
universalIdentifier: options.universalIdentifier,
verbose: options.verbose,
});
},
);
program
.command('dev:build [appPath]')
.description('Build and generate API client')
.option('--tarball', 'Also pack into a .tgz tarball')
.action(async (appPath, options) => {
await buildCommand.execute({
appPath: formatPath(appPath),
tarball: options.tarball,
});
});
program
.command('dev:typecheck [appPath]')
.description('Run TypeScript type checking')
.action(async (appPath) => {
await typecheckCommand.execute({
appPath: formatPath(appPath),
});
});
program
.command('dev:add [entityType]')
.description(
`Scaffold a new entity (${Object.values(SyncableEntity).join('|')})`,
)
.option('--path <path>', 'Path in which the entity should be created.')
.action(async (entityType?: string, options?: { path?: string }) => {
await addCommand.execute(entityType as SyncableEntity, options?.path);
});
program
.command('dev:catalog-sync')
.description('Trigger marketplace catalog sync')
.option('-r, --remote <name>', 'Sync on a specific remote')
.action(async (options: { remote?: string }) => {
const { CatalogSyncCommand } = await import('./catalog-sync');
const cmd = new CatalogSyncCommand();
await cmd.execute({ remote: options.remote });
});
program
.command('dev:generate-client [appPath]')
.description(
'Generate the typed API client from the active remote (no app definition required)',
)
.action(async (appPath) => {
await generateClientCommand.execute({
appPath: formatPath(appPath),
});
});
program
.command('dev:translations-extract [appPath]')
.description('Extract translatable strings into locales/ catalogs')
.option(
'--locale <locale>',
'Scaffold an empty catalog for a target locale (e.g. fr-FR)',
)
.action(async (appPath, options) => {
await translationsExtractCommand.execute({
appPath: formatPath(appPath),
locale: options.locale,
});
});
registerDevFunctionCommands(program);
};

View File

@@ -0,0 +1,70 @@
import { ApiService } from '@/cli/utilities/api/api-service';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import chalk from 'chalk';
import { readManifestFromFile } from '@/cli/utilities/build/manifest/manifest-reader';
export class LogicFunctionLogsCommand {
private apiService = new ApiService();
async execute({
appPath = CURRENT_EXECUTION_DIRECTORY,
functionUniversalIdentifier,
functionName,
}: {
appPath?: string;
functionUniversalIdentifier?: string;
functionName?: string;
}): Promise<void> {
try {
const manifest = await readManifestFromFile(appPath);
if (!manifest) {
process.exit(1);
}
this.logWatchInfo({
appName: manifest.application.displayName,
functionUniversalIdentifier,
functionName,
});
await this.apiService.subscribeToLogs({
applicationUniversalIdentifier:
manifest.application.universalIdentifier,
functionUniversalIdentifier,
functionName,
});
} catch (error) {
console.error(
chalk.red('Watch logs failed:'),
error instanceof Error ? error.message : error,
);
process.exit(1);
}
}
private logWatchInfo({
appName,
functionUniversalIdentifier,
functionName,
}: {
appName?: string;
functionUniversalIdentifier?: string;
functionName?: string;
}): void {
const appPath = appName ?? 'Twenty Application';
const functionIdentifier =
functionUniversalIdentifier || functionName
? `function "${functionUniversalIdentifier || functionName}"`
: 'functions';
const remoteName = ConfigService.getActiveRemote();
console.log(
chalk.blue(
`🚀 Watching ${appPath} ${functionIdentifier} logs on ${remoteName}:\n`,
),
);
}
}

View File

@@ -0,0 +1,81 @@
import { appPull } from '@/cli/operations/pull';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import { formatPullReport } from '@/cli/utilities/pull/format-pull-report';
import { PULL_BASE_FILE_PATH } from '@/cli/utilities/pull/pull-base-file';
import { checkSdkVersionCompatibility } from '@/cli/utilities/version/check-sdk-version-compatibility';
import chalk from 'chalk';
export type AppPullCommandOptions = {
appPath?: string;
universalIdentifier?: string;
verbose?: boolean;
};
export class AppPullCommand {
async execute(options: AppPullCommandOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
await checkSdkVersionCompatibility(appPath);
const remoteName = ConfigService.getActiveRemote();
console.log(
chalk.yellow(
'⚠ pull is experimental\n' +
' It covers only part of an application, and it overwrites the files\n' +
' that define what it pulls. Commit your work before running it.\n',
),
);
console.log(chalk.blue(`Pulling application from ${remoteName}...`));
console.log(chalk.gray(`App path: ${appPath}\n`));
const result = await appPull({
appPath,
universalIdentifier: options.universalIdentifier,
onProgress: (message) => console.log(chalk.gray(message)),
});
if (!result.success) {
console.error(chalk.red(result.error.message));
process.exit(1);
}
const report = formatPullReport({
writes: result.data.writes,
deletions: result.data.deletions,
unchangedCount: result.data.unchangedCount,
skipped: result.data.skipped,
coverage: result.data.coverage,
localOnlyRelativePaths: result.data.localOnlyRelativePaths,
unreadableRelativePaths: result.data.unreadableRelativePaths,
compiledTranslationEntryCountByLocale:
result.data.compiledTranslationEntryCountByLocale,
verbose: options.verbose,
});
console.log(`\n${report}\n`);
console.log(
chalk.green(
`✓ Pulled ${result.data.applicationDisplayName} into ${appPath}`,
),
);
console.log(chalk.gray(`Base recorded in ${PULL_BASE_FILE_PATH}`));
if (result.data.isSdkResolvable) {
console.log(chalk.gray('Next: yarn twenty apply --no-delete'));
} else {
console.log(
chalk.yellow(
'twenty-sdk is not installed here, so the written files cannot build yet.',
),
);
console.log(
chalk.gray(
'Next: install your dependencies, then yarn twenty apply --no-delete',
),
);
}
}
}

View File

@@ -0,0 +1,84 @@
import path from 'path';
import chalk from 'chalk';
import { buildAndValidateManifest } from '@/cli/utilities/build/manifest/build-and-validate-manifest';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import { extractApplicationTranslations } from '@/cli/utilities/translations/extract-application-translations';
import {
APP_LOCALES,
SOURCE_LOCALE,
type AppLocale,
} from 'twenty-shared/translations';
type AppTranslationsExtractOptions = {
appPath?: string;
locale?: string;
};
const isSupportedLocale = (locale: string): locale is AppLocale =>
Object.prototype.hasOwnProperty.call(APP_LOCALES, locale);
export class AppTranslationsExtractCommand {
async execute(options: AppTranslationsExtractOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
let scaffoldLocale: AppLocale | undefined;
if (options.locale !== undefined) {
if (
options.locale === SOURCE_LOCALE ||
!isSupportedLocale(options.locale)
) {
console.error(
chalk.red(
`"${options.locale}" is not a supported target locale. Choose one of: ${Object.keys(
APP_LOCALES,
)
.filter((locale) => locale !== SOURCE_LOCALE)
.join(', ')}`,
),
);
process.exit(1);
}
scaffoldLocale = options.locale;
}
console.log(chalk.blue('Extracting translatable strings...'));
const manifestResult = await buildAndValidateManifest(appPath);
if (!manifestResult.success) {
console.error(chalk.red(manifestResult.errors.join('\n')));
process.exit(1);
}
if (manifestResult.warnings.length > 0) {
console.warn(chalk.yellow(manifestResult.warnings.join('\n')));
}
const frontComponentSourcePaths =
manifestResult.filePaths.frontComponents.map((relativePath) =>
path.join(appPath, relativePath),
);
const { sourceCount, updatedLocaleFiles } =
await extractApplicationTranslations({
appPath,
manifest: manifestResult.manifest,
frontComponentSourcePaths,
scaffoldLocale,
});
console.log(
chalk.green(
`✓ Extracted ${sourceCount} string${sourceCount === 1 ? '' : 's'} to locales/`,
),
);
if (updatedLocaleFiles.length > 0) {
console.log(chalk.gray(`Updated: ${updatedLocaleFiles.join(', ')}`));
}
}
}

View File

@@ -0,0 +1,42 @@
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import {
runTypecheck,
type TypecheckError,
} from '@/cli/utilities/build/common/typecheck-plugin';
import chalk from 'chalk';
export type AppTypecheckOptions = {
appPath?: string;
};
const formatTypecheckError = (error: TypecheckError): string => {
return `${chalk.cyan(error.file)}:${chalk.yellow(String(error.line))}:${chalk.yellow(String(error.column + 1))} - ${chalk.red('error')} ${error.text}`;
};
export class AppTypecheckCommand {
async execute(options: AppTypecheckOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
console.log(chalk.blue('Running type check...'));
console.log(chalk.gray(`App path: ${appPath}\n`));
const errors = await runTypecheck(appPath);
if (errors.length === 0) {
console.log(chalk.green('✓ No type errors found'));
process.exit(0);
}
for (const error of errors) {
console.log(formatTypecheckError(error));
}
console.log(
'\n',
chalk.red(
`✗ Found ${errors.length} type error${errors.length === 1 ? '' : 's'}`,
),
);
process.exit(1);
}
}

View File

@@ -0,0 +1,299 @@
import { serverStart } from '@/cli/operations/server-start';
import { serverUpgrade } from '@/cli/operations/server-upgrade';
import {
CONTAINER_NAME,
containerExists,
DEFAULT_PORT,
DEFAULT_TEST_PORT,
getContainerEnvVar,
getContainerPort,
isContainerRunning,
TEST_CONTAINER_NAME,
} from '@/cli/utilities/server/docker-container';
import { checkServerHealth } from '@/cli/utilities/server/detect-local-server';
import chalk from 'chalk';
import type { Command } from 'commander';
import { execSync, spawnSync } from 'node:child_process';
const startAction = async (
version: string | undefined,
options: { port?: string; test?: boolean },
) => {
const defaultPort = options.test ? DEFAULT_TEST_PORT : DEFAULT_PORT;
const port = options.port ? parseInt(options.port, 10) : defaultPort;
if (isNaN(port) || port < 1 || port > 65535) {
console.error(chalk.red('Invalid port number.'));
process.exit(1);
}
const result = await serverStart({
port,
test: options.test,
version,
onProgress: (message) => console.log(chalk.gray(message)),
});
if (!result.success) {
console.error(chalk.red(result.error.message));
process.exit(1);
}
};
const stopAction = (options: { test?: boolean }) => {
const containerName = options.test ? TEST_CONTAINER_NAME : CONTAINER_NAME;
if (!containerExists(containerName)) {
console.log(chalk.yellow('No Twenty server container found.'));
return;
}
execSync(`docker stop ${containerName}`, { stdio: 'ignore' });
console.log(chalk.green('Twenty server stopped.'));
};
const logsAction = (options: { lines: string; test?: boolean }) => {
const containerName = options.test ? TEST_CONTAINER_NAME : CONTAINER_NAME;
if (!containerExists(containerName)) {
console.log(chalk.yellow('No Twenty server container found.'));
return;
}
try {
spawnSync(
'docker',
['logs', '-f', '--tail', options.lines, containerName],
{ stdio: 'inherit' },
);
} catch {
// User hit Ctrl-C
}
};
const statusAction = async (options: { test?: boolean }) => {
const containerName = options.test ? TEST_CONTAINER_NAME : CONTAINER_NAME;
const defaultPort = options.test ? DEFAULT_TEST_PORT : DEFAULT_PORT;
if (!containerExists(containerName)) {
console.log(` Status: ${chalk.gray('not created')}`);
console.log(
chalk.gray(
` Run 'yarn twenty docker:start${options.test ? ' --test' : ''}' to create one.`,
),
);
return;
}
const running = isContainerRunning(containerName);
const port = running ? getContainerPort(containerName) : defaultPort;
const healthy = running ? await checkServerHealth(port) : false;
const statusText = healthy
? chalk.green('running (healthy)')
: running
? chalk.yellow('running (starting...)')
: chalk.gray('stopped');
const appVersion = getContainerEnvVar('APP_VERSION', containerName);
console.log(` Status: ${statusText}`);
console.log(` URL: http://localhost:${port}`);
if (appVersion) {
console.log(` Version: ${chalk.gray(appVersion)}`);
}
if (healthy) {
console.log(chalk.gray(' Login: tim@apple.dev / tim@apple.dev'));
}
};
const resetAction = (options: { test?: boolean }) => {
const containerName = options.test ? TEST_CONTAINER_NAME : CONTAINER_NAME;
const volumeData = options.test
? 'twenty-app-dev-test-data'
: 'twenty-app-dev-data';
const volumeStorage = options.test
? 'twenty-app-dev-test-storage'
: 'twenty-app-dev-storage';
if (containerExists(containerName)) {
execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
}
try {
execSync(`docker volume rm ${volumeData} ${volumeStorage}`, {
stdio: 'ignore',
});
} catch {
// Volumes may not exist
}
console.log(chalk.green('Twenty server data reset.'));
console.log(
chalk.gray(
`Run 'yarn twenty docker:start${options.test ? ' --test' : ''}' to start a fresh instance.`,
),
);
};
const upgradeAction = async (
version: string | undefined,
options: { test?: boolean },
) => {
const result = await serverUpgrade({
version: version ?? 'latest',
test: options.test,
onProgress: (message) => console.log(chalk.gray(message)),
});
if (!result.success) {
console.error(chalk.red(result.error.message));
process.exit(1);
}
const { data } = result;
if (!data.imageUpdated) {
console.log(chalk.green(` Already up to date (${data.image}).`));
return;
}
console.log(chalk.green(` Upgraded to: ${data.image}`));
if (data.containerRecreated) {
console.log(
chalk.gray(
` Run 'yarn twenty docker:start${options.test ? ' --test' : ''}' to wait for the server to be ready.`,
),
);
}
};
export const registerServerCommands = (program: Command): void => {
program
.command('docker:start [version]')
.description(
'Start the local Twenty container (version defaults to the app `engines.twenty` range, then `latest`)',
)
.option('-p, --port <port>', 'HTTP port')
.option('--test', 'Start a separate test instance (port 2021)')
.action(startAction);
program
.command('docker:stop')
.description('Stop the local Twenty container')
.option('--test', 'Stop the test instance')
.action(stopAction);
program
.command('docker:logs')
.description('Stream container logs')
.option('-n, --lines <lines>', 'Number of lines to show', '50')
.option('--test', 'Show logs for the test instance')
.action(logsAction);
program
.command('docker:status')
.description('Show container status')
.option('--test', 'Show status of the test instance')
.action(statusAction);
program
.command('docker:reset')
.description('Delete all data and start fresh')
.option('--test', 'Reset the test instance')
.action(resetAction);
program
.command('docker:upgrade [version]')
.description('Upgrade the Docker image')
.option('--test', 'Upgrade the test instance')
.action(upgradeAction);
// Deprecated: `server <subcommand>` forwarding to `docker:<subcommand>`
const server = program
.command('server', { hidden: true })
.description(
'Manage a Twenty server (local instance and server-side actions)',
);
const deprecate = (oldCmd: string, newCmd: string) =>
console.warn(
chalk.yellow(
`⚠ \`twenty server ${oldCmd}\` is deprecated. Use \`twenty ${newCmd}\` instead.`,
),
);
server
.command('start [version]')
.option('-p, --port <port>', 'HTTP port')
.option('--test', 'Start a separate test instance (port 2021)')
.action(
async (
version: string | undefined,
options: { port?: string; test?: boolean },
) => {
deprecate('start', 'docker:start');
await startAction(version, options);
},
);
server
.command('stop')
.option('--test', 'Stop the test instance')
.action((options: { test?: boolean }) => {
deprecate('stop', 'docker:stop');
stopAction(options);
});
server
.command('logs')
.option('-n, --lines <lines>', 'Number of lines to show', '50')
.option('--test', 'Show logs for the test instance')
.action((options: { lines: string; test?: boolean }) => {
deprecate('logs', 'docker:logs');
logsAction(options);
});
server
.command('status')
.option('--test', 'Show status of the test instance')
.action(async (options: { test?: boolean }) => {
deprecate('status', 'docker:status');
await statusAction(options);
});
server
.command('reset')
.option('--test', 'Reset the test instance')
.action((options: { test?: boolean }) => {
deprecate('reset', 'docker:reset');
resetAction(options);
});
server
.command('upgrade [version]')
.option('--test', 'Upgrade the test instance')
.action(
async (version: string | undefined, options: { test?: boolean }) => {
deprecate('upgrade', 'docker:upgrade');
await upgradeAction(version, options);
},
);
server
.command('catalog-sync')
.option('-r, --remote <name>', 'Sync on a specific remote')
.action(async (options: { remote?: string }) => {
deprecate('catalog-sync', 'dev:catalog-sync');
const { CatalogSyncCommand } = await import('../dev/catalog-sync');
const cmd = new CatalogSyncCommand();
await cmd.execute({ remote: options.remote });
});
};

View File

@@ -0,0 +1,14 @@
import type { Command } from 'commander';
import { registerAppCommands } from './app';
import { registerDeprecatedCommands } from './deprecated';
import { registerDevCommands } from './dev';
import { registerServerCommands } from './docker';
import { registerRemoteCommands } from './remote';
export const registerCommands = (program: Command): void => {
registerDevCommands(program);
registerAppCommands(program);
registerServerCommands(program);
registerRemoteCommands(program);
registerDeprecatedCommands(program);
};

View File

@@ -0,0 +1,31 @@
import { deriveRemoteName } from '@/cli/commands/remote/derive-remote-name';
describe('deriveRemoteName', () => {
it('should use only the subdomain when the host has one', () => {
expect(deriveRemoteName('https://martin-s-workspace.twenty.com')).toBe(
'martin-s-workspace',
);
});
it('should join multiple subdomain labels with dashes', () => {
expect(deriveRemoteName('https://app.staging.twenty.com')).toBe(
'app-staging',
);
});
it('should drop the tld when there is no subdomain', () => {
expect(deriveRemoteName('https://twenty.com')).toBe('twenty');
});
it('should handle single-label hosts like localhost', () => {
expect(deriveRemoteName('http://localhost:3000')).toBe('localhost');
});
it('should keep IPv4 addresses intact', () => {
expect(deriveRemoteName('http://127.0.0.1:3000')).toBe('127-0-0-1');
});
it('should fall back to "remote" for invalid URLs', () => {
expect(deriveRemoteName('not a url')).toBe('remote');
});
});

View File

@@ -0,0 +1,25 @@
const IPV4_REGEX = /^(\d{1,3}\.){3}\d{1,3}$/;
export const deriveRemoteName = (url: string): string => {
try {
const { hostname } = new URL(url);
if (IPV4_REGEX.test(hostname)) {
return hostname.replace(/\./g, '-');
}
const labels = hostname.split('.');
if (labels.length > 2) {
return labels.slice(0, -2).join('-');
}
if (labels.length === 2) {
return labels[0];
}
return labels.join('-');
} catch {
return 'remote';
}
};

View File

@@ -0,0 +1,354 @@
import { deriveRemoteName } from '@/cli/commands/remote/derive-remote-name';
import { authLogin } from '@/cli/operations/login';
import { authLoginOAuth } from '@/cli/operations/login-oauth';
import { ApiService } from '@/cli/utilities/api/api-service';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { getConfigPath } from '@/cli/utilities/config/get-config-path';
import { detectLocalServer } from '@/cli/utilities/server/detect-local-server';
import chalk from 'chalk';
import type { Command } from 'commander';
import inquirer from 'inquirer';
import { normalizeUrl } from 'twenty-shared/utils';
type AuthMethod = 'OAuth' | 'API key';
const authenticate = async (
apiUrl: string,
apiKey?: string,
): Promise<AuthMethod> => {
if (apiKey) {
const result = await authLogin({ apiKey, apiUrl });
if (!result.success) {
console.error(chalk.red('✗ Authentication failed.'));
process.exit(1);
}
return 'API key';
}
return runOAuthWithApiKeyFallback(apiUrl);
};
const runOAuthWithApiKeyFallback = async (
apiUrl: string,
): Promise<AuthMethod> => {
console.log(chalk.gray('Opening browser for authentication...'));
const oauthResult = await authLoginOAuth({ apiUrl });
if (oauthResult.success) {
return 'OAuth';
}
console.log(chalk.yellow(oauthResult.error.message));
const keyAnswer = await inquirer.prompt([
{
type: 'password',
name: 'apiKey',
message: 'API Key:',
mask: '*',
validate: (input: string) => input.length > 0 || 'API key is required',
},
]);
const fallbackResult = await authLogin({
apiKey: keyAnswer.apiKey,
apiUrl,
});
if (!fallbackResult.success) {
console.error(chalk.red('✗ Authentication failed.'));
process.exit(1);
}
return 'API key';
};
const addAction = async (options: {
as?: string;
apiKey?: string;
url?: string;
apiUrl?: string;
local?: boolean;
test?: boolean;
}) => {
if (options.apiUrl) {
console.warn(chalk.yellow('⚠ --api-url is deprecated. Use --url instead.'));
}
const configPath = options.test ? getConfigPath(true) : undefined;
const configService = new ConfigService(
configPath ? { configPath } : undefined,
);
const existingRemotes = await configService.getRemotes();
if (options.as !== undefined && existingRemotes.includes(options.as)) {
const config = await configService.getConfigForRemote(options.as);
ConfigService.setActiveRemote(options.as);
const method = await authenticate(config.apiUrl, options.apiKey);
console.log(
chalk.green(`✓ Re-authenticated "${options.as}" via ${method}.`),
);
await configService.setDefaultRemote(options.as);
console.log(chalk.green(`✓ Default remote set to "${options.as}".`));
return;
}
let serverUrl = options.url ?? options.apiUrl;
if (serverUrl) {
serverUrl = normalizeUrl(serverUrl);
} else {
const detectedUrl = await detectLocalServer();
if (options.local) {
if (!detectedUrl) {
console.error(
chalk.red(
'No local Twenty server found.\n' +
'Start one with: yarn twenty docker:start',
),
);
process.exit(1);
}
console.log(chalk.gray(`Found local server at ${detectedUrl}`));
serverUrl = detectedUrl;
} else {
serverUrl = normalizeUrl(
(
await inquirer.prompt<{ serverUrl: string }>([
{
type: 'input',
name: 'serverUrl',
message: 'Twenty server URL:',
validate: (input: string) => {
try {
new URL(input);
return true;
} catch {
return 'Please enter a valid URL';
}
},
},
])
).serverUrl,
);
}
}
const name = options.as ?? deriveRemoteName(serverUrl);
ConfigService.setActiveRemote(name);
const method = await authenticate(serverUrl, options.apiKey);
console.log(
chalk.green(`✓ Remote "${name}" added (${serverUrl}) via ${method}.`),
);
await configService.setDefaultRemote(name);
console.log(chalk.green(`✓ Default remote set to "${name}".`));
};
const listAction = async () => {
const configService = new ConfigService();
const remotes = await configService.getRemotes();
const defaultRemote = await configService.getDefaultRemote();
if (remotes.length === 0) {
console.log('No remotes configured.');
console.log("Use 'twenty remote:add' to add one.");
return;
}
console.log('');
for (const remoteName of remotes) {
const config = await configService.getConfigForRemote(remoteName);
const authMethod = config.twentyCLIAccessToken
? 'oauth'
: config.apiKey
? 'api-key'
: 'none';
const isDefault = remoteName === defaultRemote;
const marker = isDefault ? '* ' : ' ';
const nameText = isDefault ? chalk.bold(remoteName) : remoteName;
console.log(
`${marker}${nameText} ${chalk.gray(config.apiUrl)} [${authMethod}]`,
);
}
console.log(
'\n',
chalk.gray("Use 'twenty remote:use <name>' to change default"),
);
};
const useAction = async (nameArg?: string) => {
const configService = new ConfigService();
const remoteName =
nameArg ??
(
await inquirer.prompt<{ remote: string }>([
{
type: 'select',
name: 'remote',
message: 'Select default remote:',
choices: await configService.getRemotes(),
},
])
).remote;
const remotes = await configService.getRemotes();
if (!remotes.includes(remoteName)) {
console.error(chalk.red(`Remote "${remoteName}" not found.`));
process.exit(1);
}
await configService.setDefaultRemote(remoteName);
console.log(chalk.green(`✓ Default remote set to "${remoteName}".`));
};
const statusAction = async () => {
const configService = new ConfigService();
const apiService = new ApiService();
const activeRemote = ConfigService.getActiveRemote();
const config = await configService.getConfig();
const authMethod = config.twentyCLIAccessToken
? 'oauth'
: config.apiKey
? 'api-key'
: 'none';
console.log(` Remote: ${chalk.bold(activeRemote)}`);
console.log(` Server: ${config.apiUrl}`);
if (authMethod === 'none') {
console.log(` Auth: ${chalk.yellow('not configured')}`);
return;
}
const { authValid } = await apiService.validateAuth();
const statusText = authValid
? chalk.green(`${authMethod} (valid)`)
: chalk.red(`${authMethod} (invalid)`);
console.log(` Auth: ${statusText}`);
};
const removeAction = async (name: string) => {
const configService = new ConfigService();
const remotes = await configService.getRemotes();
if (!remotes.includes(name)) {
console.error(chalk.red(`Remote "${name}" not found.`));
process.exit(1);
}
ConfigService.setActiveRemote(name);
await configService.clearConfig();
console.log(chalk.green(`✓ Remote "${name}" removed.`));
};
export const registerRemoteCommands = (program: Command): void => {
program
.command('remote:add')
.description('Add or re-authenticate a remote')
.option('--as <name>', 'Name for this remote')
.option('--api-key <apiKey>', 'API key for non-interactive auth')
.option('--url <url>', 'Server URL')
.option('--api-url <apiUrl>', '[deprecated: use --url]')
.option('--local', 'Connect to a local Twenty server (auto-detect)')
.option('--test', 'Write to config.test.json (for integration tests)')
.action(addAction);
program
.command('remote:list')
.description('List all configured remotes')
.action(listAction);
program
.command('remote:use [name]')
.description('Set the default remote')
.action(useAction);
program
.command('remote:status')
.description('Show active remote auth status')
.action(statusAction);
program
.command('remote:remove <name>')
.description('Remove a remote')
.action(removeAction);
// Deprecated: `remote <subcommand>` forwarding to `remote:<subcommand>`
const remote = program
.command('remote', { hidden: true })
.description('Manage remote Twenty servers');
const deprecate = (oldCmd: string, newCmd: string) =>
console.warn(
chalk.yellow(
`⚠ \`twenty remote ${oldCmd}\` is deprecated. Use \`twenty ${newCmd}\` instead.`,
),
);
remote
.command('add')
.option('--as <name>', 'Name for this remote')
.option('--api-key <apiKey>', 'API key for non-interactive auth')
.option('--url <url>', 'Server URL')
.option('--api-url <apiUrl>', '[deprecated: use --url]')
.option('--local', 'Connect to a local Twenty server (auto-detect)')
.option('--test', 'Write to config.test.json (for integration tests)')
.action(
async (options: {
as?: string;
apiKey?: string;
url?: string;
apiUrl?: string;
local?: boolean;
test?: boolean;
}) => {
deprecate('add', 'remote:add');
await addAction(options);
},
);
remote.command('list').action(async () => {
deprecate('list', 'remote:list');
await listAction();
});
remote.command('switch [name]').action(async (name?: string) => {
deprecate('switch', 'remote:use');
await useAction(name);
});
remote.command('status').action(async () => {
deprecate('status', 'remote:status');
await statusAction();
});
remote.command('remove <name>').action(async (name: string) => {
deprecate('remove', 'remote:remove');
await removeAction(name);
});
};

View File

@@ -0,0 +1,4 @@
export const DESTRUCTIVE_METADATA_NAMES = new Set<string>([
'objectMetadata',
'fieldMetadata',
]);

View File

@@ -0,0 +1,7 @@
// Seeded development API key for the twenty-app-dev Docker image.
// Maps to API key ID 20202020-f401-4d8a-a731-64d007c27bad in tim@apple.dev's workspace.
// Only valid against the local dev Docker image — not a secret.
export const DEV_API_KEY =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc';
export const DEV_API_URL = 'http://localhost:2020';

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,129 @@
import { execSync } from 'child_process';
import path from 'path';
import { applyGeneratedCover } from '@/cli/utilities/build/cover/apply-generated-cover';
import { buildApplication } from '@/cli/utilities/build/common/build-application';
import { runTypecheck } from '@/cli/utilities/build/common/typecheck-plugin';
import { buildAndValidateManifest } from '@/cli/utilities/build/manifest/build-and-validate-manifest';
import { manifestUpdateChecksums } from '@/cli/utilities/build/manifest/manifest-update-checksums';
import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer';
import { compileApplicationTranslations } from '@/cli/utilities/translations/compile-application-translations';
import { runSafe } from '@/cli/utilities/run-safe';
import { APP_ERROR_CODES, type CommandResult } from '@/cli/types';
export type AppBuildOptions = {
appPath: string;
tarball?: boolean;
onProgress?: (message: string) => void;
};
export type AppBuildResult = {
outputDir: string;
fileCount: number;
tarballPath?: string;
};
const innerAppBuild = async (
options: AppBuildOptions,
): Promise<CommandResult<AppBuildResult>> => {
const { appPath, onProgress } = options;
onProgress?.('Building manifest...');
const manifestResult = await buildAndValidateManifest(appPath);
if (!manifestResult.success) {
return {
success: false,
error: {
code: APP_ERROR_CODES.MANIFEST_BUILD_FAILED,
message: manifestResult.errors.join('\n'),
},
};
}
const { filePaths } = manifestResult;
for (const warning of manifestResult.warnings) {
onProgress?.(`⚠ ${warning}`);
}
const { manifest, generatedAssets } = await applyGeneratedCover({
appPath,
manifest: manifestResult.manifest,
}).catch((error) => {
onProgress?.(
`⚠ Skipped cover image generation: ${error instanceof Error ? error.message : String(error)}`,
);
return { manifest: manifestResult.manifest, generatedAssets: [] };
});
if (generatedAssets.length > 0) {
onProgress?.('Generated cover image from logo');
}
const translations = await compileApplicationTranslations(appPath);
onProgress?.('Building application files...');
const buildResult = await buildApplication({
appPath,
manifest,
filePaths,
generatedAssets,
});
onProgress?.('Running typecheck...');
const typecheckErrors = await runTypecheck(appPath);
if (typecheckErrors.length > 0) {
const errorMessages = typecheckErrors.map(
(error) =>
`${error.file}(${error.line},${error.column + 1}): ${error.text}`,
);
return {
success: false,
error: {
code: APP_ERROR_CODES.TYPECHECK_FAILED,
message: `Typecheck failed:\n${errorMessages.join('\n')}`,
},
};
}
const updatedManifest = manifestUpdateChecksums({
manifest,
builtFileInfos: buildResult.builtFileInfos,
});
await writeManifestToOutput(appPath, { ...updatedManifest, translations });
const outputDir = path.join(appPath, '.twenty', 'output');
const result: AppBuildResult = {
outputDir,
fileCount: buildResult.builtFileInfos.size,
};
if (options.tarball) {
onProgress?.('Packing tarball...');
const packOutput = execSync('npm pack --pack-destination .', {
cwd: outputDir,
encoding: 'utf-8',
}).trim();
const tarballName = packOutput.split('\n').pop()!;
result.tarballPath = path.join(outputDir, tarballName);
}
return { success: true, data: result };
};
export const appBuild = (
options: AppBuildOptions,
): Promise<CommandResult<AppBuildResult>> =>
runSafe(() => innerAppBuild(options), APP_ERROR_CODES.BUILD_FAILED);

View File

@@ -0,0 +1,61 @@
import fs from 'fs';
import { ApiService } from '@/cli/utilities/api/api-service';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { runSafe } from '@/cli/utilities/run-safe';
import { APP_ERROR_CODES, type CommandResult } from '@/cli/types';
export type AppDeployOptions = {
tarballPath: string;
remote?: string;
serverUrl?: string;
token?: string;
onProgress?: (message: string) => void;
};
export type AppDeployResult = {
id: string;
name: string;
universalIdentifier: string;
};
const innerAppDeploy = async (
options: AppDeployOptions,
): Promise<CommandResult<AppDeployResult>> => {
const { tarballPath, onProgress } = options;
if (options.remote) {
ConfigService.setActiveRemote(options.remote);
}
onProgress?.(`Uploading ${tarballPath}...`);
const tarballBuffer = fs.readFileSync(tarballPath);
const apiService = new ApiService({
serverUrl: options.serverUrl,
token: options.token,
});
const uploadResult = await apiService.uploadAppTarball({ tarballBuffer });
if (!uploadResult.success) {
return {
success: false,
error: {
code: APP_ERROR_CODES.DEPLOY_FAILED,
message: `Upload failed: ${uploadResult.error}`,
},
};
}
return {
success: true,
data: uploadResult.data,
};
};
export const appDeploy = (
options: AppDeployOptions,
): Promise<CommandResult<AppDeployResult>> =>
runSafe(() => innerAppDeploy(options), APP_ERROR_CODES.DEPLOY_FAILED);

View File

@@ -0,0 +1,397 @@
import path from 'path';
import { type Manifest, OUTPUT_DIR } from 'twenty-shared/application';
import { type MetadataValidationErrorResponse } from 'twenty-shared/metadata';
import { isPlainObject } from 'twenty-shared/utils';
import { ApiService } from '@/cli/utilities/api/api-service';
import {
ensureAppAccessTokenIsValidOrRefresh,
ensureAppRegistration,
} from '@/cli/utilities/auth';
import { buildAppTokenPairFetcher } from '@/cli/utilities/auth/build-app-token-pair-fetcher';
import { promptForReauthentication } from '@/cli/utilities/auth/reauth-helper';
import { buildApplication } from '@/cli/utilities/build/common/build-application';
import { runTypecheck } from '@/cli/utilities/build/common/typecheck-plugin';
import { buildAndValidateManifest } from '@/cli/utilities/build/manifest/build-and-validate-manifest';
import { compileApplicationTranslations } from '@/cli/utilities/translations/compile-application-translations';
import { manifestUpdateChecksums } from '@/cli/utilities/build/manifest/manifest-update-checksums';
import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer';
import { ClientService } from '@/cli/utilities/client/client-service';
import { ConfigService } from '@/cli/utilities/config/config-service';
import {
countDestructiveActions,
formatSyncActionsPlan,
hasDestructiveActions,
} from '@/cli/utilities/dev/orchestrator/steps/format-sync-actions-plan';
import { formatManifestValidationErrors } from '@/cli/utilities/error/format-manifest-validation-errors';
import { getSyncErrorRecoveryHint } from '@/cli/utilities/error/get-sync-error-recovery-hint';
import { getGraphQLErrorMessage } from '@/cli/utilities/error/parse-server-error';
import { serializeError } from '@/cli/utilities/error/serialize-error';
import { FileUploader } from '@/cli/utilities/file/file-uploader';
import { formatUploadFailures } from '@/cli/utilities/file/format-upload-failures';
import { runSafe } from '@/cli/utilities/run-safe';
import {
APP_ERROR_CODES,
type CommandError,
type CommandResult,
} from '@/cli/types';
import chalk from 'chalk';
export type AppDevOnceOptions = {
appPath: string;
verbose?: boolean;
apply?: boolean;
force?: boolean;
inferDeletionFromMissingEntities?: boolean;
onProgress?: (message: string) => void;
onPlan?: (text: string) => void;
confirmApply?: (deleteCount: number) => Promise<boolean>;
};
export type AppDevOnceResult = {
outputDir: string;
fileCount: number;
applicationDisplayName: string;
applicationUniversalIdentifier: string;
applied: boolean;
};
const appendRecoveryHint = (
message: string,
errorMessage: string | undefined,
): string => {
const hint = getSyncErrorRecoveryHint(errorMessage);
return hint ? `${message}\n\n${hint}` : message;
};
const NOT_INSTALLED_SUB_CODES = new Set([
'APP_NOT_INSTALLED',
'APPLICATION_NOT_FOUND',
]);
const isAppNotInstalledError = (result: {
error?: MetadataValidationErrorResponse;
message?: string;
}): boolean => {
const extensions = result.error;
if (
isPlainObject(extensions) &&
NOT_INSTALLED_SUB_CODES.has(
(extensions as { subCode?: string }).subCode ?? '',
)
) {
return true;
}
const message = (result.message ?? '').toLowerCase();
return (
message.includes('not installed in workspace') ||
message.includes('not found in workspace')
);
};
const buildSyncError = (
result: { error?: MetadataValidationErrorResponse; message?: string },
verbose: boolean,
): CommandError => {
const errorEvents = verbose
? null
: formatManifestValidationErrors(result.error);
const message = errorEvents
? errorEvents.map((event) => event.message).join('\n')
: `Sync failed with error: ${result.message ?? 'Unknown error'}`;
return {
code: APP_ERROR_CODES.SYNC_FAILED,
message: appendRecoveryHint(message, result.message),
};
};
const innerAppDevOnce = async (
options: AppDevOnceOptions,
): Promise<CommandResult<AppDevOnceResult>> => {
const {
appPath,
onProgress,
onPlan,
confirmApply,
verbose = false,
apply = true,
force = false,
inferDeletionFromMissingEntities = true,
} = options;
onProgress?.('Checking server...');
const apiService = new ApiService({ disableInterceptors: true });
const validateAuth = await apiService.validateAuth();
if (!validateAuth.serverUp) {
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message:
'Cannot reach Twenty server.\n\n' +
' Start a local server:\n' +
' yarn twenty docker:start\n\n' +
' Check server status:\n' +
' yarn twenty docker:status',
},
};
}
if (!validateAuth.authValid) {
const outcome = await promptForReauthentication(
ConfigService.getActiveRemote(),
);
if (outcome !== 'reauthenticated') {
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message:
'Authentication failed. Run `yarn twenty remote:add` to authenticate.',
},
};
}
}
onProgress?.('Building manifest...');
const manifestResult = await buildAndValidateManifest(appPath);
if (!manifestResult.success) {
return {
success: false,
error: {
code: APP_ERROR_CODES.MANIFEST_BUILD_FAILED,
message: manifestResult.errors.join('\n'),
},
};
}
for (const warning of manifestResult.warnings) {
onProgress?.(chalk.yellow(`⚠ ${warning}`));
}
onProgress?.('Building application files...');
const buildResult = await buildApplication({
appPath,
manifest: manifestResult.manifest,
filePaths: manifestResult.filePaths,
});
onProgress?.('Running typecheck...');
const typecheckErrors = await runTypecheck(appPath);
if (typecheckErrors.length > 0) {
const errorMessages = typecheckErrors.map(
(error) =>
`${error.file}(${error.line},${error.column + 1}): ${error.text}`,
);
return {
success: false,
error: {
code: APP_ERROR_CODES.TYPECHECK_FAILED,
message: `Typecheck failed:\n${errorMessages.join('\n')}`,
},
};
}
const translations = await compileApplicationTranslations(appPath);
const manifest: Manifest = {
...manifestUpdateChecksums({
manifest: manifestResult.manifest,
builtFileInfos: buildResult.builtFileInfos,
}),
translations,
};
await writeManifestToOutput(appPath, manifest);
const makeData = (): AppDevOnceResult => ({
outputDir: path.join(appPath, OUTPUT_DIR),
fileCount: buildResult.builtFileInfos.size,
applicationDisplayName: manifest.application.displayName,
applicationUniversalIdentifier: manifest.application.universalIdentifier,
applied: apply,
});
if (!apply) {
onProgress?.(
'Computing metadata plan (read-only, nothing will be applied)...',
);
const planResult = await apiService.syncApplication(manifest, {
dryRun: true,
inferDeletionFromMissingEntities,
});
if (!planResult.success) {
return { success: false, error: buildSyncError(planResult, verbose) };
}
onPlan?.(
formatSyncActionsPlan(planResult.data.actions, {
showNoDeleteHint: inferDeletionFromMissingEntities,
}),
);
return { success: true, data: makeData() };
}
let planRendered = false;
if (!force) {
onProgress?.('Computing metadata plan...');
const planResult = await apiService.syncApplication(manifest, {
dryRun: true,
inferDeletionFromMissingEntities,
});
if (planResult.success) {
onPlan?.(
formatSyncActionsPlan(planResult.data.actions, {
showNoDeleteHint: inferDeletionFromMissingEntities,
}),
);
planRendered = true;
if (hasDestructiveActions(planResult.data.actions)) {
const approved = confirmApply
? await confirmApply(countDestructiveActions(planResult.data.actions))
: false;
if (!approved) {
return {
success: false,
error: {
code: APP_ERROR_CODES.APPLY_ABORTED,
message: 'Apply cancelled — no changes were made.',
},
};
}
}
} else if (!isAppNotInstalledError(planResult)) {
return { success: false, error: buildSyncError(planResult, verbose) };
}
}
onProgress?.('Registering application...');
const configService = new ConfigService();
const { clientId, clientSecret } = await ensureAppRegistration(
apiService,
configService,
{
name: manifest.application.displayName,
universalIdentifier: manifest.application.universalIdentifier,
},
);
const createDevAppResult = await apiService.createDevelopmentApplication({
universalIdentifier: manifest.application.universalIdentifier,
name: manifest.application.displayName,
});
if (!createDevAppResult.success) {
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message:
getGraphQLErrorMessage(createDevAppResult.error) ??
`Failed to install development application: ${serializeError(createDevAppResult.error)}`,
},
};
}
onProgress?.(
`Uploading ${buildResult.builtFileInfos.size} file${buildResult.builtFileInfos.size === 1 ? '' : 's'}...`,
);
const fileUploader = new FileUploader({
appPath,
applicationUniversalIdentifier: manifest.application.universalIdentifier,
});
const uploadFailures = await fileUploader.uploadFiles(
Array.from(buildResult.builtFileInfos.values()).map((builtFileInfo) => ({
builtPath: builtFileInfo.builtPath,
fileFolder: builtFileInfo.fileFolder,
})),
);
if (uploadFailures.length > 0) {
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message: formatUploadFailures(uploadFailures).join('\n'),
},
};
}
onProgress?.('Syncing manifest...');
const syncResult = await apiService.syncApplication(manifest, {
inferDeletionFromMissingEntities,
});
if (!syncResult.success) {
return { success: false, error: buildSyncError(syncResult, verbose) };
}
if (!planRendered) {
onPlan?.(formatSyncActionsPlan(syncResult.data.actions));
}
onProgress?.('Generating API client...');
try {
const appAccessToken = await ensureAppAccessTokenIsValidOrRefresh(
configService,
{
credentials: clientSecret ? { clientId, clientSecret } : undefined,
fetchTokenPair: buildAppTokenPairFetcher(
apiService,
createDevAppResult.data.id,
),
},
);
const clientService = new ClientService();
await clientService.generateCoreClient({
appPath,
appAccessToken,
});
} catch (error) {
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message: `Failed to generate API client: ${serializeError(error)}`,
},
};
}
return { success: true, data: makeData() };
};
export const appDevOnce = (
options: AppDevOnceOptions,
): Promise<CommandResult<AppDevOnceResult>> =>
runSafe(() => innerAppDevOnce(options), APP_ERROR_CODES.SYNC_FAILED);

View File

@@ -0,0 +1,180 @@
import { ApiService } from '@/cli/utilities/api/api-service';
import { readManifestFromFile } from '@/cli/utilities/build/manifest/manifest-reader';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { type Manifest } from 'twenty-shared/application';
import { runSafe } from '@/cli/utilities/run-safe';
import {
APP_ERROR_CODES,
FUNCTION_ERROR_CODES,
type CommandResult,
type FunctionExecutionResult,
} from '@/cli/types';
export type FunctionExecuteOptions = {
appPath: string;
remote?: string;
payload?: Record<string, unknown>;
} & (
| { postInstall: true }
| { preInstall: true }
| { uninstall: true }
| { functionUniversalIdentifier: string }
| { functionName: string }
);
type LogicFunction = {
id: string;
name: string;
universalIdentifier: string;
applicationId: string | null;
};
const belongsToApplication = (
logicFunction: LogicFunction,
manifest: Manifest,
): boolean => {
return manifest.logicFunctions.some(
(manifestFn) =>
manifestFn.universalIdentifier === logicFunction.universalIdentifier,
);
};
const resolveIdentifier = (options: FunctionExecuteOptions): string => {
if ('postInstall' in options) return 'post install';
if ('preInstall' in options) return 'pre install';
if ('uninstall' in options) return 'uninstall';
if ('functionUniversalIdentifier' in options)
return options.functionUniversalIdentifier;
if ('functionName' in options) return options.functionName;
return 'unknown';
};
const innerFunctionExecute = async (
options: FunctionExecuteOptions,
): Promise<CommandResult<FunctionExecutionResult>> => {
if (options.remote) {
ConfigService.setActiveRemote(options.remote);
}
const apiService = new ApiService();
const manifest = await readManifestFromFile(options.appPath);
if (!manifest) {
return {
success: false,
error: {
code: APP_ERROR_CODES.MANIFEST_NOT_FOUND,
message: 'Manifest not found. Run `build` or `dev` first.',
},
};
}
const functionsResult = await apiService.findLogicFunctions();
if (!functionsResult.success) {
const errorMessage =
functionsResult.error instanceof Error
? functionsResult.error.message
: String(functionsResult.error ?? 'Failed to fetch functions');
return {
success: false,
error: {
code: FUNCTION_ERROR_CODES.FETCH_FUNCTIONS_FAILED,
message: errorMessage,
},
};
}
const appFunctions = functionsResult.data.filter(
(logicFunction) =>
logicFunction.universalIdentifier &&
belongsToApplication(logicFunction, manifest),
);
const targetFunction = appFunctions.find((logicFunction) => {
if ('postInstall' in options && options.postInstall) {
return (
logicFunction.universalIdentifier ===
manifest.application.postInstallLogicFunction?.universalIdentifier
);
}
if ('preInstall' in options && options.preInstall) {
return (
logicFunction.universalIdentifier ===
manifest.application.preInstallLogicFunction?.universalIdentifier
);
}
if ('uninstall' in options && options.uninstall) {
return (
logicFunction.universalIdentifier ===
manifest.application.uninstallLogicFunction?.universalIdentifier
);
}
if ('functionUniversalIdentifier' in options) {
return (
logicFunction.universalIdentifier ===
options.functionUniversalIdentifier
);
}
if ('functionName' in options) {
return logicFunction.name === options.functionName;
}
return false;
});
if (!targetFunction) {
return {
success: false,
error: {
code: FUNCTION_ERROR_CODES.FUNCTION_NOT_FOUND,
message: `Function "${resolveIdentifier(options)}" not found in application.`,
details: {
identifier: resolveIdentifier(options),
availableFunctions: appFunctions.map((logicFunction) => ({
name: logicFunction.name,
universalIdentifier: logicFunction.universalIdentifier,
})),
},
},
};
}
const result = await apiService.executeLogicFunction({
functionId: targetFunction.id,
payload: options.payload ?? {},
});
if (!result.success) {
const errorMessage =
result.error instanceof Error
? result.error.message
: String(result.error ?? 'Execution failed');
return {
success: false,
error: {
code: FUNCTION_ERROR_CODES.EXECUTION_FAILED,
message: errorMessage,
},
};
}
return {
success: true,
data: {
functionName: targetFunction.name,
...result.data!,
},
};
};
export const functionExecute = (
options: FunctionExecuteOptions,
): Promise<CommandResult<FunctionExecutionResult>> =>
runSafe(
() => innerFunctionExecute(options),
FUNCTION_ERROR_CODES.EXECUTION_FAILED,
);

View File

@@ -0,0 +1,68 @@
// Auth
export { authLogin } from './login';
export type { AuthLoginOptions } from './login';
export { authLoginOAuth } from './login-oauth';
export type { AuthLoginOAuthOptions } from './login-oauth';
export { authLogout } from './logout';
export type { AuthLogoutOptions } from './logout';
// App
export { appBuild } from './build';
export type { AppBuildOptions, AppBuildResult } from './build';
export { appDeploy } from './deploy';
export type { AppDeployOptions, AppDeployResult } from './deploy';
export { appDevOnce } from './dev-once';
export type { AppDevOnceOptions, AppDevOnceResult } from './dev-once';
export { appInstall } from './install';
export type { AppInstallOptions } from './install';
export { appPublish } from './publish';
export { appPull } from './pull';
export type { AppPullOptions, AppPullResult } from './pull';
export type { AppPublishOptions, AppPublishResult } from './publish';
export { appUninstall } from './uninstall';
export type { AppUninstallOptions } from './uninstall';
// Functions
export { functionExecute } from './execute';
export type { FunctionExecuteOptions } from './execute';
// Server
export { serverStart } from './server-start';
export type { ServerStartOptions, ServerStartResult } from './server-start';
export { serverUpgrade } from './server-upgrade';
export type {
ServerUpgradeOptions,
ServerUpgradeResult,
} from './server-upgrade';
export { detectLocalServer } from '@/cli/utilities/server/detect-local-server';
export {
checkDockerRunning,
containerExists,
getContainerDigest,
getImageDigest,
getImageForVersion,
} from '@/cli/utilities/server/docker-container';
export { getEngineVersionRange } from '@/cli/utilities/version/get-engine-version-range';
export { resolveHighestEngineVersion } from '@/cli/utilities/version/resolve-highest-engine-version';
// Config
export { ConfigService } from '@/cli/utilities/config/config-service';
// Constants
export { DEV_API_KEY, DEV_API_URL } from '@/cli/constants/dev-api-key';
// Shared types and error codes
export {
APP_ERROR_CODES,
AUTH_ERROR_CODES,
FUNCTION_ERROR_CODES,
SERVER_ERROR_CODES,
} from '@/cli/types';
export type {
AuthListRemote,
AuthStatusResult,
CommandError,
CommandResult,
FunctionExecutionResult,
TypecheckResult,
} from '@/cli/types';

View File

@@ -0,0 +1,59 @@
import { ApiService } from '@/cli/utilities/api/api-service';
import { readManifestFromFile } from '@/cli/utilities/build/manifest/manifest-reader';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { formatManifestValidationErrors } from '@/cli/utilities/error/format-manifest-validation-errors';
import { runSafe } from '@/cli/utilities/run-safe';
import { APP_ERROR_CODES, type CommandResult } from '@/cli/types';
export type AppInstallOptions = {
appPath: string;
remote?: string;
};
const innerAppInstall = async (
options: AppInstallOptions,
): Promise<CommandResult> => {
if (options.remote) {
ConfigService.setActiveRemote(options.remote);
}
const apiService = new ApiService();
const manifest = await readManifestFromFile(options.appPath);
if (!manifest) {
return {
success: false,
error: {
code: APP_ERROR_CODES.MANIFEST_NOT_FOUND,
message: 'Manifest not found. Run `build` or `dev` first.',
},
};
}
const result = await apiService.installTarballApp({
universalIdentifier: manifest.application.universalIdentifier,
});
if (!result.success) {
const errorEvents = formatManifestValidationErrors(result.error);
const message = errorEvents
? errorEvents.map((event) => event.message).join('\n')
: `Install failed with error: ${result.message ?? 'Unknown error'}`;
return {
success: false,
error: {
code: APP_ERROR_CODES.INSTALL_FAILED,
message,
},
};
}
return { success: true, data: undefined };
};
export const appInstall = (
options: AppInstallOptions,
): Promise<CommandResult> =>
runSafe(() => innerAppInstall(options), APP_ERROR_CODES.INSTALL_FAILED);

View File

@@ -0,0 +1,143 @@
import { ApiService } from '@/cli/utilities/api/api-service';
import { startCallbackServer } from '@/cli/utilities/auth/callback-server';
import { openBrowser } from '@/cli/utilities/auth/open-browser';
import { generatePkceChallenge } from '@/cli/utilities/auth/pkce';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { runSafe } from '@/cli/utilities/run-safe';
import axios from 'axios';
import { AUTH_ERROR_CODES, type CommandResult } from '@/cli/types';
export type AuthLoginOAuthOptions = {
apiUrl: string;
remote?: string;
timeoutMs?: number;
};
export type OAuthDiscoveryResponse = {
authorization_endpoint: string;
token_endpoint: string;
cli_client_id?: string;
};
const innerAuthLoginOAuth = async (
options: AuthLoginOAuthOptions,
): Promise<CommandResult> => {
const { apiUrl, remote, timeoutMs } = options;
if (remote) {
ConfigService.setActiveRemote(remote);
}
const configService = new ConfigService();
const discoveryUrl = `${apiUrl}/.well-known/oauth-authorization-server`;
let discovery: OAuthDiscoveryResponse;
try {
const response = await axios.get(discoveryUrl);
discovery = response.data;
} catch {
return {
success: false,
error: {
code: AUTH_ERROR_CODES.OAUTH_NOT_SUPPORTED,
message: `Could not reach the OAuth discovery endpoint at ${discoveryUrl}. Ensure the server is running. Use --api-key instead.`,
},
};
}
if (!discovery.cli_client_id) {
return {
success: false,
error: {
code: AUTH_ERROR_CODES.OAUTH_NOT_SUPPORTED,
message:
'Server does not expose a CLI client ID. Ensure the server is up to date. Use --api-key instead.',
},
};
}
const clientId = discovery.cli_client_id;
const { codeVerifier, codeChallenge } = generatePkceChallenge();
const callbackServer = await startCallbackServer({ timeoutMs });
try {
const authUrl = new URL(discovery.authorization_endpoint);
authUrl.searchParams.set('clientId', clientId);
authUrl.searchParams.set('codeChallenge', codeChallenge);
authUrl.searchParams.set('redirectUrl', callbackServer.callbackUrl);
const browserOpened = await openBrowser(authUrl.toString());
if (!browserOpened) {
console.log(
`\nOpen this URL in your browser to authenticate:\n${authUrl.toString()}\n`,
);
}
const callbackResult = await callbackServer.waitForCallback();
if (!callbackResult.success) {
return {
success: false,
error: {
code: AUTH_ERROR_CODES.AUTH_FAILED,
message: callbackResult.error,
},
};
}
const tokenResponse = await axios.post(discovery.token_endpoint, {
grant_type: 'authorization_code',
code: callbackResult.code,
code_verifier: codeVerifier,
redirect_uri: callbackServer.callbackUrl,
client_id: clientId,
});
const { access_token: accessToken, refresh_token: refreshToken } =
tokenResponse.data;
await configService.setConfig({
apiUrl,
twentyCLIAccessToken: accessToken,
twentyCLIRefreshToken: refreshToken,
twentyCLIRegistrationClientId: clientId,
});
const apiService = new ApiService({
serverUrl: apiUrl,
token: accessToken,
});
const validateAuth = await apiService.validateAuth();
if (!validateAuth.authValid) {
await configService.clearConfig();
return {
success: false,
error: {
code: AUTH_ERROR_CODES.AUTH_FAILED,
message:
'OAuth tokens received but authentication validation failed.',
},
};
}
return { success: true, data: undefined };
} finally {
callbackServer.close();
}
};
export const authLoginOAuth = (
options: AuthLoginOAuthOptions,
): Promise<CommandResult> =>
runSafe(() => innerAuthLoginOAuth(options), AUTH_ERROR_CODES.AUTH_FAILED);

View File

@@ -0,0 +1,50 @@
import { ApiService } from '@/cli/utilities/api/api-service';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { runSafe } from '@/cli/utilities/run-safe';
import { AUTH_ERROR_CODES, type CommandResult } from '@/cli/types';
export type AuthLoginOptions = {
apiKey: string;
apiUrl: string;
remote?: string;
};
const innerAuthLogin = async (
options: AuthLoginOptions,
): Promise<CommandResult> => {
const { apiKey, apiUrl, remote } = options;
if (remote) {
ConfigService.setActiveRemote(remote);
}
const configService = new ConfigService();
await configService.setConfig({
apiUrl,
apiKey,
twentyCLIAccessToken: undefined,
twentyCLIRefreshToken: undefined,
twentyCLIRegistrationClientId: undefined,
});
const apiService = new ApiService();
const validateAuth = await apiService.validateAuth();
if (!validateAuth.authValid) {
await configService.clearConfig();
return {
success: false,
error: {
code: AUTH_ERROR_CODES.AUTH_FAILED,
message: 'Authentication failed. Please check your credentials.',
},
};
}
return { success: true, data: undefined };
};
export const authLogin = (options: AuthLoginOptions): Promise<CommandResult> =>
runSafe(() => innerAuthLogin(options), AUTH_ERROR_CODES.AUTH_FAILED);

View File

@@ -0,0 +1,26 @@
import { ConfigService } from '@/cli/utilities/config/config-service';
import { runSafe } from '@/cli/utilities/run-safe';
import { AUTH_ERROR_CODES, type CommandResult } from '@/cli/types';
export type AuthLogoutOptions = {
remote?: string;
};
const innerAuthLogout = async (
options?: AuthLogoutOptions,
): Promise<CommandResult> => {
if (options?.remote) {
ConfigService.setActiveRemote(options.remote);
}
const configService = new ConfigService();
await configService.clearConfig();
return { success: true, data: undefined };
};
export const authLogout = (
options?: AuthLogoutOptions,
): Promise<CommandResult> =>
runSafe(() => innerAuthLogout(options), AUTH_ERROR_CODES.AUTH_FAILED);

View File

@@ -0,0 +1,86 @@
import { execFileSync } from 'child_process';
import { runSafe } from '@/cli/utilities/run-safe';
import { appBuild } from './build';
import { APP_ERROR_CODES, type CommandResult } from '@/cli/types';
export type AppPublishOptions = {
appPath: string;
npmTag?: string;
onProgress?: (message: string) => void;
};
export type AppPublishResult = {
target: 'npm';
};
const innerAppPublish = async (
options: AppPublishOptions,
): Promise<CommandResult<AppPublishResult>> => {
const { appPath, onProgress } = options;
const buildResult = await appBuild({
appPath,
onProgress,
});
if (!buildResult.success) {
return buildResult;
}
onProgress?.('Publishing to npm...');
if (options.npmTag && !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(options.npmTag)) {
return {
success: false,
error: {
code: APP_ERROR_CODES.PUBLISH_FAILED,
message: `Invalid npm tag: ${options.npmTag}`,
},
};
}
// Provenance can only be generated from a CI with OIDC; forcing it locally
// makes npm publish fail. ACTIONS_ID_TOKEN_REQUEST_URL is only set when the
// GitHub Actions workflow grants id-token: write.
// npm rejects provenance when the workflow runs from a private source repo
// (E422 "Unsupported GitHub Actions source repository visibility: private").
// Set TWENTY_APP_PUBLISH_DISABLE_PROVENANCE=true to opt out in that case.
const provenanceDisabled =
process.env.TWENTY_APP_PUBLISH_DISABLE_PROVENANCE === 'true';
const supportsProvenance =
process.env.ACTIONS_ID_TOKEN_REQUEST_URL != null && !provenanceDisabled;
const publishArgs = [
'publish',
'--access',
'public',
...(supportsProvenance ? ['--provenance'] : []),
...(options.npmTag ? ['--tag', options.npmTag] : []),
];
const isWindows = process.platform === 'win32';
try {
execFileSync(isWindows ? 'npm.cmd' : 'npm', publishArgs, {
cwd: buildResult.data.outputDir,
stdio: 'inherit',
shell: isWindows,
});
} catch {
return {
success: false,
error: {
code: APP_ERROR_CODES.PUBLISH_FAILED,
message: `npm publish failed`,
},
};
}
return { success: true, data: { target: 'npm' } };
};
export const appPublish = (
options: AppPublishOptions,
): Promise<CommandResult<AppPublishResult>> =>
runSafe(() => innerAppPublish(options), APP_ERROR_CODES.PUBLISH_FAILED);

View File

@@ -0,0 +1,241 @@
import { APP_ERROR_CODES, type CommandResult } from '@/cli/types';
import { ApiService } from '@/cli/utilities/api/api-service';
import { promptForReauthentication } from '@/cli/utilities/auth/reauth-helper';
import { ManifestEntityKey } from '@/cli/utilities/build/manifest/manifest-extract-config';
import { ConfigService } from '@/cli/utilities/config/config-service';
import {
getGraphQLErrorMessage,
hasGraphQLErrorSubCode,
} from '@/cli/utilities/error/parse-server-error';
import { pathExists } from '@/cli/utilities/file/fs-utils';
import { type ApplicationExportCoverageEntry } from '@/cli/utilities/pull/application-export-type';
import { applyPullWrites } from '@/cli/utilities/pull/apply-pull-writes';
import { type SkippedPullEntity } from '@/cli/utilities/pull/build-pull-entities';
import {
planPullWrites,
type PullDeletion,
type PullWrite,
} from '@/cli/utilities/pull/plan-pull-writes';
import { planTranslationWrites } from '@/cli/utilities/pull/plan-translation-writes';
import {
readPullBaseManifest,
writePullBaseManifest,
} from '@/cli/utilities/pull/pull-base-file';
import { getApplicationMismatchMessage } from '@/cli/utilities/pull/get-application-mismatch-message';
import { isSdkResolvable } from '@/cli/utilities/pull/is-sdk-resolvable';
import { scanProjectSourceFiles } from '@/cli/utilities/pull/scan-project-source-files';
import { runSafe } from '@/cli/utilities/run-safe';
import { join } from 'node:path';
import { isDefined } from 'twenty-shared/utils';
export type AppPullOptions = {
appPath: string;
universalIdentifier?: string;
onProgress?: (message: string) => void;
};
export type AppPullResult = {
applicationDisplayName: string;
applicationUniversalIdentifier: string;
writes: PullWrite[];
deletions: PullDeletion[];
unchangedCount: number;
localOnlyRelativePaths: string[];
skipped: SkippedPullEntity[];
coverage: ApplicationExportCoverageEntry[];
unreadableRelativePaths: string[];
compiledTranslationEntryCountByLocale: Record<string, number>;
hadBase: boolean;
isSdkResolvable: boolean;
};
const EXPORT_REFUSAL_SUB_CODES = [
'APPLICATION_NOT_EXPORTABLE',
'STANDARD_APPLICATION_NOT_EXPORTABLE',
'APPLICATION_NOT_FOUND',
];
const innerAppPull = async (
options: AppPullOptions,
): Promise<CommandResult<AppPullResult>> => {
const { appPath, onProgress } = options;
if (!(await pathExists(join(appPath, 'package.json')))) {
return {
success: false,
error: {
code: APP_ERROR_CODES.PULL_FAILED,
message:
`No package.json found in ${appPath}.\n\n` +
' Scaffold a project first, then pull into it:\n' +
' npx create-twenty-app@latest my-app',
},
};
}
onProgress?.('Checking server...');
const apiService = new ApiService({ disableInterceptors: true });
const validateAuth = await apiService.validateAuth();
if (!validateAuth.serverUp) {
return {
success: false,
error: {
code: APP_ERROR_CODES.PULL_FAILED,
message:
'Cannot reach Twenty server.\n\n' +
' Start a local server:\n' +
' yarn twenty docker:start\n\n' +
' Check server status:\n' +
' yarn twenty docker:status',
},
};
}
if (!validateAuth.authValid) {
const outcome = await promptForReauthentication(
ConfigService.getActiveRemote(),
);
if (outcome !== 'reauthenticated') {
return {
success: false,
error: {
code: APP_ERROR_CODES.PULL_FAILED,
message:
'Authentication failed. Run `yarn twenty remote:add` to authenticate.',
},
};
}
}
onProgress?.('Reading local source files...');
const scannedFiles = await scanProjectSourceFiles(appPath);
const localApplicationFile = scannedFiles.find(
(scannedFile) => scannedFile.entityKey === ManifestEntityKey.Application,
);
const localApplicationUniversalIdentifier =
localApplicationFile?.universalIdentifier;
const universalIdentifier =
options.universalIdentifier ?? localApplicationUniversalIdentifier;
if (!isDefined(universalIdentifier)) {
return {
success: false,
error: {
code: APP_ERROR_CODES.PULL_FAILED,
message:
'Could not tell which application to pull.\n\n' +
' Pass the identifier explicitly:\n' +
' yarn twenty pull -u <universalIdentifier>',
},
};
}
const applicationMismatchMessage = getApplicationMismatchMessage({
requestedUniversalIdentifier: options.universalIdentifier,
localApplicationUniversalIdentifier,
hasLocalApplicationFile: isDefined(localApplicationFile),
});
if (isDefined(applicationMismatchMessage)) {
return {
success: false,
error: {
code: APP_ERROR_CODES.PULL_FAILED,
message: applicationMismatchMessage,
},
};
}
onProgress?.(`Exporting application ${universalIdentifier}...`);
const exportResult = await apiService.exportApplication(universalIdentifier);
if (!exportResult.success) {
const isRefusal = EXPORT_REFUSAL_SUB_CODES.some((subCode) =>
hasGraphQLErrorSubCode(exportResult.error, subCode),
);
return {
success: false,
error: {
code: APP_ERROR_CODES.PULL_FAILED,
message: isRefusal
? (getGraphQLErrorMessage(exportResult.error) ??
'The server refused to export this application')
: `Export failed: ${getGraphQLErrorMessage(exportResult.error) ?? exportResult.message ?? 'Unknown error'}`,
},
};
}
const applicationExport = exportResult.data;
const { manifest } = applicationExport;
onProgress?.('Planning source writes...');
const baseManifest = await readPullBaseManifest({
appPath,
applicationUniversalIdentifier: manifest.application.universalIdentifier,
});
const plan = planPullWrites({
manifest,
baseManifest,
scannedFiles,
workspaceUniversalIdentifiers: new Set(
applicationExport.coverage.map(
({ universalIdentifier }) => universalIdentifier,
),
),
});
const translationPlan = await planTranslationWrites({
appPath,
manifest,
baseManifest,
frontComponentSourcePaths: scannedFiles
.filter(
(scannedFile) =>
scannedFile.entityKey === ManifestEntityKey.FrontComponents,
)
.map((scannedFile) => join(appPath, scannedFile.relativePath)),
});
const writes = [...plan.writes, ...translationPlan.writes];
const deletions = [...plan.deletions, ...translationPlan.deletions];
onProgress?.('Writing source files...');
await applyPullWrites({ appPath, writes, deletions });
await writePullBaseManifest({ appPath, manifest });
return {
success: true,
data: {
applicationDisplayName: applicationExport.application.displayName,
applicationUniversalIdentifier:
applicationExport.application.universalIdentifier,
writes,
deletions,
unchangedCount: plan.unchanged.length,
localOnlyRelativePaths: plan.localOnlyRelativePaths,
skipped: plan.skipped,
coverage: applicationExport.coverage,
unreadableRelativePaths: scannedFiles
.filter((scannedFile) => !scannedFile.isReadable)
.map((scannedFile) => scannedFile.relativePath),
compiledTranslationEntryCountByLocale:
translationPlan.compiledEntryCountByLocale,
hadBase: isDefined(baseManifest),
isSdkResolvable: isSdkResolvable(appPath),
},
};
};
export const appPull = (
options: AppPullOptions,
): Promise<CommandResult<AppPullResult>> =>
runSafe(() => innerAppPull(options), APP_ERROR_CODES.PULL_FAILED);

View File

@@ -0,0 +1,326 @@
import { SERVER_ERROR_CODES, type CommandResult } from '@/cli/types';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { getConfigPath } from '@/cli/utilities/config/get-config-path';
import { runSafe } from '@/cli/utilities/run-safe';
import {
checkDockerRunning,
CONTAINER_NAME,
containerExists,
DEFAULT_PORT,
DEFAULT_TEST_PORT,
getContainerImageTag,
getContainerPort,
getDockerNotRunningMessage,
getImageForVersion,
isContainerRunning,
TEST_CONTAINER_NAME,
} from '@/cli/utilities/server/docker-container';
import {
checkServerHealth,
detectLocalServer,
} from '@/cli/utilities/server/detect-local-server';
import { serverUpgrade } from '@/cli/operations/server-upgrade';
import { checkServerVersionCompatibility } from '@/cli/utilities/version/check-server-version-compatibility';
import { resolveHighestEngineVersion } from '@/cli/utilities/version/resolve-highest-engine-version';
import { execSync, spawn, spawnSync } from 'node:child_process';
import chalk from 'chalk';
const HEALTH_POLL_INTERVAL_MS = 2000;
const HEALTH_TIMEOUT_MS = 180 * 1000;
const MILESTONE_START = '==> START ';
const MILESTONE_DONE = '==> DONE';
const waitForHealthy = async (
port: number,
containerName: string,
): Promise<boolean> => {
const startTime = Date.now();
const onProgress = (message: string) =>
process.stdout.write(chalk.gray(message));
const logStream = spawn(
'docker',
['logs', '-f', '--since', '1s', containerName],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
logStream.on('error', () => {});
let hasPendingStep = false;
const handleLogLine = (line: string) => {
const trimmed = line.trim();
const startIndex = trimmed.indexOf(MILESTONE_START);
const doneIndex = trimmed.indexOf(MILESTONE_DONE);
if (startIndex !== -1) {
if (hasPendingStep) {
onProgress('Done\n');
}
const message = trimmed.slice(startIndex + MILESTONE_START.length);
onProgress(`==> ${message}... `);
hasPendingStep = true;
} else if (doneIndex !== -1 && hasPendingStep) {
onProgress('Done\n');
hasPendingStep = false;
}
};
let logBuffer = '';
const onData = (chunk: Buffer) => {
logBuffer += chunk.toString();
const lines = logBuffer.split('\n');
logBuffer = lines.pop() ?? '';
lines.forEach(handleLogLine);
};
logStream.stdout?.on('data', onData);
logStream.stderr?.on('data', onData);
try {
while (Date.now() - startTime < HEALTH_TIMEOUT_MS) {
if (await checkServerHealth(port)) {
if (hasPendingStep) {
onProgress('Done\n');
}
return true;
}
await new Promise((resolve) =>
setTimeout(resolve, HEALTH_POLL_INTERVAL_MS),
);
}
if (hasPendingStep) {
onProgress('Failed\n');
}
return false;
} finally {
logStream.kill();
}
};
export type ServerStartOptions = {
port?: number;
test?: boolean;
version?: string;
onProgress?: (message: string) => void;
};
export type ServerStartResult = {
port: number;
url: string;
};
const innerServerStart = async (
options: ServerStartOptions = {},
): Promise<CommandResult<ServerStartResult>> => {
const { onProgress, test: isTest } = options;
const version = await resolveHighestEngineVersion(options.version);
const image = getImageForVersion(version);
const containerName = isTest ? TEST_CONTAINER_NAME : CONTAINER_NAME;
const defaultPort = isTest ? DEFAULT_TEST_PORT : DEFAULT_PORT;
const volumeData = isTest
? 'twenty-app-dev-test-data'
: 'twenty-app-dev-data';
const volumeStorage = isTest
? 'twenty-app-dev-test-storage'
: 'twenty-app-dev-storage';
if (checkDockerRunning() && containerExists(containerName)) {
const currentImage = getContainerImageTag(containerName);
if (currentImage !== null && currentImage !== image) {
onProgress?.(
`Existing container runs ${currentImage}; upgrading to ${image}...`,
);
const upgradeResult = await serverUpgrade({
version,
test: isTest,
onProgress,
});
if (!upgradeResult.success) {
return { success: false, error: upgradeResult.error };
}
}
}
const existingUrl = await detectLocalServer(options.port ?? defaultPort);
if (existingUrl) {
const configService = new ConfigService(
isTest ? { configPath: getConfigPath(true) } : undefined,
);
ConfigService.setActiveRemote('local');
await configService.setConfig({ apiUrl: existingUrl });
const port = new URL(existingUrl).port;
onProgress?.(`Twenty server detected on ${existingUrl}`);
return {
success: true,
data: { port: parseInt(port, 10), url: existingUrl },
};
}
if (!checkDockerRunning()) {
const retryCommand = [
'yarn twenty docker:start',
version !== 'latest' ? version : null,
isTest ? '--test' : null,
]
.filter(Boolean)
.join(' ');
return {
success: false,
error: {
code: SERVER_ERROR_CODES.DOCKER_NOT_RUNNING,
message: getDockerNotRunningMessage(retryCommand),
},
};
}
if (isContainerRunning(containerName)) {
const port = getContainerPort(containerName);
onProgress?.('Container is running, waiting for it to become healthy...');
const healthy = await waitForHealthy(port, containerName);
if (!healthy) {
return {
success: false,
error: {
code: SERVER_ERROR_CODES.HEALTH_TIMEOUT,
message:
'Twenty server did not become healthy in time.\n' +
"Check: 'yarn twenty docker:logs'",
},
};
}
const url = `http://localhost:${port}`;
const configService = new ConfigService(
isTest ? { configPath: getConfigPath(true) } : undefined,
);
ConfigService.setActiveRemote('local');
await configService.setConfig({ apiUrl: url });
onProgress?.(`Server running on ${url}`);
return { success: true, data: { port, url } };
}
let port = options.port ?? defaultPort;
if (containerExists(containerName)) {
const existingPort = getContainerPort(containerName);
if (existingPort !== port) {
onProgress?.(
`Existing container uses port ${existingPort}. Run 'yarn twenty docker:reset${isTest ? ' --test' : ''}' first to change ports.`,
);
}
port = existingPort;
onProgress?.('Starting existing container...');
execSync(`docker start ${containerName}`, { stdio: 'ignore' });
} else {
onProgress?.(
`Pulling Docker image (${image}) and starting Twenty container...`,
);
const runResult = spawnSync(
'docker',
[
'run',
'-d',
'--name',
containerName,
'-p',
`${port}:${port}`,
'-e',
`NODE_PORT=${port}`,
'-e',
`SERVER_URL=http://localhost:${port}`,
'-v',
`${volumeData}:/data/postgres`,
'-v',
`${volumeStorage}:/app/packages/twenty-server/.local-storage`,
image,
],
{ stdio: 'inherit' },
);
if (runResult.status !== 0) {
return {
success: false,
error: {
code: SERVER_ERROR_CODES.CONTAINER_START_FAILED,
message: 'Failed to start Twenty container.',
},
};
}
}
onProgress?.('Waiting for Twenty to be ready...');
const healthy = await waitForHealthy(port, containerName);
if (!healthy) {
return {
success: false,
error: {
code: SERVER_ERROR_CODES.HEALTH_TIMEOUT,
message:
'Twenty server did not become healthy in time.\n' +
"Check: 'yarn twenty docker:logs'",
},
};
}
const url = `http://localhost:${port}`;
const configService = new ConfigService(
isTest ? { configPath: getConfigPath(true) } : undefined,
);
ConfigService.setActiveRemote('local');
await configService.setConfig({ apiUrl: url });
onProgress?.(`Server running on ${url}`);
return { success: true, data: { port, url } };
};
export const serverStart = async (
options?: ServerStartOptions,
): Promise<CommandResult<ServerStartResult>> => {
const result = await runSafe(
() => innerServerStart(options),
SERVER_ERROR_CODES.CONTAINER_START_FAILED,
);
if (result.success) {
const containerName = options?.test ? TEST_CONTAINER_NAME : CONTAINER_NAME;
await checkServerVersionCompatibility(containerName);
}
return result;
};

View File

@@ -0,0 +1,153 @@
import { SERVER_ERROR_CODES, type CommandResult } from '@/cli/types';
import { runSafe } from '@/cli/utilities/run-safe';
import {
checkDockerRunning,
CONTAINER_NAME,
containerExists,
getContainerDigest,
getContainerPort,
getDockerNotRunningMessage,
getImageDigest,
getImageForVersion,
TEST_CONTAINER_NAME,
} from '@/cli/utilities/server/docker-container';
import { execSync, spawnSync } from 'node:child_process';
export type ServerUpgradeOptions = {
version?: string;
test?: boolean;
onProgress?: (message: string) => void;
};
export type ServerUpgradeResult = {
image: string;
imageUpdated: boolean;
containerRecreated: boolean;
};
const innerServerUpgrade = async (
options: ServerUpgradeOptions = {},
): Promise<CommandResult<ServerUpgradeResult>> => {
const { version = 'latest', test: isTest, onProgress } = options;
if (!checkDockerRunning()) {
const retryCommand = [
'yarn twenty docker:upgrade',
version !== 'latest' ? version : null,
isTest ? '--test' : null,
]
.filter(Boolean)
.join(' ');
return {
success: false,
error: {
code: SERVER_ERROR_CODES.DOCKER_NOT_RUNNING,
message: getDockerNotRunningMessage(retryCommand),
},
};
}
const containerName = isTest ? TEST_CONTAINER_NAME : CONTAINER_NAME;
const image = getImageForVersion(version);
const hasContainer = containerExists(containerName);
const previousDigest = hasContainer
? getContainerDigest(containerName)
: null;
onProgress?.(`Pulling ${image}...`);
const pullResult = spawnSync('docker', ['pull', image], {
stdio: 'inherit',
});
if (pullResult.status !== 0) {
return {
success: false,
error: {
code: SERVER_ERROR_CODES.IMAGE_UPGRADE_FAILED,
message: `Failed to pull image ${image}. Check that the version exists.`,
},
};
}
const pulledDigest = getImageDigest(image);
const imageUpdated = !previousDigest || previousDigest !== pulledDigest;
if (!hasContainer) {
onProgress?.('Image pulled. No existing container to upgrade.');
return {
success: true,
data: { image, imageUpdated, containerRecreated: false },
};
}
if (!imageUpdated) {
onProgress?.('Already up to date.');
return {
success: true,
data: { image, imageUpdated: false, containerRecreated: false },
};
}
const port = getContainerPort(containerName);
onProgress?.('Removing existing container...');
execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
const volumeData = isTest
? 'twenty-app-dev-test-data'
: 'twenty-app-dev-data';
const volumeStorage = isTest
? 'twenty-app-dev-test-storage'
: 'twenty-app-dev-storage';
onProgress?.('Starting container with new image...');
const runResult = spawnSync(
'docker',
[
'run',
'-d',
'--name',
containerName,
'-p',
`${port}:${port}`,
'-e',
`NODE_PORT=${port}`,
'-e',
`SERVER_URL=http://localhost:${port}`,
'-v',
`${volumeData}:/data/postgres`,
'-v',
`${volumeStorage}:/app/packages/twenty-server/.local-storage`,
image,
],
{ stdio: 'inherit' },
);
if (runResult.status !== 0) {
return {
success: false,
error: {
code: SERVER_ERROR_CODES.IMAGE_UPGRADE_FAILED,
message: 'Failed to start container with new image.',
},
};
}
return {
success: true,
data: { image, imageUpdated: true, containerRecreated: true },
};
};
export const serverUpgrade = (
options?: ServerUpgradeOptions,
): Promise<CommandResult<ServerUpgradeResult>> =>
runSafe(
() => innerServerUpgrade(options),
SERVER_ERROR_CODES.IMAGE_UPGRADE_FAILED,
);

View File

@@ -0,0 +1,65 @@
import { ApiService } from '@/cli/utilities/api/api-service';
import { readManifestFromFile } from '@/cli/utilities/build/manifest/manifest-reader';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { runSafe } from '@/cli/utilities/run-safe';
import { APP_ERROR_CODES, type CommandResult } from '@/cli/types';
export type AppUninstallOptions = {
appPath: string;
remote?: string;
};
const innerAppUninstall = async (
options: AppUninstallOptions,
): Promise<CommandResult> => {
if (options.remote) {
ConfigService.setActiveRemote(options.remote);
}
const configService = new ConfigService();
const apiService = new ApiService();
const manifest = await readManifestFromFile(options.appPath);
if (!manifest) {
return {
success: false,
error: {
code: APP_ERROR_CODES.MANIFEST_NOT_FOUND,
message: 'Manifest not found. Run `build` or `dev` first.',
},
};
}
const result = await apiService.uninstallApplication(
manifest.application.universalIdentifier,
);
if (!result.success) {
const errorMessage =
result.error instanceof Error
? result.error.message
: String(result.error ?? 'Unknown error');
return {
success: false,
error: {
code: APP_ERROR_CODES.UNINSTALL_FAILED,
message: errorMessage,
},
};
}
await configService.setConfig({
appRegistrationId: undefined,
appRegistrationClientId: undefined,
appAccessToken: undefined,
appRefreshToken: undefined,
});
return { success: true, data: undefined };
};
export const appUninstall = (
options: AppUninstallOptions,
): Promise<CommandResult> =>
runSafe(() => innerAppUninstall(options), APP_ERROR_CODES.UNINSTALL_FAILED);

View File

@@ -0,0 +1,92 @@
export type CommandError = {
code: string;
message: string;
details?: Record<string, unknown>;
};
export type CommandResult<T = void> =
| { success: true; data: T }
| { success: false; error: CommandError };
export const AUTH_ERROR_CODES = {
AUTH_FAILED: 'AUTH_FAILED',
NO_REMOTES: 'NO_REMOTES',
REMOTE_NOT_FOUND: 'REMOTE_NOT_FOUND',
OAUTH_NOT_SUPPORTED: 'OAUTH_NOT_SUPPORTED',
} as const;
export const APP_ERROR_CODES = {
MANIFEST_NOT_FOUND: 'MANIFEST_NOT_FOUND',
MANIFEST_BUILD_FAILED: 'MANIFEST_BUILD_FAILED',
BUILD_FAILED: 'BUILD_FAILED',
PUBLISH_FAILED: 'PUBLISH_FAILED',
INSTALL_FAILED: 'INSTALL_FAILED',
UNINSTALL_FAILED: 'UNINSTALL_FAILED',
SYNC_FAILED: 'SYNC_FAILED',
APPLY_ABORTED: 'APPLY_ABORTED',
TYPECHECK_FAILED: 'TYPECHECK_FAILED',
DEPLOY_FAILED: 'DEPLOY_FAILED',
PULL_FAILED: 'PULL_FAILED',
} as const;
export const SERVER_ERROR_CODES = {
DOCKER_NOT_INSTALLED: 'DOCKER_NOT_INSTALLED',
DOCKER_NOT_RUNNING: 'DOCKER_NOT_RUNNING',
CONTAINER_START_FAILED: 'CONTAINER_START_FAILED',
HEALTH_TIMEOUT: 'HEALTH_TIMEOUT',
IMAGE_UPGRADE_FAILED: 'IMAGE_UPGRADE_FAILED',
} as const;
export const FUNCTION_ERROR_CODES = {
FETCH_FUNCTIONS_FAILED: 'FETCH_FUNCTIONS_FAILED',
FUNCTION_NOT_FOUND: 'FUNCTION_NOT_FOUND',
EXECUTION_FAILED: 'EXECUTION_FAILED',
} as const;
export type AuthStatusResult = {
remote: string;
apiUrl: string;
apiKeyMasked: string | null;
isAuthenticated: boolean;
isValid: boolean;
};
export type AuthListRemote = {
name: string;
apiUrl: string;
hasCredentials: boolean;
isDefault: boolean;
};
export type TypecheckResult = {
errors: Array<{
text: string;
file: string;
line: number;
column: number;
}>;
};
export type FunctionExecutionResult = {
functionName: string;
data: unknown;
logs: string;
duration: number;
status: string;
error?: {
errorType: string;
errorMessage: string;
stackTrace: string;
};
};
export type ChokidarFsEvent =
| 'add'
| 'addDir'
| 'change'
| 'unlink'
| 'unlinkDir'
| 'ready'
| 'raw'
| 'error'
| 'all';

View File

@@ -0,0 +1,91 @@
import { ApiClient } from '@/cli/utilities/api/api-client';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/cli/utilities/config/config-service', () => ({
ConfigService: class {
getConfig = vi.fn().mockResolvedValue({ apiUrl: 'http://localhost:2020' });
setConfig = vi.fn();
},
}));
describe('ApiClient — frontend URL resolution', () => {
let client: ApiClient;
beforeEach(() => {
client = new ApiClient({ disableInterceptors: true });
});
describe('getFrontendUrl', () => {
it('returns origin of authorization_endpoint', async () => {
vi.spyOn(client.client, 'get').mockResolvedValueOnce({
data: { authorization_endpoint: 'http://app.localhost:3001/authorize' },
});
expect(await client.getFrontendUrl()).toBe('http://app.localhost:3001');
});
it('returns null when authorization_endpoint is missing', async () => {
vi.spyOn(client.client, 'get').mockResolvedValueOnce({ data: {} });
expect(await client.getFrontendUrl()).toBeNull();
});
it('returns null on network error', async () => {
vi.spyOn(client.client, 'get').mockRejectedValueOnce(new Error('boom'));
expect(await client.getFrontendUrl()).toBeNull();
});
});
describe('getWorkspaceFrontendUrl', () => {
it('prefers customUrl over subdomainUrl', async () => {
vi.spyOn(client.client, 'post').mockResolvedValueOnce({
data: {
data: {
currentWorkspace: {
workspaceUrls: {
customUrl: 'https://crm.acme.com',
subdomainUrl: 'http://apple.localhost:3001',
},
},
},
},
});
expect(await client.getWorkspaceFrontendUrl()).toBe(
'https://crm.acme.com',
);
});
it('returns subdomainUrl when customUrl is absent', async () => {
vi.spyOn(client.client, 'post').mockResolvedValueOnce({
data: {
data: {
currentWorkspace: {
workspaceUrls: {
subdomainUrl: 'http://apple.localhost:3001',
},
},
},
},
});
expect(await client.getWorkspaceFrontendUrl()).toBe(
'http://apple.localhost:3001',
);
});
it('falls back to OAuth discovery URL when workspace query returns null', async () => {
vi.spyOn(client.client, 'post').mockResolvedValueOnce({
data: { data: { currentWorkspace: null } },
});
vi.spyOn(client.client, 'get').mockResolvedValueOnce({
data: { authorization_endpoint: 'http://app.localhost:3001/authorize' },
});
expect(await client.getWorkspaceFrontendUrl()).toBe(
'http://app.localhost:3001',
);
});
});
});

View File

@@ -0,0 +1,99 @@
import { ApplicationApi } from '@/cli/utilities/api/application-api';
import { type AxiosInstance } from 'axios';
import { type Manifest } from 'twenty-shared/application';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const MANIFEST = {
application: { universalIdentifier: 'app', displayName: 'App' },
} as unknown as Manifest;
describe('ApplicationApi.syncApplication', () => {
const post = vi.fn();
const api = new ApplicationApi({ post } as unknown as AxiosInstance);
beforeEach(() => {
post.mockReset();
post.mockResolvedValue({
data: {
data: {
syncApplication: {
applicationUniversalIdentifier: 'app',
actions: [],
},
},
},
});
});
const sentOperation = () =>
post.mock.calls[0][1] as {
query: string;
variables: Record<string, unknown>;
};
it('sends only the manifest by default', async () => {
await api.syncApplication(MANIFEST);
const { query, variables } = sentOperation();
expect(query).toContain('mutation SyncApplication($manifest: JSON!)');
expect(query).toContain('syncApplication(manifest: $manifest)');
expect(variables).toEqual({ manifest: MANIFEST });
});
it('declares dryRun only when requested', async () => {
await api.syncApplication(MANIFEST, { dryRun: true });
const { query, variables } = sentOperation();
expect(query).toContain('$dryRun: Boolean');
expect(query).toContain('dryRun: $dryRun');
expect(query).not.toContain('inferDeletionFromMissingEntities');
expect(variables).toEqual({ manifest: MANIFEST, dryRun: true });
});
it('declares inferDeletionFromMissingEntities only when deletions are turned off', async () => {
await api.syncApplication(MANIFEST, {
inferDeletionFromMissingEntities: false,
});
const { query, variables } = sentOperation();
expect(query).toContain('$inferDeletionFromMissingEntities: Boolean');
expect(query).toContain(
'inferDeletionFromMissingEntities: $inferDeletionFromMissingEntities',
);
expect(query).not.toContain('dryRun');
expect(variables).toEqual({
manifest: MANIFEST,
inferDeletionFromMissingEntities: false,
});
});
it('leaves the default deletion behaviour implicit', async () => {
await api.syncApplication(MANIFEST, {
dryRun: true,
inferDeletionFromMissingEntities: true,
});
const { query, variables } = sentOperation();
expect(query).not.toContain('inferDeletionFromMissingEntities');
expect(variables).toEqual({ manifest: MANIFEST, dryRun: true });
});
it('declares both arguments for an additive plan', async () => {
await api.syncApplication(MANIFEST, {
dryRun: true,
inferDeletionFromMissingEntities: false,
});
const { variables } = sentOperation();
expect(variables).toEqual({
manifest: MANIFEST,
dryRun: true,
inferDeletionFromMissingEntities: false,
});
});
});

View File

@@ -0,0 +1,318 @@
import { isNonEmptyString } from '@sniptt/guards';
import axios, {
type AxiosInstance,
type AxiosResponse,
type InternalAxiosRequestConfig,
} from 'axios';
import chalk from 'chalk';
import { isDefined } from 'twenty-shared/utils';
import { promptForReauthentication } from '@/cli/utilities/auth/reauth-helper';
import { ConfigService } from '@/cli/utilities/config/config-service';
export class ApiClient {
readonly client: AxiosInstance;
readonly configService: ConfigService;
private readonly tokenOverride?: string;
readonly serverUrlOverride?: string;
private reauthAttempted: boolean = false;
constructor(options?: {
disableInterceptors?: boolean;
serverUrl?: string;
token?: string;
skipAuth?: boolean;
}) {
const {
disableInterceptors = false,
serverUrl,
token,
skipAuth = false,
} = options || {};
this.configService = new ConfigService();
this.tokenOverride = token;
this.serverUrlOverride = serverUrl;
this.client = axios.create();
this.client.interceptors.request.use(async (config) => {
const twentyConfig = await this.configService.getConfig();
config.baseURL = this.serverUrlOverride ?? twentyConfig.apiUrl;
if (!config.headers.Authorization && !skipAuth) {
const authToken = await this.resolveAuthToken();
if (authToken) {
config.headers.Authorization = `Bearer ${authToken}`;
}
}
return config;
});
if (disableInterceptors) {
return;
}
this.client.interceptors.response.use(
async (response) => {
// Handle auth errors returned as GraphQL errors in HTTP 200 responses
if (
response.status === 200 &&
response.data?.errors &&
Array.isArray(response.data.errors)
) {
const hasAuthError = response.data.errors.some(
(error: { message?: string; extensions?: { code?: string } }) =>
error.extensions?.code === 'UNAUTHENTICATED' ||
error.extensions?.code === 'FORBIDDEN' ||
(typeof error.message === 'string' &&
error.message.toLowerCase().includes('unauthenticated')),
);
if (hasAuthError) {
if (!this.reauthAttempted) {
const retried = await this.tryReauthenticateAndRetry(
response.config,
);
if (retried) {
return retried;
}
}
const authError = new Error(
'Authentication failed: GraphQL auth error in response',
) as Error & { response: typeof response };
authError.response = response;
throw authError;
}
}
return response;
},
async (error) => {
if (error.response?.status === 401 && error.config) {
if (!this.reauthAttempted) {
const retried = await this.tryReauthenticateAndRetry(error.config);
if (retried) {
return retried;
}
}
} else if (error.response?.status === 403) {
console.error(
chalk.red(
'Access denied. Check your API key and workspace permissions.',
),
);
} else if (error.code === 'ECONNREFUSED') {
console.error(
chalk.red('Cannot connect to Twenty server. Is it running?'),
);
}
throw error;
},
);
}
private async tryReauthenticateAndRetry(
config: InternalAxiosRequestConfig,
): Promise<AxiosResponse | null> {
// Prevent recursion: only attempt reauth once per client instance
this.reauthAttempted = true;
const remoteName = ConfigService.getActiveRemote();
console.error(`Authentication failed on remote "${remoteName}"`);
const outcome = await promptForReauthentication(remoteName);
if (outcome === 'reauthenticated') {
const authToken = await this.resolveAuthToken();
if (authToken) {
config.headers.Authorization = `Bearer ${authToken}`;
return this.client.request(config);
}
}
return null;
}
async getFrontendUrl(): Promise<string | null> {
try {
const response = await this.client.get(
'/.well-known/oauth-authorization-server',
{ headers: { Accept: 'application/json' } },
);
const authorizationEndpoint = response.data?.authorization_endpoint;
if (!isNonEmptyString(authorizationEndpoint)) {
return null;
}
return new URL(authorizationEndpoint).origin;
} catch {
return null;
}
}
async getWorkspaceFrontendUrl(): Promise<string | null> {
const workspaceFrontendUrl = await this.getCurrentWorkspaceFrontendUrl();
if (isDefined(workspaceFrontendUrl)) {
return workspaceFrontendUrl;
}
return this.getFrontendUrl();
}
private async getCurrentWorkspaceFrontendUrl(): Promise<string | null> {
try {
const query = `
query CurrentWorkspaceForFrontendUrl {
currentWorkspace {
workspaceUrls {
subdomainUrl
customUrl
}
}
}
`;
const response = await this.client.post(
'/metadata',
{ query },
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
const workspaceUrls =
response.data?.data?.currentWorkspace?.workspaceUrls;
const workspaceFrontendUrl = isNonEmptyString(workspaceUrls?.customUrl)
? workspaceUrls.customUrl
: workspaceUrls?.subdomainUrl;
if (!isNonEmptyString(workspaceFrontendUrl)) {
return null;
}
return new URL(workspaceFrontendUrl).origin;
} catch {
return null;
}
}
async validateAuth(): Promise<{ authValid: boolean; serverUp: boolean }> {
try {
const query = `
query CurrentWorkspace {
currentWorkspace {
id
}
}
`;
const response = await this.client.post(
'/metadata',
{
query,
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
return {
authValid: response.status === 200 && !response.data.errors,
serverUp: response.status === 200,
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
return {
authValid: false,
serverUp: true,
};
}
return {
authValid: false,
serverUp: false,
};
}
}
async refreshToken(): Promise<string | null> {
const config = await this.configService.getConfig();
if (
!config.twentyCLIRefreshToken ||
!config.twentyCLIRegistrationClientId
) {
return null;
}
try {
const tokenResponse = await axios.post(`${config.apiUrl}/oauth/token`, {
grant_type: 'refresh_token',
refresh_token: config.twentyCLIRefreshToken,
client_id: config.twentyCLIRegistrationClientId,
});
const { access_token: newAccessToken, refresh_token: newRefreshToken } =
tokenResponse.data;
await this.configService.setConfig({
twentyCLIAccessToken: newAccessToken,
...(newRefreshToken ? { twentyCLIRefreshToken: newRefreshToken } : {}),
});
return newAccessToken;
} catch {
return null;
}
}
async resolveAuthToken(): Promise<string | undefined> {
if (this.tokenOverride) {
return this.tokenOverride;
}
const config = await this.configService.getConfig();
const cliToken = config.twentyCLIAccessToken;
if (cliToken && this.isTokenExpired(cliToken)) {
const refreshed = await this.refreshToken();
if (refreshed) {
return refreshed;
}
}
return cliToken ?? config.apiKey;
}
private isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(
Buffer.from(token.split('.')[1], 'base64').toString(),
);
const EXPIRATION_MARGIN_IN_SECONDS = 30;
return (
payload.exp * 1_000 < Date.now() + EXPIRATION_MARGIN_IN_SECONDS * 1_000
);
} catch {
return false;
}
}
}

View File

@@ -0,0 +1,14 @@
type SuccessfulApiResponse<TData = unknown> = {
success: true;
data: TData;
message?: string;
};
type FailingApiResponse<TError = unknown> = {
success: false;
error?: TError;
message?: string;
isAuthError?: boolean;
};
export type ApiResponse<TData = unknown, TError = unknown> =
| SuccessfulApiResponse<TData>
| FailingApiResponse<TError>;

View File

@@ -0,0 +1,166 @@
import { ApiClient } from '@/cli/utilities/api/api-client';
import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
import { ApplicationApi } from '@/cli/utilities/api/application-api';
import { FileApi } from '@/cli/utilities/api/file-api';
import { LogicFunctionApi } from '@/cli/utilities/api/logic-function-api';
import { SchemaApi } from '@/cli/utilities/api/schema-api';
import { type ApplicationExport } from '@/cli/utilities/pull/application-export-type';
import { type Manifest } from 'twenty-shared/application';
import {
type MetadataValidationErrorResponse,
type SyncAction,
} from 'twenty-shared/metadata';
type ApiServiceOptions = {
disableInterceptors?: boolean;
serverUrl?: string;
token?: string;
skipAuth?: boolean;
};
export class ApiService {
private apiClient: ApiClient;
private applicationApi: ApplicationApi;
private schemaApi: SchemaApi;
private logicFunctionApi: LogicFunctionApi;
private fileApi: FileApi;
constructor(options?: ApiServiceOptions) {
this.apiClient = new ApiClient(options);
this.applicationApi = new ApplicationApi(this.apiClient.client);
this.schemaApi = new SchemaApi(this.apiClient.client);
this.logicFunctionApi = new LogicFunctionApi(this.apiClient);
this.fileApi = new FileApi(this.apiClient.client);
}
validateAuth(): Promise<{ authValid: boolean; serverUp: boolean }> {
return this.apiClient.validateAuth();
}
getWorkspaceFrontendUrl(): Promise<string | null> {
return this.apiClient.getWorkspaceFrontendUrl();
}
refreshToken(): Promise<string | null> {
return this.apiClient.refreshToken();
}
findApplicationRegistrationByUniversalIdentifier(
...args: Parameters<
ApplicationApi['findApplicationRegistrationByUniversalIdentifier']
>
) {
return this.applicationApi.findApplicationRegistrationByUniversalIdentifier(
...args,
);
}
createApplicationRegistration(
...args: Parameters<ApplicationApi['createApplicationRegistration']>
) {
return this.applicationApi.createApplicationRegistration(...args);
}
rotateApplicationRegistrationClientSecret(
...args: Parameters<
ApplicationApi['rotateApplicationRegistrationClientSecret']
>
) {
return this.applicationApi.rotateApplicationRegistrationClientSecret(
...args,
);
}
createDevelopmentApplication(
...args: Parameters<ApplicationApi['createDevelopmentApplication']>
) {
return this.applicationApi.createDevelopmentApplication(...args);
}
generateApplicationToken(
...args: Parameters<ApplicationApi['generateApplicationToken']>
) {
return this.applicationApi.generateApplicationToken(...args);
}
syncApplication(
manifest: Manifest,
options?: { dryRun?: boolean; inferDeletionFromMissingEntities?: boolean },
): Promise<
ApiResponse<
{
applicationUniversalIdentifier: string;
actions: SyncAction[];
},
MetadataValidationErrorResponse
>
> {
return this.applicationApi.syncApplication(manifest, options);
}
exportApplication(
universalIdentifier: string,
): Promise<ApiResponse<ApplicationExport>> {
return this.applicationApi.exportApplication(universalIdentifier);
}
uninstallApplication(universalIdentifier: string): Promise<ApiResponse> {
return this.applicationApi.uninstallApplication(universalIdentifier);
}
syncMarketplaceCatalog(): Promise<ApiResponse<boolean>> {
return this.applicationApi.syncMarketplaceCatalog();
}
getSchema(options?: {
appAccessToken?: string;
}): Promise<ApiResponse<string>> {
return this.schemaApi.getSchema(options);
}
getMetadataSchema(options?: {
appAccessToken?: string;
}): Promise<ApiResponse<string>> {
return this.schemaApi.getMetadataSchema(options);
}
findLogicFunctions(
...args: Parameters<LogicFunctionApi['findLogicFunctions']>
) {
return this.logicFunctionApi.findLogicFunctions(...args);
}
executeLogicFunction(
...args: Parameters<LogicFunctionApi['executeLogicFunction']>
) {
return this.logicFunctionApi.executeLogicFunction(...args);
}
subscribeToLogs(...args: Parameters<LogicFunctionApi['subscribeToLogs']>) {
return this.logicFunctionApi.subscribeToLogs(...args);
}
uploadAppTarball(...args: Parameters<FileApi['uploadAppTarball']>) {
return this.fileApi.uploadAppTarball(...args);
}
installTarballApp(...args: Parameters<FileApi['installTarballApp']>) {
return this.fileApi.installTarballApp(...args);
}
uploadFile(...args: Parameters<FileApi['uploadFile']>) {
return this.fileApi.uploadFile(...args);
}
createApplicationFileUploads(
...args: Parameters<FileApi['createApplicationFileUploads']>
) {
return this.fileApi.createApplicationFileUploads(...args);
}
completeApplicationFileUploads(
...args: Parameters<FileApi['completeApplicationFileUploads']>
) {
return this.fileApi.completeApplicationFileUploads(...args);
}
}

View File

@@ -0,0 +1,507 @@
import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
import { type ApplicationExport } from '@/cli/utilities/pull/application-export-type';
import { serializeError } from '@/cli/utilities/error/serialize-error';
import axios, { type AxiosInstance } from 'axios';
import { type Manifest } from 'twenty-shared/application';
import {
type MetadataValidationErrorResponse,
type SyncAction,
} from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
export class ApplicationApi {
constructor(private readonly client: AxiosInstance) {}
async syncMarketplaceCatalog(): Promise<ApiResponse<boolean>> {
try {
const query = `
mutation SyncMarketplaceCatalog {
syncMarketplaceCatalog
}
`;
const response = await this.client.post(
'/metadata',
{ query },
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0],
};
}
return {
success: true,
data: response.data.data.syncMarketplaceCatalog,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async findApplicationRegistrationByUniversalIdentifier(
universalIdentifier: string,
): Promise<
ApiResponse<{
id: string;
universalIdentifier: string;
name: string;
oAuthClientId: string;
} | null>
> {
try {
const query = `
query FindApplicationRegistrationByUniversalIdentifier($universalIdentifier: String!) {
findApplicationRegistrationByUniversalIdentifier(universalIdentifier: $universalIdentifier) {
id
universalIdentifier
name
oAuthClientId
}
}
`;
const response = await this.client.post(
'/metadata',
{
query,
variables: { universalIdentifier },
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0],
};
}
return {
success: true,
data: response.data.data
.findApplicationRegistrationByUniversalIdentifier,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async exportApplication(
universalIdentifier: string,
): Promise<ApiResponse<ApplicationExport>> {
try {
const query = `
query ExportApplication($universalIdentifier: UUID!) {
exportApplication(universalIdentifier: $universalIdentifier) {
application {
universalIdentifier
displayName
sourceType
}
manifest
coverage {
metadataName
universalIdentifier
status
reason
}
files {
folder
path
content
}
}
}
`;
const response = await this.client.post(
'/metadata',
{
query,
variables: { universalIdentifier },
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0],
};
}
return {
success: true,
data: response.data.data.exportApplication,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async createApplicationRegistration(input: {
name: string;
universalIdentifier: string;
}): Promise<
ApiResponse<{
applicationRegistration: {
id: string;
universalIdentifier: string;
oAuthClientId: string;
};
clientSecret: string;
}>
> {
try {
const mutation = `
mutation CreateApplicationRegistration($input: CreateApplicationRegistrationInput!) {
createApplicationRegistration(input: $input) {
applicationRegistration {
id
universalIdentifier
oAuthClientId
}
clientSecret
}
}
`;
const response = await this.client.post(
'/metadata',
{
query: mutation,
variables: { input },
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0],
};
}
return {
success: true,
data: response.data.data.createApplicationRegistration,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async rotateApplicationRegistrationClientSecret(
id: string,
): Promise<ApiResponse<{ clientSecret: string }>> {
try {
const mutation = `
mutation RotateApplicationRegistrationClientSecret($id: String!) {
rotateApplicationRegistrationClientSecret(id: $id) {
clientSecret
}
}
`;
const response = await this.client.post(
'/metadata',
{
query: mutation,
variables: { id },
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0],
};
}
return {
success: true,
data: response.data.data.rotateApplicationRegistrationClientSecret,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async generateApplicationToken(applicationId: string): Promise<
ApiResponse<{
applicationAccessToken: { token: string; expiresAt: string };
applicationRefreshToken: { token: string; expiresAt: string };
}>
> {
try {
const mutation = `
mutation GenerateApplicationToken($applicationId: UUID!) {
generateApplicationToken(applicationId: $applicationId) {
applicationAccessToken {
token
expiresAt
}
applicationRefreshToken {
token
expiresAt
}
}
}
`;
const response = await this.client.post(
'/metadata',
{
query: mutation,
variables: { applicationId },
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0],
};
}
return {
success: true,
data: response.data.data.generateApplicationToken,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async createDevelopmentApplication(input: {
universalIdentifier: string;
name: string;
}): Promise<ApiResponse<{ id: string; universalIdentifier: string }>> {
try {
const mutation = `
mutation CreateDevelopmentApplication($universalIdentifier: String!, $name: String!) {
createDevelopmentApplication(universalIdentifier: $universalIdentifier, name: $name) {
id
universalIdentifier
}
}
`;
const response = await this.client.post(
'/metadata',
{
query: mutation,
variables: input,
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0],
};
}
return {
success: true,
data: response.data.data.createDevelopmentApplication,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async syncApplication(
manifest: Manifest,
options?: { dryRun?: boolean; inferDeletionFromMissingEntities?: boolean },
): Promise<
ApiResponse<
{
applicationUniversalIdentifier: string;
actions: SyncAction[];
},
MetadataValidationErrorResponse
>
> {
try {
const optionalArguments = [
options?.dryRun ? 'dryRun' : null,
options?.inferDeletionFromMissingEntities === false
? 'inferDeletionFromMissingEntities'
: null,
].filter(isDefined);
const variableDeclarations = [
'$manifest: JSON!',
...optionalArguments.map((argument) => `$${argument}: Boolean`),
].join(', ');
const argumentAssignments = [
'manifest: $manifest',
...optionalArguments.map((argument) => `${argument}: $${argument}`),
].join(', ');
const mutation = `
mutation SyncApplication(${variableDeclarations}) {
syncApplication(${argumentAssignments}) {
applicationUniversalIdentifier
actions
}
}
`;
const variables = {
manifest,
...(options?.dryRun ? { dryRun: true } : {}),
...(options?.inferDeletionFromMissingEntities === false
? { inferDeletionFromMissingEntities: false }
: {}),
};
const response = await this.client.post(
'/metadata',
{
query: mutation,
variables,
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0]?.extensions,
message: response.data.errors[0]?.message,
};
}
return {
success: true,
data: response.data.data.syncApplication,
message: `Successfully synced application: ${manifest.application.displayName}`,
};
} catch (error) {
return {
success: false,
message: serializeError(error),
};
}
}
async uninstallApplication(
universalIdentifier: string,
): Promise<ApiResponse> {
try {
const mutation = `
mutation UninstallApplication($universalIdentifier: String!) {
uninstallApplication(universalIdentifier: $universalIdentifier)
}
`;
const variables = { universalIdentifier };
const response = await this.client.post(
'/metadata',
{
query: mutation,
variables,
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error:
response.data.errors[0]?.message || 'Failed to delete application',
};
}
return {
success: true,
data: response.data.data.uninstallApplication,
message: 'Successfully uninstalled application',
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
return {
success: false,
error: error.response.data?.errors?.[0]?.message || error.message,
};
}
throw error;
}
}
}

View File

@@ -0,0 +1,438 @@
import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
import { serializeError } from '@/cli/utilities/error/serialize-error';
import axios, { type AxiosInstance, type AxiosResponse } from 'axios';
import * as fs from 'fs';
import * as path from 'path';
import { type MetadataValidationErrorResponse } from 'twenty-shared/metadata';
import { type FileFolder } from 'twenty-shared/types';
import { pascalCase } from 'twenty-shared/utils';
const MIME_TYPES: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
'.ico': 'image/x-icon',
'.pdf': 'application/pdf',
'.doc': 'application/msword',
'.docx':
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx':
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.txt': 'text/plain',
'.csv': 'text/csv',
'.json': 'application/json',
'.xml': 'application/xml',
'.zip': 'application/zip',
'.tar': 'application/x-tar',
'.gz': 'application/gzip',
'.mp3': 'audio/mpeg',
'.mp4': 'video/mp4',
'.avi': 'video/x-msvideo',
'.mov': 'video/quicktime',
'.js': 'application/javascript',
'.ts': 'application/typescript',
'.jsx': 'application/javascript',
'.tsx': 'application/typescript',
'.html': 'text/html',
'.css': 'text/css',
};
const getMimeType = (filename: string): string => {
const ext = path.extname(filename).toLowerCase();
return MIME_TYPES[ext] || 'application/octet-stream';
};
export type ApplicationFileUploadRequest = {
fileFolder: FileFolder;
filePath: string;
size: number;
};
export type ApplicationFileUploadTarget = {
fileId: string;
filePath: string;
uploadUrl: string;
contentType: string;
expiresAt: string;
};
export type ApplicationFileUploadError = {
fileFolder: string;
filePath: string;
message: string;
};
export type CreateApplicationFileUploadsResult = {
targets: ApplicationFileUploadTarget[];
errors: ApplicationFileUploadError[];
};
export type ApplicationFileCompletionError = {
fileId: string;
message: string;
};
export type CompleteApplicationFileUploadsResult = {
files: { id: string; path: string }[];
errors: ApplicationFileCompletionError[];
};
export class FileApi {
constructor(private readonly client: AxiosInstance) {}
// TODO: Migrate to MetadataClient once available
// (see https://github.com/twentyhq/core-team-issues/issues/2289)
async uploadAppTarball({
tarballBuffer,
universalIdentifier,
}: {
tarballBuffer: Buffer;
universalIdentifier?: string;
}): Promise<
ApiResponse<{
id: string;
universalIdentifier: string;
name: string;
}>
> {
try {
const mutation = `
mutation UploadAppTarball($file: Upload!, $universalIdentifier: String) {
uploadAppTarball(file: $file, universalIdentifier: $universalIdentifier) {
id
universalIdentifier
name
}
}
`;
const operations = JSON.stringify({
query: mutation,
variables: {
file: null,
universalIdentifier: universalIdentifier ?? null,
},
});
const map = JSON.stringify({
'0': ['variables.file'],
});
const formData = new FormData();
formData.append('operations', operations);
formData.append('map', map);
formData.append(
'0',
new Blob([new Uint8Array(tarballBuffer)], {
type: 'application/gzip',
}),
'app.tar.gz',
);
const response: AxiosResponse = await this.client.post(
'/metadata',
formData,
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0]?.message || 'Failed to upload tarball',
};
}
return {
success: true,
data: response.data.data.uploadAppTarball,
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
if (error.response.status === 401) {
return {
success: false,
error: error.response.data?.errors?.[0]?.message || error.message,
isAuthError: true,
};
}
return {
success: false,
error: error.response.data?.errors?.[0]?.message || error.message,
};
}
return {
success: false,
error,
};
}
}
async installTarballApp({
universalIdentifier,
}: {
universalIdentifier: string;
}): Promise<ApiResponse<boolean, MetadataValidationErrorResponse>> {
try {
const mutation = `
mutation InstallApplication($universalIdentifier: String!) {
installApplication(universalIdentifier: $universalIdentifier) {
id
}
}
`;
const response: AxiosResponse = await this.client.post(
'/metadata',
{
query: mutation,
variables: { universalIdentifier },
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0]?.extensions,
message:
response.data.errors[0]?.message || 'Failed to install application',
};
}
return {
success: true,
data: response.data.data.installApplication,
};
} catch (error) {
return {
success: false,
message: serializeError(error),
};
}
}
async uploadFile({
filePath,
builtHandlerPath,
fileFolder,
applicationUniversalIdentifier,
}: {
filePath: string;
builtHandlerPath: string;
fileFolder: FileFolder;
applicationUniversalIdentifier: string;
}): Promise<ApiResponse<boolean>> {
try {
const absolutePath = path.resolve(filePath);
if (!fs.existsSync(absolutePath)) {
return {
success: false,
error: `File not found: ${absolutePath}`,
};
}
const filename = path.basename(absolutePath);
const buffer = fs.readFileSync(absolutePath);
const mimeType = getMimeType(filename);
const mutation = `
mutation UploadApplicationFile($file: Upload!, $applicationUniversalIdentifier: String!, $fileFolder: FileFolder!, $filePath: String!) {
uploadApplicationFile(file: $file, applicationUniversalIdentifier: $applicationUniversalIdentifier, fileFolder: $fileFolder, filePath: $filePath)
{ path }
}
`;
const graphqlEnumFileFolder = pascalCase(fileFolder);
const operations = JSON.stringify({
query: mutation,
variables: {
file: null,
applicationUniversalIdentifier,
filePath: builtHandlerPath,
fileFolder: graphqlEnumFileFolder,
},
});
const map = JSON.stringify({
'0': ['variables.file'],
});
const formData = new FormData();
formData.append('operations', operations);
formData.append('map', map);
formData.append(
'0',
new Blob([new Uint8Array(buffer)], { type: mimeType }),
filename,
);
const response: AxiosResponse = await this.client.post(
'/metadata',
formData,
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0]?.message || 'Failed to upload file',
};
}
return {
success: true,
data: response.data.data.uploadApplicationFile,
message: `Successfully uploaded ${filename}`,
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
return {
success: false,
error: error.response.data?.errors?.[0]?.message || error.message,
};
}
return {
success: false,
error,
};
}
}
async createApplicationFileUploads({
applicationUniversalIdentifier,
files,
}: {
applicationUniversalIdentifier: string;
files: ApplicationFileUploadRequest[];
}): Promise<ApiResponse<CreateApplicationFileUploadsResult>> {
const mutation = `
mutation CreateApplicationFileUploads($applicationUniversalIdentifier: String!, $files: [ApplicationFileUploadRequestInput!]!) {
createApplicationFileUploads(applicationUniversalIdentifier: $applicationUniversalIdentifier, files: $files) {
targets {
fileId
filePath
uploadUrl
contentType
expiresAt
}
errors {
fileFolder
filePath
message
}
}
}
`;
return this.runMetadataMutation<CreateApplicationFileUploadsResult>({
mutation,
variables: {
applicationUniversalIdentifier,
files: files.map(({ fileFolder, filePath, size }) => ({
fileFolder: pascalCase(fileFolder),
filePath,
size,
})),
},
resultKey: 'createApplicationFileUploads',
defaultErrorMessage: 'Failed to create application file uploads',
});
}
async completeApplicationFileUploads({
applicationUniversalIdentifier,
fileIds,
}: {
applicationUniversalIdentifier: string;
fileIds: string[];
}): Promise<ApiResponse<CompleteApplicationFileUploadsResult>> {
const mutation = `
mutation CompleteApplicationFileUploads($applicationUniversalIdentifier: String!, $fileIds: [UUID!]!) {
completeApplicationFileUploads(applicationUniversalIdentifier: $applicationUniversalIdentifier, fileIds: $fileIds) {
files {
id
path
}
errors {
fileId
message
}
}
}
`;
return this.runMetadataMutation<CompleteApplicationFileUploadsResult>({
mutation,
variables: { applicationUniversalIdentifier, fileIds },
resultKey: 'completeApplicationFileUploads',
defaultErrorMessage: 'Failed to complete application file uploads',
});
}
private async runMetadataMutation<TData>({
mutation,
variables,
resultKey,
defaultErrorMessage,
}: {
mutation: string;
variables: Record<string, unknown>;
resultKey: string;
defaultErrorMessage: string;
}): Promise<ApiResponse<TData>> {
try {
const response: AxiosResponse = await this.client.post(
'/metadata',
{ query: mutation, variables },
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0]?.message || defaultErrorMessage,
};
}
return {
success: true,
data: response.data.data[resultKey],
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
return {
success: false,
error: error.response.data?.errors?.[0]?.message || error.message,
};
}
return {
success: false,
error,
};
}
}
}

View File

@@ -0,0 +1,188 @@
import { type ApiClient } from '@/cli/utilities/api/api-client';
import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
import { createClient } from 'graphql-sse';
export class LogicFunctionApi {
constructor(private readonly apiClient: ApiClient) {}
async findLogicFunctions(): Promise<
ApiResponse<
Array<{
id: string;
name: string;
universalIdentifier: string;
applicationId: string | null;
}>
>
> {
try {
const query = `
query FindManyLogicFunctions {
findManyLogicFunctions {
id
name
universalIdentifier
applicationId
}
}
`;
const response = await this.apiClient.client.post(
'/metadata',
{ query },
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error:
response.data.errors[0]?.message || 'Failed to fetch functions',
};
}
return {
success: true,
data: response.data.data.findManyLogicFunctions,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async executeLogicFunction({
functionId,
payload,
}: {
functionId: string;
payload: Record<string, unknown>;
}): Promise<
ApiResponse<{
data: unknown;
logs: string;
duration: number;
status: string;
error?: {
errorType: string;
errorMessage: string;
stackTrace: string;
};
}>
> {
try {
const mutation = `
mutation ExecuteOneLogicFunction($input: ExecuteOneLogicFunctionInput!) {
executeOneLogicFunction(input: $input) {
data
logs
duration
status
error
}
}
`;
const variables = {
input: {
id: functionId,
payload,
},
};
const response = await this.apiClient.client.post(
'/metadata',
{
query: mutation,
variables,
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error:
response.data.errors[0]?.message ||
'Failed to execute logic function',
};
}
return {
success: true,
data: response.data.data.executeOneLogicFunction,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async subscribeToLogs({
applicationUniversalIdentifier,
functionUniversalIdentifier,
functionName,
}: {
applicationUniversalIdentifier: string;
functionUniversalIdentifier?: string;
functionName?: string;
}) {
const twentyConfig = await this.apiClient.configService.getConfig();
const baseUrl = this.apiClient.serverUrlOverride ?? twentyConfig.apiUrl;
const wsClient = createClient({
url: baseUrl + '/metadata',
headers: async () => {
const authToken = await this.apiClient.resolveAuthToken();
return {
Authorization: authToken ? `Bearer ${authToken}` : '',
'Content-Type': 'application/json',
Accept: 'text/event-stream',
};
},
});
const query = `
subscription SubscribeToLogs($input: LogicFunctionLogsInput!) {
logicFunctionLogs(input: $input) {
logs
}
}
`;
const variables = {
input: {
applicationUniversalIdentifier,
universalIdentifier: functionUniversalIdentifier,
name: functionName,
},
};
wsClient.subscribe<{ logicFunctionLogs: { logs: string } }>(
{
query,
variables,
},
{
next: ({ data }) => console.log(data?.logicFunctionLogs.logs),
error: (err: unknown) => console.error(err),
complete: () => console.log('Completed'),
},
);
}
}

View File

@@ -0,0 +1,70 @@
import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
import axios, { type AxiosInstance } from 'axios';
import { buildClientSchema, getIntrospectionQuery, printSchema } from 'graphql';
export class SchemaApi {
constructor(private readonly client: AxiosInstance) {}
async getSchema(options?: {
appAccessToken?: string;
}): Promise<ApiResponse<string>> {
return this.introspectEndpoint('/graphql', options);
}
async getMetadataSchema(options?: {
appAccessToken?: string;
}): Promise<ApiResponse<string>> {
return this.introspectEndpoint('/metadata', options);
}
private async introspectEndpoint(
endpoint: string,
options?: { appAccessToken?: string },
): Promise<ApiResponse<string>> {
try {
const introspectionQuery = getIntrospectionQuery();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: '*/*',
};
if (options?.appAccessToken) {
headers.Authorization = `Bearer ${options.appAccessToken}`;
}
const response = await this.client.post(
endpoint,
{
query: introspectionQuery,
},
{ headers },
);
if (response.data.errors) {
return {
success: false,
error: `GraphQL introspection errors: ${JSON.stringify(response.data.errors)}`,
};
}
const schema = buildClientSchema(response.data.data);
return {
success: true,
data: printSchema(schema),
message: `Successfully loaded schema from ${endpoint}`,
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
return {
success: false,
error:
error.response.data?.errors?.[0]?.message ||
`Failed to load schema from ${endpoint}`,
};
}
throw error;
}
}
}

View File

@@ -0,0 +1,23 @@
import { join } from 'path';
import { pathExists } from '@/cli/utilities/file/fs-utils';
export const APP_CONFIG_CANDIDATE_PATHS = [
'src/application.config.ts',
'src/application-config.ts',
'src/applicationConfig.ts',
];
export const findAppConfigPath = async (
projectRoot: string,
): Promise<string | null> => {
for (const candidate of APP_CONFIG_CANDIDATE_PATHS) {
const absolute = join(projectRoot, candidate);
if (await pathExists(absolute)) {
return absolute;
}
}
return null;
};

View File

@@ -0,0 +1,38 @@
import {
APP_CONFIG_CANDIDATE_PATHS,
findAppConfigPath,
} from '@/cli/utilities/application/find-app-config-path';
import { extractManifestFromFile } from '@/cli/utilities/build/manifest/manifest-extract-config-from-file';
type ApplicationConfigWithUniversalIdentifier = {
universalIdentifier?: string;
};
// Scaffolding derives deterministic identifiers from the application
// universal identifier, so nothing can be generated before defineApplication
// declares one.
export const getApplicationUniversalIdentifierOrThrow = async (
appPath: string,
): Promise<string> => {
const configPath = await findAppConfigPath(appPath);
if (configPath === null) {
throw new Error(
`Could not find the application config file (expected one of: ${APP_CONFIG_CANDIDATE_PATHS.join(', ')}). Create it with defineApplication({ universalIdentifier: ... }) before generating entities.`,
);
}
const { config } =
await extractManifestFromFile<ApplicationConfigWithUniversalIdentifier>({
appPath,
filePath: configPath,
});
if (!config?.universalIdentifier) {
throw new Error(
`defineApplication in ${configPath} must declare a universalIdentifier before generating entities.`,
);
}
return config.universalIdentifier;
};

View File

@@ -0,0 +1,144 @@
import http from 'node:http';
import { startCallbackServer } from '../callback-server';
const httpGet = (
url: string,
): Promise<{
status: number;
body: string;
headers: http.IncomingHttpHeaders;
}> =>
new Promise((resolve, reject) => {
http
.get(url, (res) => {
let body = '';
res.on('data', (chunk: string) => (body += chunk));
res.on('end', () =>
resolve({
status: res.statusCode ?? 0,
body,
headers: res.headers,
}),
);
})
.on('error', reject);
});
describe('startCallbackServer', () => {
it('should start on a random port and provide a callback URL', async () => {
const server = await startCallbackServer();
try {
expect(server.port).toBeGreaterThan(0);
expect(server.callbackUrl).toBe(
`http://127.0.0.1:${server.port}/callback`,
);
} finally {
server.close();
}
});
it('should resolve with the authorization code on successful callback', async () => {
const server = await startCallbackServer();
try {
const waitPromise = server.waitForCallback();
await httpGet(`${server.callbackUrl}?code=test-auth-code`);
const result = await waitPromise;
expect(result).toEqual({ success: true, code: 'test-auth-code' });
} finally {
server.close();
}
});
it('should send the success page with an explicit Content-Length (not chunked)', async () => {
const server = await startCallbackServer();
try {
const waitPromise = server.waitForCallback();
const response = await httpGet(
`${server.callbackUrl}?code=test-auth-code`,
);
await waitPromise;
expect(response.status).toBe(200);
expect(response.headers['transfer-encoding']).toBeUndefined();
expect(response.headers['content-length']).toBe(
String(Buffer.byteLength(response.body)),
);
expect(response.body).toContain('Authentication successful');
} finally {
server.close();
}
});
it('should deliver the full page even when the server is closed right after the callback resolves', async () => {
const server = await startCallbackServer();
const responsePromise = httpGet(
`${server.callbackUrl}?code=test-auth-code`,
);
await server.waitForCallback();
server.close();
const response = await responsePromise;
expect(response.status).toBe(200);
expect(response.body).toContain('Authentication successful');
expect(response.body).toContain('</html>');
});
it('should resolve with error when callback contains an error', async () => {
const server = await startCallbackServer();
try {
const waitPromise = server.waitForCallback();
await httpGet(`${server.callbackUrl}?error=access_denied`);
const result = await waitPromise;
expect(result).toEqual({ success: false, error: 'access_denied' });
} finally {
server.close();
}
});
it('should return 404 for non-callback paths', async () => {
const server = await startCallbackServer();
try {
const response = await httpGet(
`http://127.0.0.1:${server.port}/other-path`,
);
expect(response.status).toBe(404);
} finally {
server.close();
}
});
it('should time out if no callback is received', async () => {
const server = await startCallbackServer({ timeoutMs: 500 });
try {
const result = await server.waitForCallback();
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('Timed out');
}
} finally {
server.close();
}
});
});

View File

@@ -0,0 +1,41 @@
import crypto from 'node:crypto';
import { generatePkceChallenge } from '../pkce';
describe('generatePkceChallenge', () => {
it('should return a code verifier and code challenge', () => {
const { codeVerifier, codeChallenge } = generatePkceChallenge();
expect(codeVerifier).toBeDefined();
expect(codeChallenge).toBeDefined();
expect(codeVerifier.length).toBeGreaterThan(0);
expect(codeChallenge.length).toBeGreaterThan(0);
});
it('should produce a challenge that is the SHA256 hash of the verifier', () => {
const { codeVerifier, codeChallenge } = generatePkceChallenge();
const expectedChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
expect(codeChallenge).toBe(expectedChallenge);
});
it('should generate unique values on each call', () => {
const first = generatePkceChallenge();
const second = generatePkceChallenge();
expect(first.codeVerifier).not.toBe(second.codeVerifier);
expect(first.codeChallenge).not.toBe(second.codeChallenge);
});
it('should use base64url encoding with no padding', () => {
const { codeVerifier, codeChallenge } = generatePkceChallenge();
// base64url uses - and _ instead of + and /, and no = padding
expect(codeVerifier).not.toMatch(/[+/=]/);
expect(codeChallenge).not.toMatch(/[+/=]/);
});
});

View File

@@ -0,0 +1,22 @@
import { type ApiService } from '@/cli/utilities/api/api-service';
// Builds the fetchTokenPair token source for
// ensureAppAccessTokenIsValidOrRefresh: mints a workspace-scoped app token
// pair via the generateApplicationToken mutation.
export const buildAppTokenPairFetcher =
(apiService: ApiService, applicationId: string) =>
async (): Promise<
{ accessToken: string; refreshToken?: string } | undefined
> => {
const tokenResult =
await apiService.generateApplicationToken(applicationId);
if (!tokenResult.success || !tokenResult.data) {
return undefined;
}
return {
accessToken: tokenResult.data.applicationAccessToken.token,
refreshToken: tokenResult.data.applicationRefreshToken.token,
};
};

View File

@@ -0,0 +1,265 @@
import http from 'node:http';
import { OAUTH_MODAL_HEADER_IMAGE_BASE64 } from '@/cli/constants/oauth-modal-header-image-base64';
type CallbackResult =
| { success: true; code: string }
| { success: false; error: string };
type CallbackServer = {
port: number;
callbackUrl: string;
waitForCallback: () => Promise<CallbackResult>;
close: () => void;
};
const pageHtml = ({
title,
message,
isSuccess,
isDarkMode,
}: {
title: string;
message: string;
isSuccess: boolean;
isDarkMode: boolean;
}) => `<!DOCTYPE html>
<html lang="en"${isDarkMode ? ' class="dark"' : ''}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${title} — Twenty</title>
<style>
:root {
--bg-body: #fafafa;
--bg-card: #fff;
--bg-logo-tile: #fff;
--text-primary: #333;
--text-secondary: #666;
--shadow: 2px 4px 16px 0 rgba(0,0,0,0.16), 0 2px 4px 0 rgba(0,0,0,0.08);
--success-bg: #f0faf0;
--error-bg: #fef0f0;
}
html.dark {
--bg-body: #171717;
--bg-card: #1b1b1b;
--bg-logo-tile: #2b2b2b;
--text-primary: #e6e6e6;
--text-secondary: #999;
--shadow: 2px 4px 16px 0 rgba(0,0,0,0.5), 0 2px 4px 0 rgba(0,0,0,0.3);
--success-bg: #0a2e0a;
--error-bg: #2e0a0a;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: var(--bg-body);
display: flex;
align-items: center;
justify-content: center;
min-height: 100dvh;
color: var(--text-primary);
}
.card {
background: var(--bg-card);
border-radius: 8px;
box-shadow: var(--shadow);
max-width: min(100%, 360px);
width: fit-content;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
height: 120px;
width: 100%;
display: flex;
align-items: center;
gap: 8px;
justify-content: center;
background-image: url('data:image/png;base64,${OAUTH_MODAL_HEADER_IMAGE_BASE64}');
background-position: center;
background-size: cover;
}
.logo-tile {
width: 36px;
height: 36px;
padding: 4px;
background: var(--bg-logo-tile);
border-radius: 100%;
box-shadow: var(--shadow);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.status-icon {
width: 24px;
height: 24px;
border-radius: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.status-icon svg {
width: 20px;
height: 20px;
}
.status-icon-success { background: var(--success-bg); }
.status-icon-error { background: var(--error-bg); }
.content {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px;
}
h2 {
font-size: 1rem;
font-weight: 500;
line-height: 1.1;
color: var(--text-primary);
text-align: center;
text-wrap: balance;
margin-bottom: 16px;
}
p {
font-size: 0.82rem;
color: var(--text-secondary);
text-align: center;
line-height: 1.5;
}
</style>
</head>
<body>
<div class="card">
<div class="header">
<div class="logo-tile">
<div class="status-icon ${isSuccess ? 'status-icon-success' : 'status-icon-error'}">
${
isSuccess
? '<svg viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'
: '<svg viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>'
}
</div>
</div>
</div>
<div class="content">
<h2>${title}</h2>
<p>${message}</p>
</div>
</div>
</body>
</html>`;
const successHtml = (isDarkMode: boolean) =>
pageHtml({
title: 'Authentication successful',
message: 'You can close this window and return to the terminal.',
isSuccess: true,
isDarkMode,
});
const escapeHtml = (text: string): string =>
text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
const errorHtml = (error: string, isDarkMode: boolean) =>
pageHtml({
title: 'Authentication failed',
message: `${escapeHtml(error)}<br>Please return to the terminal and try again.`,
isSuccess: false,
isDarkMode,
});
export const startCallbackServer = (options?: {
timeoutMs?: number;
}): Promise<CallbackServer> => {
const timeoutMs = options?.timeoutMs ?? 120_000;
return new Promise((resolve, reject) => {
let callbackResolve: (result: CallbackResult) => void;
let timeoutHandle: ReturnType<typeof setTimeout>;
const callbackPromise = new Promise<CallbackResult>((res) => {
callbackResolve = res;
});
const server = http.createServer((req, res) => {
const url = new URL(req.url ?? '/', `http://127.0.0.1`);
if (url.pathname !== '/callback') {
res.writeHead(404, { Connection: 'close' });
res.end('Not found');
return;
}
const code = url.searchParams.get('code');
const error = url.searchParams.get('error');
const isDarkMode = url.searchParams.get('theme') === 'dark';
const result: CallbackResult = code
? { success: true, code }
: {
success: false,
error:
error ??
url.searchParams.get('error_description') ??
'Unknown error',
};
const body = result.success
? successHtml(isDarkMode)
: errorHtml(result.error, isDarkMode);
res.writeHead(200, {
'Content-Type': 'text/html',
'Content-Length': Buffer.byteLength(body),
Connection: 'close',
});
res.on('close', () => callbackResolve(result));
res.end(body);
});
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
reject(new Error('Failed to start callback server'));
return;
}
const port = address.port;
resolve({
port,
callbackUrl: `http://127.0.0.1:${port}/callback`,
waitForCallback: () => {
timeoutHandle = setTimeout(() => {
callbackResolve({
success: false,
error: `Timed out waiting for authorization (${timeoutMs / 1000}s)`,
});
}, timeoutMs);
return callbackPromise.finally(() => {
clearTimeout(timeoutHandle);
});
},
close: () => {
clearTimeout(timeoutHandle);
server.close();
server.closeIdleConnections();
},
});
});
server.on('error', reject);
});
};

View File

@@ -0,0 +1,107 @@
import { type ConfigService } from '@/cli/utilities/config/config-service';
import { isOAuthInvalidClientError } from '@/cli/utilities/error/parse-server-error';
import { exchangeCredentialsForTokens } from './exchange-credentials-for-tokens';
const EXPIRATION_MARGIN_MS = 30_000;
const isTokenExpired = (token: string): boolean => {
try {
const payload = JSON.parse(
Buffer.from(token.split('.')[1], 'base64').toString(),
);
return payload.exp * 1_000 < Date.now() + EXPIRATION_MARGIN_MS;
} catch {
return false;
}
};
export type AppTokenSources = {
credentials?: { clientId: string; clientSecret: string };
// Workspace-scoped token minting (generateApplicationToken mutation). Used
// when the registration is not owned by this workspace (e.g. an app synced
// from the marketplace catalog), where no client secret is available and
// rotating the shared one would break the published app.
fetchTokenPair?: () => Promise<
{ accessToken: string; refreshToken?: string } | undefined
>;
};
export const ensureAppAccessTokenIsValidOrRefresh = async (
configService: ConfigService,
tokenSources?: AppTokenSources,
): Promise<string | undefined> => {
const config = await configService.getConfig();
if (config.appAccessToken && !isTokenExpired(config.appAccessToken)) {
return config.appAccessToken;
}
if (config.appRefreshToken && config.appRegistrationClientId) {
const response = await fetch(`${config.apiUrl}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: config.appRefreshToken,
client_id: config.appRegistrationClientId,
}),
});
if (response.ok) {
const data = (await response.json()) as {
access_token: string;
refresh_token?: string;
};
await configService.setConfig({
appAccessToken: data.access_token,
...(data.refresh_token ? { appRefreshToken: data.refresh_token } : {}),
});
return data.access_token;
}
try {
const body = await response.json();
if (isOAuthInvalidClientError(body)) {
await configService.setConfig({
appRegistrationId: undefined,
appRegistrationClientId: undefined,
appAccessToken: undefined,
appRefreshToken: undefined,
});
}
} catch {
// Non-JSON error response (e.g. proxy 502) — fall through to the other token sources
}
}
if (tokenSources?.fetchTokenPair) {
const tokenPair = await tokenSources.fetchTokenPair();
if (tokenPair) {
await configService.setConfig({
appAccessToken: tokenPair.accessToken,
...(tokenPair.refreshToken
? { appRefreshToken: tokenPair.refreshToken }
: {}),
});
return tokenPair.accessToken;
}
}
if (tokenSources?.credentials) {
const result = await exchangeCredentialsForTokens(
configService,
tokenSources.credentials,
);
return result.accessToken;
}
return undefined;
};

View File

@@ -0,0 +1,79 @@
import { type ApiService } from '@/cli/utilities/api/api-service';
import { type ConfigService } from '@/cli/utilities/config/config-service';
import { hasGraphQLErrorSubCode } from '@/cli/utilities/error/parse-server-error';
export const ensureAppRegistration = async (
apiService: ApiService,
configService: ConfigService,
app: { name: string; universalIdentifier: string },
): Promise<{
clientId: string;
clientSecret?: string;
isNewRegistration: boolean;
}> => {
const createResult = await apiService.createApplicationRegistration({
name: app.name,
universalIdentifier: app.universalIdentifier,
});
if (createResult.success) {
const { applicationRegistration, clientSecret } = createResult.data;
await configService.setConfig({
appRegistrationId: applicationRegistration.id,
appRegistrationClientId: applicationRegistration.oAuthClientId,
appAccessToken: undefined,
appRefreshToken: undefined,
});
return {
clientId: applicationRegistration.oAuthClientId,
clientSecret,
isNewRegistration: true,
};
}
const isAlreadyClaimed = hasGraphQLErrorSubCode(
createResult.error,
'UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED',
);
if (!isAlreadyClaimed) {
const errorDetail =
createResult.error instanceof Error
? createResult.error.message
: ((createResult.error as { message?: string })?.message ??
String(createResult.error));
throw new Error(`Failed to create app registration: ${errorDetail}`);
}
const findResult =
await apiService.findApplicationRegistrationByUniversalIdentifier(
app.universalIdentifier,
);
if (!findResult.success || !findResult.data) {
throw new Error(
`App registration exists but could not be found: ${app.universalIdentifier}`,
);
}
const registration = findResult.data;
await configService.setConfig({
appRegistrationId: registration.id,
appRegistrationClientId: registration.oAuthClientId,
appAccessToken: undefined,
appRefreshToken: undefined,
});
// The registration may be a catalog-synced npm app owned by another (or no)
// workspace, so rotating its shared client secret is neither allowed nor
// desirable. Dev mode mints workspace-scoped app tokens via the
// generateApplicationToken mutation instead.
return {
clientId: registration.oAuthClientId,
isNewRegistration: false,
};
};

Some files were not shown because too many files have changed in this diff Show More