From 731e297147cd7d3c3bfce29caf08a9d6483bece5 Mon Sep 17 00:00:00 2001 From: martmull Date: Tue, 17 Mar 2026 11:43:17 +0100 Subject: [PATCH] Twenty sdk cli oauth (#18638) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit image --------- Co-authored-by: Félix Malfait Co-authored-by: Félix Malfait --- .github/workflows/ci-create-app-e2e.yaml | 8 +- packages/create-twenty-app/README.md | 46 +- .../src/constants/base-application/README.md | 38 +- .../src/create-app.command.ts | 8 +- .../src/utils/__tests__/test-template.spec.ts | 1 - .../src/utils/test-template.ts | 6 +- .../community/apollo-enrich/README.md | 26 +- .../function-execute-app/package.json | 6 +- .../fixtures/invalid-app/package.json | 20 +- .../fixtures/minimal-app/package.json | 20 +- .../fixtures/postcard-app/package.json | 20 +- packages/twenty-apps/hello-world/README.md | 26 +- .../hello-world/src/__tests__/setup-test.ts | 5 +- .../internal/call-recording/README.md | 26 +- .../src/logic-functions/end-recording.ts | 2 +- packages/twenty-front/codegen-metadata.cjs | 8 +- packages/twenty-sdk/README.md | 208 ++-- packages/twenty-sdk/package.json | 4 +- .../__e2e__/function-execute.e2e-spec.ts | 6 +- .../app-dev/app-dev.integration.spec.ts | 4 +- .../app-dev/tests/manifest.tests.ts | 8 +- ...tions-install-delete-reinstall.e2e-spec.ts | 12 +- .../app-dev/app-dev.integration.spec.ts | 4 +- .../constants/server-url.constant.ts | 2 +- .../src/cli/__tests__/constants/setupTest.ts | 5 +- .../utils/normalize-manifest.util.ts | 42 +- .../utils/run-app-dev-in-process.util.ts | 2 +- .../integration/utils/setup-app-dev-mocks.ts | 20 +- packages/twenty-sdk/src/cli/cli.ts | 18 +- .../commands/{entity/entity-add.ts => add.ts} | 0 .../src/cli/commands/app-command.ts | 157 +-- .../src/cli/commands/auth/auth-list.ts | 54 - .../src/cli/commands/auth/auth-login.ts | 63 - .../src/cli/commands/auth/auth-logout.ts | 19 - .../src/cli/commands/auth/auth-status.ts | 37 - .../src/cli/commands/auth/auth-switch.ts | 99 -- .../commands/{app/app-build.ts => build.ts} | 2 +- .../twenty-sdk/src/cli/commands/deploy.ts | 56 + .../cli/commands/{app/app-dev.ts => dev.ts} | 0 .../logic-function-execute.ts => exec.ts} | 9 +- .../logic-function-logs.ts => logs.ts} | 0 .../{app/app-publish.ts => publish.ts} | 24 +- .../twenty-sdk/src/cli/commands/remote.ts | 286 +++++ .../{app/app-typecheck.ts => typecheck.ts} | 0 .../{app/app-uninstall.ts => uninstall.ts} | 2 +- .../app-build.ts => operations/build.ts} | 2 +- .../twenty-sdk/src/cli/operations/deploy.ts | 82 ++ .../execute.ts} | 10 +- .../twenty-sdk/src/cli/operations/index.ts | 36 + .../src/cli/operations/login-oauth.ts | 143 +++ .../auth-login.ts => operations/login.ts} | 18 +- .../auth-logout.ts => operations/logout.ts} | 8 +- .../twenty-sdk/src/cli/operations/publish.ts | 57 + .../uninstall.ts} | 10 +- .../src/cli/public-operations/app-publish.ts | 146 --- .../src/cli/public-operations/index.ts | 32 - .../src/cli/{public-operations => }/types.ts | 10 +- .../src/cli/utilities/api/api-client.ts | 177 +++ .../src/cli/utilities/api/api-service.ts | 1055 ++--------------- .../src/cli/utilities/api/application-api.ts | 286 +++++ .../src/cli/utilities/api/file-api.ts | 277 +++++ .../cli/utilities/api/logic-function-api.ts | 188 +++ .../src/cli/utilities/api/schema-api.ts | 70 ++ .../auth/__tests__/callback-server.test.ts | 92 ++ .../cli/utilities/auth/__tests__/pkce.test.ts | 41 + .../src/cli/utilities/auth/callback-server.ts | 203 ++++ .../src/cli/utilities/auth/open-browser.ts | 22 + .../twenty-sdk/src/cli/utilities/auth/pkce.ts | 16 + .../common/synchronize-built-application.ts | 7 +- .../build/manifest/manifest-build.ts | 28 +- .../cli/utilities/config/config-service.ts | 213 ++-- .../dev/orchestrator/dev-mode-orchestrator.ts | 15 - .../steps/check-server-orchestrator-step.ts | 18 +- .../ensure-valid-tokens-orchestrator-step.ts | 134 --- .../generate-api-client-orchestrator-step.ts | 2 +- .../steps/register-app-orchestrator-step.ts | 1 - .../twenty-sdk/src/cli/utilities/run-safe.ts | 2 +- .../src/clients/generated/core/index.ts | 2 +- .../src/clients/generated/core/schema.ts | 2 +- .../clients/generated/metadata/schema.graphql | 80 +- .../src/clients/generated/metadata/schema.ts | 168 +-- .../src/clients/generated/metadata/types.ts | 392 +++--- packages/twenty-sdk/vite.config.node.ts | 2 +- ...ed-cli-application-registration.command.ts | 61 + .../1-20-upgrade-version-command.module.ts | 5 + .../upgrade.command.ts | 3 + .../application-development.resolver.ts | 6 + .../application-oauth.module.ts | 3 +- .../controllers/oauth-discovery.controller.ts | 16 +- .../application-registration.service.ts | 29 +- .../auth/services/auth.service.ts | 48 +- .../auth/services/sign-in-up.service.spec.ts | 3 - .../auth/services/sign-in-up.service.ts | 2 - .../dev-seeder/dev-seeder.module.ts | 2 + .../dev-seeder/services/dev-seeder.service.ts | 4 + ...y-cli-application-registration.constant.ts | 6 + 96 files changed, 3191 insertions(+), 2453 deletions(-) rename packages/twenty-sdk/src/cli/commands/{entity/entity-add.ts => add.ts} (100%) delete mode 100644 packages/twenty-sdk/src/cli/commands/auth/auth-list.ts delete mode 100644 packages/twenty-sdk/src/cli/commands/auth/auth-login.ts delete mode 100644 packages/twenty-sdk/src/cli/commands/auth/auth-logout.ts delete mode 100644 packages/twenty-sdk/src/cli/commands/auth/auth-status.ts delete mode 100644 packages/twenty-sdk/src/cli/commands/auth/auth-switch.ts rename packages/twenty-sdk/src/cli/commands/{app/app-build.ts => build.ts} (95%) create mode 100644 packages/twenty-sdk/src/cli/commands/deploy.ts rename packages/twenty-sdk/src/cli/commands/{app/app-dev.ts => dev.ts} (100%) rename packages/twenty-sdk/src/cli/commands/{logic-function/logic-function-execute.ts => exec.ts} (95%) rename packages/twenty-sdk/src/cli/commands/{logic-function/logic-function-logs.ts => logs.ts} (100%) rename packages/twenty-sdk/src/cli/commands/{app/app-publish.ts => publish.ts} (59%) create mode 100644 packages/twenty-sdk/src/cli/commands/remote.ts rename packages/twenty-sdk/src/cli/commands/{app/app-typecheck.ts => typecheck.ts} (100%) rename packages/twenty-sdk/src/cli/commands/{app/app-uninstall.ts => uninstall.ts} (95%) rename packages/twenty-sdk/src/cli/{public-operations/app-build.ts => operations/build.ts} (98%) create mode 100644 packages/twenty-sdk/src/cli/operations/deploy.ts rename packages/twenty-sdk/src/cli/{public-operations/function-execute.ts => operations/execute.ts} (95%) create mode 100644 packages/twenty-sdk/src/cli/operations/index.ts create mode 100644 packages/twenty-sdk/src/cli/operations/login-oauth.ts rename packages/twenty-sdk/src/cli/{public-operations/auth-login.ts => operations/login.ts} (73%) rename packages/twenty-sdk/src/cli/{public-operations/auth-logout.ts => operations/logout.ts} (76%) create mode 100644 packages/twenty-sdk/src/cli/operations/publish.ts rename packages/twenty-sdk/src/cli/{public-operations/app-uninstall.ts => operations/uninstall.ts} (84%) delete mode 100644 packages/twenty-sdk/src/cli/public-operations/app-publish.ts delete mode 100644 packages/twenty-sdk/src/cli/public-operations/index.ts rename packages/twenty-sdk/src/cli/{public-operations => }/types.ts (87%) create mode 100644 packages/twenty-sdk/src/cli/utilities/api/api-client.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/api/application-api.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/api/file-api.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/api/logic-function-api.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/api/schema-api.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/auth/__tests__/callback-server.test.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/auth/__tests__/pkce.test.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/auth/callback-server.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/auth/open-browser.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/auth/pkce.ts delete mode 100644 packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/ensure-valid-tokens-orchestrator-step.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-seed-cli-application-registration.command.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/twenty-cli-application-registration.constant.ts diff --git a/.github/workflows/ci-create-app-e2e.yaml b/.github/workflows/ci-create-app-e2e.yaml index 13e0dc61565..d7ce4719df4 100644 --- a/.github/workflows/ci-create-app-e2e.yaml +++ b/.github/workflows/ci-create-app-e2e.yaml @@ -151,22 +151,24 @@ jobs: SEED_API_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik' run: | cd /tmp/e2e-test-workspace/test-app - npx --no-install twenty auth:login --api-key $SEED_API_KEY --api-url http://localhost:3000 + npx --no-install twenty remote add --token $SEED_API_KEY --url http://localhost:3000 - name: Build scaffolded app run: | cd /tmp/e2e-test-workspace/test-app - npx --no-install twenty app:build + npx --no-install twenty build test -d .twenty/output - name: Execute hello-world logic function run: | cd /tmp/e2e-test-workspace/test-app - EXEC_OUTPUT=$(npx --no-install twenty function:execute --functionName hello-world-logic-function) + EXEC_OUTPUT=$(npx --no-install twenty exec --functionName hello-world-logic-function) echo "$EXEC_OUTPUT" echo "$EXEC_OUTPUT" | grep -q "Hello, World!" - name: Run scaffolded app integration test + env: + TWENTY_API_URL: http://localhost:3000 run: | cd /tmp/e2e-test-workspace/test-app yarn test diff --git a/packages/create-twenty-app/README.md b/packages/create-twenty-app/README.md index a4641625be5..ac995abf7cc 100644 --- a/packages/create-twenty-app/README.md +++ b/packages/create-twenty-app/README.md @@ -36,36 +36,36 @@ cd my-twenty-app # Get help and list all available commands yarn twenty help -# Authenticate using your API key (you'll be prompted) -yarn twenty auth:login +# Authenticate with your Twenty server +yarn twenty remote add --local # Add a new entity to your application (guided) -yarn twenty entity:add +yarn twenty add # Start dev mode: watches, builds, and syncs local changes to your workspace # (also auto-generates typed CoreApiClient — MetadataApiClient ships pre-built with the SDK — both available via `twenty-sdk/clients`) -yarn twenty app:dev +yarn twenty dev # Watch your application's function logs -yarn twenty function:logs +yarn twenty logs # Execute a function with a JSON payload -yarn twenty function:execute -n my-function -p '{"key": "value"}' +yarn twenty exec -n my-function -p '{"key": "value"}' # Execute the pre-install function -yarn twenty function:execute --preInstall +yarn twenty exec --preInstall # Execute the post-install function -yarn twenty function:execute --postInstall +yarn twenty exec --postInstall # Build the app for distribution -yarn twenty app:build +yarn twenty build # Publish the app to npm or directly to a Twenty server -yarn twenty app:publish +yarn twenty publish # Uninstall the application from the current workspace -yarn twenty app:uninstall +yarn twenty uninstall ``` ## Scaffolding modes @@ -110,10 +110,10 @@ npx create-twenty-app@latest my-app -m ## Next steps - Run `yarn twenty help` to see all available commands. -- Use `yarn twenty auth:login` to authenticate with your Twenty workspace. -- Explore the generated project and add your first entity with `yarn twenty entity:add` (logic functions, front components, objects, roles, views, navigation menu items, skills). -- Use `yarn twenty app:dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time. -- `CoreApiClient` (for workspace data via `/graphql`) is auto-generated by `yarn twenty app:dev`. `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`) ships pre-built with the SDK. Both are available via `import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/clients'`. +- Use `yarn twenty remote add --local` to authenticate with your Twenty workspace. +- Explore the generated project and add your first entity with `yarn twenty add` (logic functions, front components, objects, roles, views, navigation menu items, skills). +- Use `yarn twenty dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time. +- `CoreApiClient` (for workspace data via `/graphql`) is auto-generated by `yarn twenty dev`. `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`) ships pre-built with the SDK. Both are available via `import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/clients'`. ## Build and publish your application @@ -121,19 +121,19 @@ Once your app is ready, build and publish it using the CLI: ```bash # Build the app (output goes to .twenty/output/) -yarn twenty app:build +yarn twenty build # Build and create a tarball (.tgz) for distribution -yarn twenty app:build --tarball +yarn twenty build --tarball # Publish to npm (requires npm login) -yarn twenty app:publish +yarn twenty publish # Publish with a dist-tag (e.g. beta, next) -yarn twenty app:publish --tag beta +yarn twenty publish --tag beta -# Publish directly to a Twenty server (builds, uploads, and installs in one step) -yarn twenty app:publish --server https://app.twenty.com +# Deploy directly to a Twenty server (builds, uploads, and installs in one step) +yarn twenty deploy ``` ### Publish to the Twenty marketplace @@ -153,8 +153,8 @@ Our team reviews contributions for quality, security, and reusability before mer ## Troubleshooting -- Auth prompts not appearing: run `yarn twenty auth:login` again and verify the API key permissions. -- Types not generated: ensure `yarn twenty app:dev` is running — it auto‑generates the typed client. +- Auth prompts not appearing: run `yarn twenty remote add --local` again and verify the API key permissions. +- Types not generated: ensure `yarn twenty dev` is running — it auto‑generates the typed client. ## Contributing diff --git a/packages/create-twenty-app/src/constants/base-application/README.md b/packages/create-twenty-app/src/constants/base-application/README.md index 34da2bd55ce..9dd96e23b25 100644 --- a/packages/create-twenty-app/src/constants/base-application/README.md +++ b/packages/create-twenty-app/src/constants/base-application/README.md @@ -2,16 +2,10 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c ## Getting Started -First, authenticate to your workspace: +Start development mode — it auto-connects to your local Twenty server at localhost:3000: ```bash -yarn twenty auth:login -``` - -Then, start development mode to sync your app and watch for changes: - -```bash -yarn twenty app:dev +yarn twenty dev ``` Open your Twenty instance and go to `/settings/applications` section to see the result. @@ -21,19 +15,23 @@ Open your Twenty instance and go to `/settings/applications` section to see the Run `yarn twenty help` to list all available commands. Common commands: ```bash -# Authentication -yarn twenty auth:login # Authenticate with Twenty -yarn twenty auth:logout # Remove credentials -yarn twenty auth:status # Check auth status -yarn twenty auth:switch # Switch default workspace -yarn twenty auth:list # List all configured workspaces +# Remotes & authentication +yarn twenty remote add --local # Connect to local Twenty server +yarn twenty remote add # Connect to a remote server (OAuth) +yarn twenty remote list # List all configured remotes +yarn twenty remote switch # Switch default remote +yarn twenty remote status # Check auth status +yarn twenty remote remove # Remove a remote -# Application -yarn twenty app:dev # Start dev mode (watch, build, sync, and auto-generate typed client) -yarn twenty entity:add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item) -yarn twenty function:logs # Stream function logs -yarn twenty function:execute # Execute a function with JSON payload -yarn twenty app:uninstall # Uninstall app from workspace +# Development +yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client) +yarn twenty build # Build the application +yarn twenty deploy # Deploy to a Twenty server +yarn twenty publish # Publish to npm +yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item) +yarn twenty exec # Execute a function with JSON payload +yarn twenty logs # Stream function logs +yarn twenty uninstall # Uninstall app from server ``` ## Integration Tests diff --git a/packages/create-twenty-app/src/create-app.command.ts b/packages/create-twenty-app/src/create-app.command.ts index eeaba791831..b532f216910 100644 --- a/packages/create-twenty-app/src/create-app.command.ts +++ b/packages/create-twenty-app/src/create-app.command.ts @@ -187,8 +187,12 @@ export class CreateAppCommand { console.log(chalk.blue('Next steps:')); console.log(chalk.gray(` cd ${dirName}`)); console.log( - chalk.gray(' yarn twenty auth:login # Authenticate with Twenty'), + chalk.gray( + ' yarn twenty remote add --local # Authenticate with Twenty', + ), + ); + console.log( + chalk.gray(' yarn twenty dev # Start dev mode'), ); - console.log(chalk.gray(' yarn twenty app:dev # Start dev mode')); } } diff --git a/packages/create-twenty-app/src/utils/__tests__/test-template.spec.ts b/packages/create-twenty-app/src/utils/__tests__/test-template.spec.ts index ea9c4a03681..0c45baea498 100644 --- a/packages/create-twenty-app/src/utils/__tests__/test-template.spec.ts +++ b/packages/create-twenty-app/src/utils/__tests__/test-template.spec.ts @@ -103,7 +103,6 @@ describe('scaffoldIntegrationTest', () => { expect(content).toContain('TWENTY_API_KEY'); expect(content).not.toContain('TWENTY_TEST_API_KEY'); - expect(content).toContain('TWENTY_API_URL'); expect(content).toContain('setup-test.ts'); expect(content).toContain('tsconfig.spec.json'); expect(content).toContain('integration-test.ts'); diff --git a/packages/create-twenty-app/src/utils/test-template.ts b/packages/create-twenty-app/src/utils/test-template.ts index 12e21b764a4..08001d0af32 100644 --- a/packages/create-twenty-app/src/utils/test-template.ts +++ b/packages/create-twenty-app/src/utils/test-template.ts @@ -45,7 +45,6 @@ export default defineConfig({ include: ['src/**/*.integration-test.ts'], setupFiles: ['src/__tests__/setup-test.ts'], env: { - TWENTY_API_URL: 'http://localhost:3000', TWENTY_API_KEY: '${SEED_API_KEY}', }, @@ -120,12 +119,13 @@ beforeAll(async () => { fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true }); const configFile = { - profiles: { - default: { + remotes: { + local: { apiUrl: process.env.TWENTY_API_URL, apiKey: process.env.TWENTY_API_KEY, }, }, + defaultRemote: 'local', }; fs.writeFileSync( diff --git a/packages/twenty-apps/community/apollo-enrich/README.md b/packages/twenty-apps/community/apollo-enrich/README.md index f12837b91ea..fa00215005b 100644 --- a/packages/twenty-apps/community/apollo-enrich/README.md +++ b/packages/twenty-apps/community/apollo-enrich/README.md @@ -5,13 +5,13 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c First, authenticate to your workspace: ```bash -yarn twenty auth:login +yarn twenty remote add --local ``` Then, start development mode to sync your app and watch for changes: ```bash -yarn twenty app:dev +yarn twenty dev ``` Open your Twenty instance and go to `/settings/applications` section to see the result. @@ -21,19 +21,19 @@ Open your Twenty instance and go to `/settings/applications` section to see the Run `yarn twenty help` to list all available commands. Common commands: ```bash -# Authentication -yarn twenty auth:login # Authenticate with Twenty -yarn twenty auth:logout # Remove credentials -yarn twenty auth:status # Check auth status -yarn twenty auth:switch # Switch default workspace -yarn twenty auth:list # List all configured workspaces +# Remotes & Authentication +yarn twenty remote add --local # Authenticate with Twenty +yarn twenty remote status # Check auth status +yarn twenty remote switch # Switch default remote +yarn twenty remote list # List all configured remotes +yarn twenty remote remove # Remove a remote # Application -yarn twenty app:dev # Start dev mode (watch, build, sync, and auto-generate typed client) -yarn twenty entity:add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item) -yarn twenty function:logs # Stream function logs -yarn twenty function:execute # Execute a function with JSON payload -yarn twenty app:uninstall # Uninstall app from workspace +yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client) +yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item) +yarn twenty logs # Stream function logs +yarn twenty exec # Execute a function with JSON payload +yarn twenty uninstall # Uninstall app from workspace ``` ## LLMs instructions diff --git a/packages/twenty-apps/fixtures/function-execute-app/package.json b/packages/twenty-apps/fixtures/function-execute-app/package.json index 5dde174ff19..1e229559f2f 100644 --- a/packages/twenty-apps/fixtures/function-execute-app/package.json +++ b/packages/twenty-apps/fixtures/function-execute-app/package.json @@ -9,9 +9,9 @@ }, "packageManager": "yarn@4.9.2", "scripts": { - "app:dev": "twenty app:dev", - "function:execute": "twenty function:execute", - "app:uninstall": "twenty app:uninstall", + "dev": "twenty dev", + "exec": "twenty exec", + "uninstall": "twenty uninstall", "lint": "oxlint -c .oxlintrc.json .", "lint:fix": "oxlint --fix -c .oxlintrc.json ." }, diff --git a/packages/twenty-apps/fixtures/invalid-app/package.json b/packages/twenty-apps/fixtures/invalid-app/package.json index cdf827671ae..d1864a7f0e4 100644 --- a/packages/twenty-apps/fixtures/invalid-app/package.json +++ b/packages/twenty-apps/fixtures/invalid-app/package.json @@ -9,16 +9,16 @@ }, "packageManager": "yarn@4.9.2", "scripts": { - "auth:login": "twenty auth:login", - "auth:logout": "twenty auth:logout", - "auth:status": "twenty auth:status", - "auth:switch": "twenty auth:switch", - "auth:list": "twenty auth:list", - "app:dev": "twenty app:dev", - "entity:add": "twenty entity:add", - "function:logs": "twenty function:logs", - "function:execute": "twenty function:execute", - "app:uninstall": "twenty app:uninstall", + "remote:add": "twenty remote add --local", + "remote:status": "twenty remote status", + "remote:switch": "twenty remote switch", + "remote:list": "twenty remote list", + "remote:remove": "twenty remote remove", + "dev": "twenty dev", + "add": "twenty add", + "logs": "twenty logs", + "exec": "twenty exec", + "uninstall": "twenty uninstall", "help": "twenty help", "lint": "oxlint -c .oxlintrc.json .", "lint:fix": "oxlint --fix -c .oxlintrc.json ." diff --git a/packages/twenty-apps/fixtures/minimal-app/package.json b/packages/twenty-apps/fixtures/minimal-app/package.json index d03347ef85d..14b4b0ddfc0 100644 --- a/packages/twenty-apps/fixtures/minimal-app/package.json +++ b/packages/twenty-apps/fixtures/minimal-app/package.json @@ -9,16 +9,16 @@ }, "packageManager": "yarn@4.9.2", "scripts": { - "auth:login": "twenty auth:login", - "auth:logout": "twenty auth:logout", - "auth:status": "twenty auth:status", - "auth:switch": "twenty auth:switch", - "auth:list": "twenty auth:list", - "app:dev": "twenty app:dev", - "entity:add": "twenty entity:add", - "function:logs": "twenty function:logs", - "function:execute": "twenty function:execute", - "app:uninstall": "twenty app:uninstall", + "remote:add": "twenty remote add --local", + "remote:status": "twenty remote status", + "remote:switch": "twenty remote switch", + "remote:list": "twenty remote list", + "remote:remove": "twenty remote remove", + "dev": "twenty dev", + "add": "twenty add", + "logs": "twenty logs", + "exec": "twenty exec", + "uninstall": "twenty uninstall", "help": "twenty help", "lint": "oxlint -c .oxlintrc.json .", "lint:fix": "oxlint --fix -c .oxlintrc.json ." diff --git a/packages/twenty-apps/fixtures/postcard-app/package.json b/packages/twenty-apps/fixtures/postcard-app/package.json index f441dc81dfb..4c3a0ac4207 100644 --- a/packages/twenty-apps/fixtures/postcard-app/package.json +++ b/packages/twenty-apps/fixtures/postcard-app/package.json @@ -9,16 +9,16 @@ }, "packageManager": "yarn@4.9.2", "scripts": { - "auth:login": "twenty auth:login", - "auth:logout": "twenty auth:logout", - "auth:status": "twenty auth:status", - "auth:switch": "twenty auth:switch", - "auth:list": "twenty auth:list", - "app:dev": "twenty app:dev", - "entity:add": "twenty entity:add", - "function:logs": "twenty function:logs", - "function:execute": "twenty function:execute", - "app:uninstall": "twenty app:uninstall", + "remote:add": "twenty remote add --local", + "remote:status": "twenty remote status", + "remote:switch": "twenty remote switch", + "remote:list": "twenty remote list", + "remote:remove": "twenty remote remove", + "dev": "twenty dev", + "add": "twenty add", + "logs": "twenty logs", + "exec": "twenty exec", + "uninstall": "twenty uninstall", "help": "twenty help", "lint": "oxlint -c .oxlintrc.json .", "lint:fix": "oxlint --fix -c .oxlintrc.json ." diff --git a/packages/twenty-apps/hello-world/README.md b/packages/twenty-apps/hello-world/README.md index 34da2bd55ce..bf69b550885 100644 --- a/packages/twenty-apps/hello-world/README.md +++ b/packages/twenty-apps/hello-world/README.md @@ -5,13 +5,13 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c First, authenticate to your workspace: ```bash -yarn twenty auth:login +yarn twenty remote add --local ``` Then, start development mode to sync your app and watch for changes: ```bash -yarn twenty app:dev +yarn twenty dev ``` Open your Twenty instance and go to `/settings/applications` section to see the result. @@ -21,19 +21,19 @@ Open your Twenty instance and go to `/settings/applications` section to see the Run `yarn twenty help` to list all available commands. Common commands: ```bash -# Authentication -yarn twenty auth:login # Authenticate with Twenty -yarn twenty auth:logout # Remove credentials -yarn twenty auth:status # Check auth status -yarn twenty auth:switch # Switch default workspace -yarn twenty auth:list # List all configured workspaces +# Remotes & Authentication +yarn twenty remote add --local # Authenticate with Twenty +yarn twenty remote status # Check auth status +yarn twenty remote switch # Switch default remote +yarn twenty remote list # List all configured remotes +yarn twenty remote remove # Remove a remote # Application -yarn twenty app:dev # Start dev mode (watch, build, sync, and auto-generate typed client) -yarn twenty entity:add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item) -yarn twenty function:logs # Stream function logs -yarn twenty function:execute # Execute a function with JSON payload -yarn twenty app:uninstall # Uninstall app from workspace +yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client) +yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item) +yarn twenty logs # Stream function logs +yarn twenty exec # Execute a function with JSON payload +yarn twenty uninstall # Uninstall app from workspace ``` ## Integration Tests diff --git a/packages/twenty-apps/hello-world/src/__tests__/setup-test.ts b/packages/twenty-apps/hello-world/src/__tests__/setup-test.ts index 0b866c9bb0f..c3bb17f8820 100644 --- a/packages/twenty-apps/hello-world/src/__tests__/setup-test.ts +++ b/packages/twenty-apps/hello-world/src/__tests__/setup-test.ts @@ -29,12 +29,13 @@ beforeAll(async () => { fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true }); const configFile = { - profiles: { - default: { + remotes: { + local: { apiUrl: process.env.TWENTY_API_URL, apiKey: process.env.TWENTY_API_KEY, }, }, + defaultRemote: 'local', }; fs.writeFileSync( diff --git a/packages/twenty-apps/internal/call-recording/README.md b/packages/twenty-apps/internal/call-recording/README.md index f12837b91ea..fa00215005b 100644 --- a/packages/twenty-apps/internal/call-recording/README.md +++ b/packages/twenty-apps/internal/call-recording/README.md @@ -5,13 +5,13 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c First, authenticate to your workspace: ```bash -yarn twenty auth:login +yarn twenty remote add --local ``` Then, start development mode to sync your app and watch for changes: ```bash -yarn twenty app:dev +yarn twenty dev ``` Open your Twenty instance and go to `/settings/applications` section to see the result. @@ -21,19 +21,19 @@ Open your Twenty instance and go to `/settings/applications` section to see the Run `yarn twenty help` to list all available commands. Common commands: ```bash -# Authentication -yarn twenty auth:login # Authenticate with Twenty -yarn twenty auth:logout # Remove credentials -yarn twenty auth:status # Check auth status -yarn twenty auth:switch # Switch default workspace -yarn twenty auth:list # List all configured workspaces +# Remotes & Authentication +yarn twenty remote add --local # Authenticate with Twenty +yarn twenty remote status # Check auth status +yarn twenty remote switch # Switch default remote +yarn twenty remote list # List all configured remotes +yarn twenty remote remove # Remove a remote # Application -yarn twenty app:dev # Start dev mode (watch, build, sync, and auto-generate typed client) -yarn twenty entity:add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item) -yarn twenty function:logs # Stream function logs -yarn twenty function:execute # Execute a function with JSON payload -yarn twenty app:uninstall # Uninstall app from workspace +yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client) +yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item) +yarn twenty logs # Stream function logs +yarn twenty exec # Execute a function with JSON payload +yarn twenty uninstall # Uninstall app from workspace ``` ## LLMs instructions diff --git a/packages/twenty-apps/internal/call-recording/src/logic-functions/end-recording.ts b/packages/twenty-apps/internal/call-recording/src/logic-functions/end-recording.ts index 08eb46c4e28..ab189ffb3e7 100644 --- a/packages/twenty-apps/internal/call-recording/src/logic-functions/end-recording.ts +++ b/packages/twenty-apps/internal/call-recording/src/logic-functions/end-recording.ts @@ -236,7 +236,7 @@ const handler = async (event: any) => { }, }); - // TODO: remove `as any` after running `yarn twenty app:dev` to regenerate the typed client + // TODO: remove `as any` after running `yarn twenty dev` to regenerate the typed client const updateSummary = async (markdown: string) => { await client.mutation({ updateCallRecording: { diff --git a/packages/twenty-front/codegen-metadata.cjs b/packages/twenty-front/codegen-metadata.cjs index 4608328121d..ebd7885313b 100644 --- a/packages/twenty-front/codegen-metadata.cjs +++ b/packages/twenty-front/codegen-metadata.cjs @@ -23,7 +23,7 @@ module.exports = { './src/modules/databases/graphql/**/*.{ts,tsx}', './src/modules/analytics/graphql/**/*.{ts,tsx}', './src/modules/object-metadata/graphql/**/*.{ts,tsx}', - './src/modules/navigation-menu-item/graphql/**/*.{ts,tsx}', + './src/modules/navigation-menu-item/**/graphql/**/*.{ts,tsx}', './src/modules/command-menu-item/graphql/**/*.{ts,tsx}', './src/modules/attachments/graphql/**/*.{ts,tsx}', './src/modules/file/graphql/**/*.{ts,tsx}', @@ -42,11 +42,7 @@ module.exports = { overwrite: true, generates: { './src/generated-metadata/graphql.ts': { - plugins: [ - 'typescript', - 'typescript-operations', - 'typed-document-node', - ], + plugins: ['typescript', 'typescript-operations', 'typed-document-node'], config: { skipTypename: false, scalars: { diff --git a/packages/twenty-sdk/README.md b/packages/twenty-sdk/README.md index aad38b3a9be..81e4dcd1772 100644 --- a/packages/twenty-sdk/README.md +++ b/packages/twenty-sdk/README.md @@ -43,115 +43,119 @@ Usage: twenty [options] [command] CLI for Twenty application development Options: - --workspace Use a specific workspace configuration (default: "default") - -V, --version output the version number - -h, --help display help for command + -V, --version output the version number + -r, --remote Use a specific remote (overrides the default set by remote switch) + -h, --help display help for command Commands: - auth:login Authenticate with Twenty - auth:logout Remove authentication credentials - auth:status Check authentication status - auth:switch Switch the default workspace - auth:list List all configured workspaces - app:dev Watch and sync local application changes - app:build Build, sync, and generate API client - app:publish Build and publish to npm or a Twenty server - app:typecheck Run TypeScript type checking on the application - app:uninstall Uninstall application from Twenty - entity:add Add a new entity to your application - function:logs Watch application function logs - function:execute Execute a logic function with a JSON payload - help [command] display help for command + dev [appPath] Watch and sync local application changes + build [appPath] Build, sync, and generate API client into .twenty/output/ + deploy [appPath] Build and deploy to a Twenty server + publish [appPath] Build and publish to npm + typecheck [appPath] Run TypeScript type checking on the application + uninstall [appPath] Uninstall application from Twenty + remote Manage remote Twenty servers + add [entityType] Add a new entity to your application + exec [appPath] Execute a logic function with a JSON payload + logs [appPath] Watch application function logs + help [command] display help for command ``` -In a scaffolded project (via `create-twenty-app`), use `yarn twenty ` instead of calling `twenty` directly. For example: `yarn twenty help`, `yarn twenty app:dev`, etc. +In a scaffolded project (via `create-twenty-app`), use `yarn twenty ` instead of calling `twenty` directly. For example: `yarn twenty help`, `yarn twenty dev`, etc. ## Global Options -- `--workspace `: Use a specific workspace configuration profile. Defaults to `default`. See Configuration for details. +- `--remote ` (or `-r `): Use a specific remote configuration. Defaults to `local`. See Configuration for details. ## Commands -### Auth +### Remote -Authenticate the CLI against your Twenty workspace. +Manage remote server connections and authentication. -- `twenty auth:login` — Authenticate with Twenty. +- `twenty remote add [nameOrUrl]` — Add a new remote or re-authenticate an existing one. - Options: - - `--api-key `: API key for authentication. - - `--api-url `: Twenty API URL (defaults to your current profile's value or `http://localhost:3000`). - - Behavior: Prompts for any missing values, persists them to the active workspace profile, and validates the credentials. + - `--token `: API key for non-interactive auth. + - `--url `: Server URL (alternative to positional arg). + - `--as `: Name for this remote (otherwise derived from URL hostname). + - `--local`: Connect to local development server (`http://localhost:3000`). + - Behavior: If `nameOrUrl` matches an existing remote name, re-authenticates it. Otherwise, creates a new remote and authenticates via OAuth (with API key fallback). -- `twenty auth:logout` — Remove authentication credentials for the active workspace profile. +- `twenty remote remove ` — Remove a remote and its credentials. -- `twenty auth:status` — Print the current authentication status (API URL, masked API key, validity). +- `twenty remote list` — List all configured remotes with their auth status and URLs. -- `twenty auth:list` — List all configured workspaces. +- `twenty remote switch [name]` — Set the default remote. - - Behavior: Displays all available workspaces with their authentication status and API URLs. Shows which workspace is the current default. + - If omitted, shows an interactive selection. -- `twenty auth:switch [workspace]` — Switch the default workspace for authentication. - - Arguments: - - `workspace` (optional): Name of the workspace to switch to. If omitted, shows an interactive selection. - - Behavior: Sets the specified workspace as the default, so subsequent commands use it without needing `--workspace`. +- `twenty remote status` — Print the current remote name, server URL, and auth status. Examples: ```bash -# Login interactively (recommended) -twenty auth:login +# Add a remote interactively (recommended) +twenty remote add -# Provide values in flags -twenty auth:login --api-key $TWENTY_API_KEY --api-url https://api.twenty.com +# Provide values in flags (non-interactive, for CI) +twenty remote add https://api.twenty.com --token $TWENTY_API_KEY -# Login interactively for a specific workspace profile -twenty auth:login --workspace my-custom-workspace +# Add a local development remote +twenty remote add --local + +# Name a remote explicitly +twenty remote add https://api.twenty.com --as production + +# Re-authenticate an existing remote by name +twenty remote add production # Check status -twenty auth:status +twenty remote status -# Logout current profile -twenty auth:logout +# List all configured remotes +twenty remote list -# List all configured workspaces -twenty auth:list +# Switch default remote +twenty remote switch production -# Switch default workspace interactively -twenty auth:switch - -# Switch to a specific workspace -twenty auth:switch production +# Remove a remote +twenty remote remove production ``` ### App Application development commands. -- `twenty app:dev [appPath]` — Start development mode: watch and sync local application changes. +- `twenty dev [appPath]` — Start development mode: watch and sync local application changes. - - Behavior: Builds your application (functions and front components), computes the manifest, syncs everything to your workspace, then watches the directory for changes and re-syncs automatically. Displays an interactive UI showing build and sync status in real time. Press Ctrl+C to stop. + - Behavior: Builds your application (functions and front components), computes the manifest, syncs everything to your remote, then watches the directory for changes and re-syncs automatically. Displays an interactive UI showing build and sync status in real time. Press Ctrl+C to stop. -- `twenty app:build [appPath]` — Build the application, sync to the server, generate the typed API client, then rebuild with the real client. +- `twenty build [appPath]` — Build the application, sync to the server, generate the typed API client, then rebuild with the real client. - Options: - `--tarball`: Also pack the output into a `.tgz` tarball. -- `twenty app:publish [appPath]` — Build and publish the application. +- `twenty publish [appPath]` — Build and publish the application to npm. - - Default (no flags): builds and runs `npm publish` on the output directory. + - Behavior: Builds the application and runs `npm publish` on the output directory. - Options: - - `--server `: Publish to a Twenty server instead of npm (builds tarball, uploads, and installs). - - `--token `: Auth token for the server. - `--tag `: npm dist-tag (e.g. `beta`, `next`). -- `twenty app:typecheck [appPath]` — Run TypeScript type checking on the application (runs `tsc --noEmit`). Exits with code 1 if type errors are found. +- `twenty deploy [appPath]` — Build and deploy the application to a Twenty server. -- `twenty app:uninstall [appPath]` — Uninstall the application from the current workspace. + - Behavior: Builds the tarball, uploads it to the server, and installs the application. + - Options: + - `--server `: Target Twenty server URL. + - `--token `: Auth token for the server. + +- `twenty typecheck [appPath]` — Run TypeScript type checking on the application (runs `tsc --noEmit`). Exits with code 1 if type errors are found. + +- `twenty uninstall [appPath]` — Uninstall the application from the current remote. ### Entity -- `twenty entity:add [entityType]` — Add a new entity to your application. +- `twenty add [entityType]` — Add a new entity to your application. - Arguments: - `entityType`: one of `object`, `field`, `function`, `front-component`, `role`, `view`, `navigation-menu-item`, or `skill`. If omitted, an interactive prompt is shown. - Options: @@ -168,13 +172,13 @@ Application development commands. ### Function -- `twenty function:logs [appPath]` — Stream application function logs. +- `twenty logs [appPath]` — Stream application function logs. - Options: - `-u, --functionUniversalIdentifier `: Only show logs for a specific function universal ID. - `-n, --functionName `: Only show logs for a specific function name. -- `twenty function:execute [appPath]` — Execute a logic function with a JSON payload. +- `twenty exec [appPath]` — Execute a logic function with a JSON payload. - Options: - `--preInstall`: Execute the pre-install logic function defined in the application manifest (required if `--postInstall`, `-n`, and `-u` not provided). - `--postInstall`: Execute the post-install logic function defined in the application manifest (required if `--preInstall`, `-n`, and `-u` not provided). @@ -186,70 +190,70 @@ Examples: ```bash # Start dev mode (watch, build, and sync) -twenty app:dev +twenty dev -# Start dev mode with a custom workspace profile -twenty app:dev --workspace my-custom-workspace +# Start dev mode with a custom remote +twenty dev --remote my-custom-remote # Type check the application -twenty app:typecheck +twenty typecheck # Add a new entity interactively -twenty entity:add +twenty add # Add a new function -twenty entity:add function +twenty add function # Add a new front component -twenty entity:add front-component +twenty add front-component # Add a new view -twenty entity:add view +twenty add view # Add a new navigation menu item -twenty entity:add navigation-menu-item +twenty add navigation-menu-item # Add a new skill -twenty entity:add skill +twenty add skill # Build the app (output in .twenty/output/) -twenty app:build +twenty build # Build and create a tarball -twenty app:build --tarball +twenty build --tarball # Publish to npm -twenty app:publish +twenty publish # Publish with a dist-tag -twenty app:publish --tag beta +twenty publish --tag beta -# Publish directly to a Twenty server (builds, uploads, and installs) -twenty app:publish --server https://app.twenty.com +# Deploy directly to a Twenty server (builds, uploads, and installs) +twenty deploy --server https://app.twenty.com -# Uninstall the app from the workspace -twenty app:uninstall +# Uninstall the app from the remote +twenty uninstall # Watch all function logs -twenty function:logs +twenty logs # Watch logs for a specific function by name -twenty function:logs -n my-function +twenty logs -n my-function # Execute a function by name (with empty payload) -twenty function:execute -n my-function +twenty exec -n my-function # Execute a function with a JSON payload -twenty function:execute -n my-function -p '{"name": "test"}' +twenty exec -n my-function -p '{"name": "test"}' # Execute a function by universal identifier -twenty function:execute -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf -p '{"key": "value"}' +twenty exec -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf -p '{"key": "value"}' # Execute the pre-install function -twenty function:execute --preInstall +twenty exec --preInstall # Execute the post-install function -twenty function:execute --postInstall +twenty exec --postInstall ``` ## Configuration @@ -257,21 +261,23 @@ twenty function:execute --postInstall The CLI stores configuration per user in a JSON file: - Location: `~/.twenty/config.json` -- Structure: Profiles keyed by workspace name. The active profile is selected with `--workspace ` or by the `defaultWorkspace` setting. +- Structure: Remotes keyed by name. The active remote is selected with `--remote ` or by the `defaultRemote` setting. Example configuration file: ```json { - "defaultWorkspace": "prod", - "profiles": { - "default": { + "defaultRemote": "production", + "remotes": { + "local": { "apiUrl": "http://localhost:3000", "apiKey": "" }, - "prod": { + "production": { "apiUrl": "https://api.twenty.com", - "apiKey": "" + "accessToken": "", + "refreshToken": "", + "oauthClientId": "" } } } @@ -279,17 +285,17 @@ Example configuration file: Notes: -- If a profile is missing, `apiUrl` defaults to `http://localhost:3000` until set. -- `twenty auth:login` writes the `apiUrl` and `apiKey` for the active workspace profile. -- `twenty auth:login --workspace custom-workspace` writes the `apiUrl` and `apiKey` for a custom `custom-workspace` profile. -- `twenty auth:switch` sets the `defaultWorkspace` field, which is used when `--workspace` is not specified. -- `twenty auth:list` shows all configured workspaces and their authentication status. +- If a remote is missing, `apiUrl` defaults to `http://localhost:3000`. +- `twenty remote add` writes credentials for the active remote (OAuth tokens or API key). +- `twenty remote add --as my-remote` saves under a custom name. +- `twenty remote switch` sets the `defaultRemote` field, used when `-r` is not specified. +- `twenty remote list` shows all configured remotes and their authentication status. ## Troubleshooting -- Auth errors: run `twenty auth:login` again and ensure the API key has the required permissions. -- Typings out of date: restart `twenty app:dev` to refresh the client and types. -- Not seeing changes in dev: make sure dev mode is running (`twenty app:dev`). +- Auth errors: run `twenty remote add` again (or add a new remote) and ensure the API key has the required permissions. +- Typings out of date: restart `twenty dev` to refresh the client and types. +- Not seeing changes in dev: make sure dev mode is running (`twenty dev`). ## Contributing @@ -327,7 +333,7 @@ After building, you can run the CLI directly: ```bash npx nx run twenty-sdk:start -- -# Example: npx nx run twenty-sdk:start -- auth:status +# Example: npx nx run twenty-sdk:start -- remote status ``` Or run the built CLI directly: diff --git a/packages/twenty-sdk/package.json b/packages/twenty-sdk/package.json index 9a2ada1a67e..5ef8746890c 100644 --- a/packages/twenty-sdk/package.json +++ b/packages/twenty-sdk/package.json @@ -35,7 +35,7 @@ "require": "./dist/ui/index.cjs" }, "./cli": { - "types": "./dist/cli/public-operations/index.d.ts", + "types": "./dist/cli/operations/index.d.ts", "import": "./dist/operations.mjs", "require": "./dist/operations.cjs" }, @@ -113,7 +113,7 @@ "typesVersions": { "*": { "cli": [ - "dist/cli/public-operations/index.d.ts" + "dist/cli/operations/index.d.ts" ], "ui": [ "dist/ui/index.d.ts" diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/function-execute-app/__e2e__/function-execute.e2e-spec.ts b/packages/twenty-sdk/src/cli/__tests__/apps/function-execute-app/__e2e__/function-execute.e2e-spec.ts index f5de3deac95..5fc63005d53 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/function-execute-app/__e2e__/function-execute.e2e-spec.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/function-execute-app/__e2e__/function-execute.e2e-spec.ts @@ -1,8 +1,8 @@ import { vi } from 'vitest'; -import { appBuild } from '@/cli/public-operations/app-build'; -import { appUninstall } from '@/cli/public-operations/app-uninstall'; -import { functionExecute } from '@/cli/public-operations/function-execute'; +import { appBuild } from '@/cli/operations/build'; +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'; diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/app-dev.integration.spec.ts b/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/app-dev.integration.spec.ts index 33ef13e9a4f..b91d610a306 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/app-dev.integration.spec.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/app-dev.integration.spec.ts @@ -5,7 +5,7 @@ import { defineFrontComponentsTests } from './tests/front-components.tests'; import { defineLogicFunctionsTests } from './tests/logic-functions.tests'; import { defineManifestTests } from './tests/manifest.tests'; -describe('minimal-app app:dev', () => { +describe('minimal-app dev', () => { beforeAll(async () => { const result = await runAppDevInProcess({ appPath: MINIMAL_APP_PATH }); @@ -17,7 +17,7 @@ describe('minimal-app app:dev', () => { ); throw new Error( - `app:dev did not produce manifest.json within timeout.\n${diagnostics}`, + `dev did not produce manifest.json within timeout.\n${diagnostics}`, ); } }, 60000); diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/tests/manifest.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/tests/manifest.tests.ts index c7075b5ffb3..0ed02e134dd 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/tests/manifest.tests.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/tests/manifest.tests.ts @@ -16,11 +16,13 @@ export const defineManifestTests = (appPath: string): void => { it('should have correct manifest content', async () => { const manifestPath = join(appPath, '.twenty/output/manifest.json'); - const manifest: Manifest = normalizeManifestForComparison( - await readJson(manifestPath), + const manifest = normalizeManifestForComparison( + await readJson(manifestPath), ); - expect(manifest).toEqual(EXPECTED_MANIFEST); + expect(manifest).toEqual( + normalizeManifestForComparison(EXPECTED_MANIFEST), + ); }); }); }; diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/postcard-app/__e2e__/applications-install-delete-reinstall.e2e-spec.ts b/packages/twenty-sdk/src/cli/__tests__/apps/postcard-app/__e2e__/applications-install-delete-reinstall.e2e-spec.ts index 7bfb84568f6..b18e8bf4fe1 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/postcard-app/__e2e__/applications-install-delete-reinstall.e2e-spec.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/postcard-app/__e2e__/applications-install-delete-reinstall.e2e-spec.ts @@ -15,10 +15,10 @@ describe('Application: install delete and reinstall postcard-app', () => { expect(existsSync(appPath)).toBe(true); const result = await runCliCommand({ - command: 'auth:status', - args: [appPath], + command: 'remote', + args: ['status'], timeout: 5_000, - waitForOutput: '✓ Valid', + waitForOutput: '(valid)', }); expect(result.success).toBe(true); @@ -26,7 +26,7 @@ describe('Application: install delete and reinstall postcard-app', () => { it(`should successfully install ${applicationName} application`, async () => { await runCliCommand({ - command: 'app:dev', + command: 'dev', args: [appPath], waitForOutput: '✓ Synced', }); @@ -36,7 +36,7 @@ describe('Application: install delete and reinstall postcard-app', () => { it(`should successfully delete ${applicationName} application`, async () => { await runCliCommand({ - command: 'app:uninstall', + command: 'uninstall', args: [appPath, '-y'], waitForOutput: 'Application uninstalled successfully', }); @@ -44,7 +44,7 @@ describe('Application: install delete and reinstall postcard-app', () => { it(`should successfully re-install ${applicationName} application`, async () => { await runCliCommand({ - command: 'app:dev', + command: 'dev', args: [appPath], waitForOutput: '✓ Synced', }); diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/postcard-app/__integration__/app-dev/app-dev.integration.spec.ts b/packages/twenty-sdk/src/cli/__tests__/apps/postcard-app/__integration__/app-dev/app-dev.integration.spec.ts index b8c3835f4dc..2170c7d370b 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/postcard-app/__integration__/app-dev/app-dev.integration.spec.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/postcard-app/__integration__/app-dev/app-dev.integration.spec.ts @@ -4,7 +4,7 @@ import { POSTCARD_APP_PATH } from '@/cli/__tests__/apps/fixture-paths'; import { defineEntitiesTests } from './tests/entities.tests'; import { defineManifestTests } from './tests/manifest.tests'; -describe('postcard-app app:dev', () => { +describe('postcard-app dev', () => { beforeAll(async () => { const result = await runAppDevInProcess({ appPath: POSTCARD_APP_PATH }); @@ -16,7 +16,7 @@ describe('postcard-app app:dev', () => { ); throw new Error( - `app:dev did not produce manifest.json within timeout.\n${diagnostics}`, + `dev did not produce manifest.json within timeout.\n${diagnostics}`, ); } }, 60000); diff --git a/packages/twenty-sdk/src/cli/__tests__/constants/server-url.constant.ts b/packages/twenty-sdk/src/cli/__tests__/constants/server-url.constant.ts index 5e8aa7f2818..ec22eaa8127 100644 --- a/packages/twenty-sdk/src/cli/__tests__/constants/server-url.constant.ts +++ b/packages/twenty-sdk/src/cli/__tests__/constants/server-url.constant.ts @@ -1 +1 @@ -export const SERVER_URL = 'http://localhost:3000'; +export const SERVER_URL = process.env.TWENTY_API_URL ?? 'http://localhost:3000'; diff --git a/packages/twenty-sdk/src/cli/__tests__/constants/setupTest.ts b/packages/twenty-sdk/src/cli/__tests__/constants/setupTest.ts index 4bdfe69946f..82331a32e19 100644 --- a/packages/twenty-sdk/src/cli/__tests__/constants/setupTest.ts +++ b/packages/twenty-sdk/src/cli/__tests__/constants/setupTest.ts @@ -11,12 +11,13 @@ beforeAll(async () => { await ensureDir(path.dirname(testConfigPath)); const configFile = { - profiles: { - default: { + remotes: { + local: { apiUrl: process.env.TWENTY_API_URL, apiKey: process.env.TWENTY_API_KEY, }, }, + defaultRemote: 'local', }; await writeFile(testConfigPath, JSON.stringify(configFile, null, 2)); diff --git a/packages/twenty-sdk/src/cli/__tests__/integration/utils/normalize-manifest.util.ts b/packages/twenty-sdk/src/cli/__tests__/integration/utils/normalize-manifest.util.ts index 1ea1796e994..1d54ae1bd32 100644 --- a/packages/twenty-sdk/src/cli/__tests__/integration/utils/normalize-manifest.util.ts +++ b/packages/twenty-sdk/src/cli/__tests__/integration/utils/normalize-manifest.util.ts @@ -1,5 +1,10 @@ import { type Manifest } from 'twenty-shared/application'; +const sortById = (items: T[]): T[] => + [...items].sort((a, b) => + a.universalIdentifier.localeCompare(b.universalIdentifier), + ); + export const normalizeManifestForComparison = ( manifest: T, ): T => ({ @@ -16,14 +21,31 @@ export const normalizeManifestForComparison = ( ? '[checksum]' : null, }, - logicFunctions: manifest.logicFunctions?.map((fn) => ({ - ...fn, - builtHandlerChecksum: fn.builtHandlerChecksum ? '[checksum]' : null, - })), - frontComponents: manifest.frontComponents?.map((component) => ({ - ...component, - builtComponentChecksum: component.builtComponentChecksum - ? '[checksum]' - : '', - })), + 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), + views: sortById(manifest.views), + navigationMenuItems: sortById(manifest.navigationMenuItems), + pageLayouts: sortById(manifest.pageLayouts), + logicFunctions: sortById( + manifest.logicFunctions?.map((fn) => ({ + ...fn, + builtHandlerChecksum: fn.builtHandlerChecksum ? '[checksum]' : null, + })), + ), + frontComponents: sortById( + manifest.frontComponents?.map((component) => ({ + ...component, + builtComponentChecksum: component.builtComponentChecksum + ? '[checksum]' + : '', + })), + ), }); diff --git a/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-app-dev-in-process.util.ts b/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-app-dev-in-process.util.ts index 82a3101842c..8e38167079f 100644 --- a/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-app-dev-in-process.util.ts +++ b/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-app-dev-in-process.util.ts @@ -1,7 +1,7 @@ import { join } from 'path'; import { OUTPUT_DIR } from 'twenty-shared/application'; -import { AppDevCommand } from '@/cli/commands/app/app-dev'; +import { AppDevCommand } from '@/cli/commands/dev'; import { pathExists } from '@/cli/utilities/file/fs-utils'; export type RunAppDevResult = { diff --git a/packages/twenty-sdk/src/cli/__tests__/integration/utils/setup-app-dev-mocks.ts b/packages/twenty-sdk/src/cli/__tests__/integration/utils/setup-app-dev-mocks.ts index ff4362db1dc..402b506002b 100644 --- a/packages/twenty-sdk/src/cli/__tests__/integration/utils/setup-app-dev-mocks.ts +++ b/packages/twenty-sdk/src/cli/__tests__/integration/utils/setup-app-dev-mocks.ts @@ -5,23 +5,11 @@ const mockApiService = { generateApplicationToken: vi.fn().mockResolvedValue({ success: true, data: { - applicationAccessToken: { token: 'mock-access-token', expiresAt: '' }, - applicationRefreshToken: { token: 'mock-refresh-token', expiresAt: '' }, - }, - }), - renewApplicationToken: vi.fn().mockResolvedValue({ - success: true, - data: { - applicationAccessToken: { - token: 'mock-renewed-access-token', - expiresAt: '', - }, - applicationRefreshToken: { - token: 'mock-renewed-refresh-token', - expiresAt: '', - }, + 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 }), @@ -47,7 +35,7 @@ vi.mock('@/cli/utilities/api/api-service', () => ({ ApiService: class { validateAuth = mockApiService.validateAuth; generateApplicationToken = mockApiService.generateApplicationToken; - renewApplicationToken = mockApiService.renewApplicationToken; + refreshToken = mockApiService.refreshToken; findApplicationRegistrationByUniversalIdentifier = mockApiService.findApplicationRegistrationByUniversalIdentifier; createApplicationRegistration = diff --git a/packages/twenty-sdk/src/cli/cli.ts b/packages/twenty-sdk/src/cli/cli.ts index 689ff61d7fe..a566847a912 100644 --- a/packages/twenty-sdk/src/cli/cli.ts +++ b/packages/twenty-sdk/src/cli/cli.ts @@ -16,8 +16,8 @@ program .version(packageJson.version); program.option( - '--workspace ', - 'Use a specific workspace configuration (overrides the default set by auth:switch)', + '-r, --remote ', + 'Use a specific remote (overrides the default set by remote switch)', ); program.hook('preAction', async (thisCommand) => { @@ -25,17 +25,15 @@ program.hook('preAction', async (thisCommand) => { ? (thisCommand as any).optsWithGlobals() : thisCommand.opts(); - // If --workspace is provided, use it; otherwise, read the persisted default - let workspace = opts.workspace; - if (!workspace) { + let remote = opts.remote; + if (!remote) { const configService = new ConfigService(); - workspace = await configService.getDefaultWorkspace(); + remote = await configService.getDefaultRemote(); + } else { + console.log(chalk.gray(`Using remote: ${remote}`)); } - ConfigService.setActiveWorkspace(workspace); - console.log( - chalk.gray(`👩‍💻 Workspace - ${ConfigService.getActiveWorkspace()}`), - ); + ConfigService.setActiveRemote(remote); }); registerCommands(program); diff --git a/packages/twenty-sdk/src/cli/commands/entity/entity-add.ts b/packages/twenty-sdk/src/cli/commands/add.ts similarity index 100% rename from packages/twenty-sdk/src/cli/commands/entity/entity-add.ts rename to packages/twenty-sdk/src/cli/commands/add.ts diff --git a/packages/twenty-sdk/src/cli/commands/app-command.ts b/packages/twenty-sdk/src/cli/commands/app-command.ts index b67d48ea076..2d986c0504c 100644 --- a/packages/twenty-sdk/src/cli/commands/app-command.ts +++ b/packages/twenty-sdk/src/cli/commands/app-command.ts @@ -1,78 +1,31 @@ import { formatPath } from '@/cli/utilities/file/file-path'; import chalk from 'chalk'; import type { Command } from 'commander'; -import { AppBuildCommand } from './app/app-build'; -import { AppDevCommand } from './app/app-dev'; -import { AppPublishCommand } from './app/app-publish'; -import { AppTypecheckCommand } from './app/app-typecheck'; -import { AppUninstallCommand } from './app/app-uninstall'; -import { AuthListCommand } from './auth/auth-list'; -import { AuthLoginCommand } from './auth/auth-login'; -import { AuthLogoutCommand } from './auth/auth-logout'; -import { AuthStatusCommand } from './auth/auth-status'; -import { LogicFunctionExecuteCommand } from './logic-function/logic-function-execute'; -import { LogicFunctionLogsCommand } from './logic-function/logic-function-logs'; -import { AuthSwitchCommand } from './auth/auth-switch'; -import { EntityAddCommand } from './entity/entity-add'; +import { AppBuildCommand } from './build'; +import { AppDevCommand } from './dev'; +import { AppPublishCommand } from './publish'; +import { AppTypecheckCommand } from './typecheck'; +import { AppUninstallCommand } from './uninstall'; +import { DeployCommand } from './deploy'; +import { LogicFunctionExecuteCommand } from './exec'; +import { LogicFunctionLogsCommand } from './logs'; +import { EntityAddCommand } from './add'; +import { registerRemoteCommands } from './remote'; import { SyncableEntity } from 'twenty-shared/application'; export const registerCommands = (program: Command): void => { - // Auth commands - const listCommand = new AuthListCommand(); - const loginCommand = new AuthLoginCommand(); - const logoutCommand = new AuthLogoutCommand(); - const statusCommand = new AuthStatusCommand(); - const switchCommand = new AuthSwitchCommand(); - - program - .command('auth:login') - .description('Authenticate with Twenty') - .option('--api-key ', 'API key for authentication') - .option('--api-url ', 'Twenty API URL') - .action(async (options) => { - await loginCommand.execute(options); - }); - - program - .command('auth:logout') - .description('Remove authentication credentials') - .action(async () => { - await logoutCommand.execute(); - }); - - program - .command('auth:status') - .description('Check authentication status') - .action(async () => { - await statusCommand.execute(); - }); - - program - .command('auth:switch [workspace]') - .description('Switch the default workspace for authentication') - .action(async (workspace?: string) => { - await switchCommand.execute({ workspace }); - }); - - program - .command('auth:list') - .description('List all configured workspaces') - .action(async () => { - await listCommand.execute(); - }); - - // App commands const buildCommand = new AppBuildCommand(); const devCommand = new AppDevCommand(); const publishCommand = new AppPublishCommand(); const typecheckCommand = new AppTypecheckCommand(); const uninstallCommand = new AppUninstallCommand(); + const deployCommand = new DeployCommand(); const addCommand = new EntityAddCommand(); const logsCommand = new LogicFunctionLogsCommand(); const executeCommand = new LogicFunctionExecuteCommand(); program - .command('app:dev [appPath]') + .command('dev [appPath]') .description('Watch and sync local application changes') .action(async (appPath) => { await devCommand.execute({ @@ -81,7 +34,7 @@ export const registerCommands = (program: Command): void => { }); program - .command('app:build [appPath]') + .command('build [appPath]') .description('Build, sync, and generate API client into .twenty/output/') .option('--tarball', 'Also pack into a .tgz tarball') .action(async (appPath, options) => { @@ -92,24 +45,29 @@ export const registerCommands = (program: Command): void => { }); program - .command('app:publish [appPath]') - .description( - 'Build and publish to npm, or to a Twenty server with --server', - ) - .option('--server ', 'Publish to a Twenty server instead of npm') - .option('--token ', 'Auth token for the server') + .command('deploy [appPath]') + .description('Build and deploy to a Twenty server') + .option('-r, --remote ', 'Deploy to a specific remote') + .action(async (appPath, options) => { + await deployCommand.execute({ + appPath: formatPath(appPath), + remote: options.remote, + }); + }); + + program + .command('publish [appPath]') + .description('Build and publish to npm') .option('--tag ', 'npm dist-tag (e.g. beta, next)') .action(async (appPath, options) => { await publishCommand.execute({ appPath: formatPath(appPath), - server: options.server, - token: options.token, tag: options.tag, }); }); program - .command('app:typecheck [appPath]') + .command('typecheck [appPath]') .description('Run TypeScript type checking on the application') .action(async (appPath) => { await typecheckCommand.execute({ @@ -118,7 +76,7 @@ export const registerCommands = (program: Command): void => { }); program - .command('app:uninstall [appPath]') + .command('uninstall [appPath]') .description('Uninstall application from Twenty') .option('-y, --yes', 'Skip confirmation prompt') .action(async (appPath?: string, options?: { yes?: boolean }) => { @@ -133,8 +91,10 @@ export const registerCommands = (program: Command): void => { } }); + registerRemoteCommands(program); + program - .command('entity:add [entityType]') + .command('add [entityType]') .option('--path ', 'Path in which the entity should be created.') .description( `Add a new entity to your application (${Object.values(SyncableEntity).join('|')})`, @@ -143,35 +103,8 @@ export const registerCommands = (program: Command): void => { await addCommand.execute(entityType as SyncableEntity, options?.path); }); - // Function commands program - .command('function:logs [appPath]') - .option( - '-u, --functionUniversalIdentifier ', - 'Only show logs for the function with this universal ID', - ) - .option( - '-n, --functionName ', - 'Only show logs for the function with this name', - ) - .description('Watch application function logs') - .action( - async ( - appPath?: string, - options?: { - functionUniversalIdentifier?: string; - functionName?: string; - }, - ) => { - await logsCommand.execute({ - ...options, - appPath: formatPath(appPath), - }); - }, - ); - - program - .command('function:execute [appPath]') + .command('exec [appPath]') .option('--postInstall', 'Execute post-install logic function if defined') .option( '-p, --payload ', @@ -216,4 +149,30 @@ export const registerCommands = (program: Command): void => { }); }, ); + + program + .command('logs [appPath]') + .option( + '-u, --functionUniversalIdentifier ', + 'Only show logs for the function with this universal ID', + ) + .option( + '-n, --functionName ', + 'Only show logs for the function with this name', + ) + .description('Watch application function logs') + .action( + async ( + appPath?: string, + options?: { + functionUniversalIdentifier?: string; + functionName?: string; + }, + ) => { + await logsCommand.execute({ + ...options, + appPath: formatPath(appPath), + }); + }, + ); }; diff --git a/packages/twenty-sdk/src/cli/commands/auth/auth-list.ts b/packages/twenty-sdk/src/cli/commands/auth/auth-list.ts deleted file mode 100644 index 86765350637..00000000000 --- a/packages/twenty-sdk/src/cli/commands/auth/auth-list.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { ConfigService } from '@/cli/utilities/config/config-service'; -import chalk from 'chalk'; - -export class AuthListCommand { - private configService = new ConfigService(); - - async execute(): Promise { - try { - const availableWorkspaces = - await this.configService.getAvailableWorkspaces(); - const currentDefault = await this.configService.getDefaultWorkspace(); - - if (availableWorkspaces.length === 0) { - console.log( - chalk.yellow( - '⚠ No workspaces configured. Use `twenty auth:login` to create one.', - ), - ); - return; - } - - console.log(chalk.blue('Available workspaces:\n')); - - for (const workspaceName of availableWorkspaces) { - const config = - await this.configService.getConfigForWorkspace(workspaceName); - const hasCredentials = !!config.apiKey; - const isDefault = workspaceName === currentDefault; - - const defaultIndicator = isDefault ? chalk.green(' (default)') : ''; - const credentialStatus = hasCredentials - ? chalk.green('●') - : chalk.gray('○'); - - console.log( - ` ${credentialStatus} ${workspaceName}${defaultIndicator}`, - ); - console.log(chalk.gray(` API URL: ${config.apiUrl}`)); - } - - console.log(''); - console.log(chalk.gray('● = authenticated, ○ = no credentials')); - console.log( - chalk.gray('Use `twenty auth:switch ` to change default'), - ); - } catch (error) { - console.error( - chalk.red('List failed:'), - error instanceof Error ? error.message : error, - ); - process.exit(1); - } - } -} diff --git a/packages/twenty-sdk/src/cli/commands/auth/auth-login.ts b/packages/twenty-sdk/src/cli/commands/auth/auth-login.ts deleted file mode 100644 index 996b9d07b03..00000000000 --- a/packages/twenty-sdk/src/cli/commands/auth/auth-login.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { authLogin } from '@/cli/public-operations/auth-login'; -import { ConfigService } from '@/cli/utilities/config/config-service'; -import chalk from 'chalk'; -import inquirer from 'inquirer'; - -export class AuthLoginCommand { - private configService = new ConfigService(); - - async execute(options: { apiKey?: string; apiUrl?: string }): Promise { - let { apiKey, apiUrl } = options; - - const config = await this.configService.getConfig(); - - if (!apiUrl) { - const urlAnswer = await inquirer.prompt([ - { - type: 'input', - name: 'apiUrl', - message: 'Twenty API URL:', - default: config.apiUrl, - validate: (input) => { - try { - new URL(input); - return true; - } catch { - return 'Please enter a valid URL'; - } - }, - }, - ]); - apiUrl = urlAnswer.apiUrl; - } - - if (!apiKey) { - const keyAnswer = await inquirer.prompt([ - { - type: 'password', - name: 'apiKey', - message: 'API Key:', - mask: '*', - validate: (input) => input.length > 0 || 'API key is required', - }, - ]); - apiKey = keyAnswer.apiKey; - } - - const result = await authLogin({ apiKey: apiKey!, apiUrl: apiUrl! }); - - if (result.success) { - const activeWorkspace = ConfigService.getActiveWorkspace(); - console.log( - chalk.green( - `✓ Successfully authenticated with Twenty (workspace: ${activeWorkspace})`, - ), - ); - } else { - console.log( - chalk.red('✗ Authentication failed. Please check your credentials.'), - ); - process.exit(1); - } - } -} diff --git a/packages/twenty-sdk/src/cli/commands/auth/auth-logout.ts b/packages/twenty-sdk/src/cli/commands/auth/auth-logout.ts deleted file mode 100644 index 6008e476a45..00000000000 --- a/packages/twenty-sdk/src/cli/commands/auth/auth-logout.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { authLogout } from '@/cli/public-operations/auth-logout'; -import { ConfigService } from '@/cli/utilities/config/config-service'; -import chalk from 'chalk'; - -export class AuthLogoutCommand { - async execute(): Promise { - const result = await authLogout(); - - if (!result.success) { - console.error(chalk.red('Logout failed:'), result.error.message); - process.exit(1); - } - - const activeWorkspace = ConfigService.getActiveWorkspace(); - console.log( - chalk.green(`✓ Successfully logged out (workspace: ${activeWorkspace})`), - ); - } -} diff --git a/packages/twenty-sdk/src/cli/commands/auth/auth-status.ts b/packages/twenty-sdk/src/cli/commands/auth/auth-status.ts deleted file mode 100644 index 0a75ea47907..00000000000 --- a/packages/twenty-sdk/src/cli/commands/auth/auth-status.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ApiService } from '@/cli/utilities/api/api-service'; -import { ConfigService } from '@/cli/utilities/config/config-service'; -import chalk from 'chalk'; - -export class AuthStatusCommand { - private configService = new ConfigService(); - private apiService = new ApiService(); - - async execute(): Promise { - try { - const activeWorkspace = ConfigService.getActiveWorkspace(); - const config = await this.configService.getConfig(); - - console.log(chalk.blue('Authentication Status:')); - console.log(`Workspace: ${activeWorkspace}`); - console.log(`API URL: ${config.apiUrl}`); - console.log( - `API Key: ${config.apiKey ? '***' + config.apiKey.slice(-4) : 'Not set'}`, - ); - - if (config.apiKey) { - const validateAuth = await this.apiService.validateAuth(); - console.log( - `Status: ${validateAuth.authValid ? chalk.green('✓ Valid') : chalk.red('✗ Invalid')}`, - ); - } else { - console.log(`Status: ${chalk.yellow('⚠ Not authenticated')}`); - } - } catch (error) { - console.error( - chalk.red('Status check failed:'), - error instanceof Error ? error.message : error, - ); - process.exit(1); - } - } -} diff --git a/packages/twenty-sdk/src/cli/commands/auth/auth-switch.ts b/packages/twenty-sdk/src/cli/commands/auth/auth-switch.ts deleted file mode 100644 index a2cc34d34ee..00000000000 --- a/packages/twenty-sdk/src/cli/commands/auth/auth-switch.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { ApiService } from '@/cli/utilities/api/api-service'; -import { ConfigService } from '@/cli/utilities/config/config-service'; -import chalk from 'chalk'; -import inquirer from 'inquirer'; - -export class AuthSwitchCommand { - private configService = new ConfigService(); - private apiService = new ApiService(); - - async execute(options: { workspace?: string }): Promise { - try { - let { workspace } = options; - - const availableWorkspaces = - await this.configService.getAvailableWorkspaces(); - const currentDefault = await this.configService.getDefaultWorkspace(); - - if (availableWorkspaces.length === 0) { - console.log( - chalk.yellow( - '⚠ No workspaces configured. Use `twenty auth:login` to create one.', - ), - ); - return; - } - - if (!workspace) { - const choices = availableWorkspaces.map((ws) => ({ - name: ws === currentDefault ? `${ws} (current default)` : ws, - value: ws, - })); - - const answer = await inquirer.prompt([ - { - type: 'list', - name: 'workspace', - message: 'Select a workspace to set as default:', - choices, - default: currentDefault, - }, - ]); - - workspace = answer.workspace as string; - } - - if (!availableWorkspaces.includes(workspace!)) { - console.log( - chalk.red( - `✗ Workspace "${workspace}" not found. Available workspaces: ${availableWorkspaces.join(', ')}`, - ), - ); - process.exit(1); - } - - if (workspace === currentDefault) { - console.log( - chalk.blue(`ℹ "${workspace}" is already the default workspace.`), - ); - return; - } - - await this.configService.setDefaultWorkspace(workspace!); - ConfigService.setActiveWorkspace(workspace); - - const config = await this.configService.getConfig(); - const hasCredentials = !!config.apiKey; - - console.log( - chalk.green(`✓ Switched default workspace to "${workspace}"`), - ); - - if (hasCredentials) { - const validateAuth = await this.apiService.validateAuth(); - - if (validateAuth.authValid) { - console.log(chalk.green('✓ Authentication is valid')); - } else { - console.log( - chalk.yellow( - '⚠ Authentication credentials exist but are invalid. Run `twenty auth:login` to re-authenticate.', - ), - ); - } - } else { - console.log( - chalk.yellow( - '⚠ No credentials configured for this workspace. Run `twenty auth:login` to authenticate.', - ), - ); - } - } catch (error) { - console.error( - chalk.red('Switch failed:'), - error instanceof Error ? error.message : error, - ); - process.exit(1); - } - } -} diff --git a/packages/twenty-sdk/src/cli/commands/app/app-build.ts b/packages/twenty-sdk/src/cli/commands/build.ts similarity index 95% rename from packages/twenty-sdk/src/cli/commands/app/app-build.ts rename to packages/twenty-sdk/src/cli/commands/build.ts index a874a8631ca..451b0fe4b16 100644 --- a/packages/twenty-sdk/src/cli/commands/app/app-build.ts +++ b/packages/twenty-sdk/src/cli/commands/build.ts @@ -1,4 +1,4 @@ -import { appBuild } from '@/cli/public-operations/app-build'; +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'; diff --git a/packages/twenty-sdk/src/cli/commands/deploy.ts b/packages/twenty-sdk/src/cli/commands/deploy.ts new file mode 100644 index 00000000000..c5db2a7c41f --- /dev/null +++ b/packages/twenty-sdk/src/cli/commands/deploy.ts @@ -0,0 +1,56 @@ +import { appDeploy } from '@/cli/operations/deploy'; +import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory'; +import { checkSdkVersionCompatibility } from '@/cli/utilities/version/check-sdk-version-compatibility'; +import { ConfigService } from '@/cli/utilities/config/config-service'; +import chalk from 'chalk'; + +export type DeployCommandOptions = { + appPath?: string; + remote?: string; +}; + +export class DeployCommand { + async execute(options: DeployCommandOptions): Promise { + const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY; + + await checkSdkVersionCompatibility(appPath); + + const configService = new ConfigService(); + let serverUrl: string; + let token: string | undefined; + + if (options.remote) { + const remoteConfig = await configService.getConfigForRemote( + options.remote, + ); + + serverUrl = remoteConfig.apiUrl; + token = remoteConfig.accessToken ?? remoteConfig.apiKey; + } else { + const config = await configService.getConfig(); + + serverUrl = config.apiUrl; + token = config.accessToken ?? config.apiKey; + } + + const remoteName = options.remote ?? ConfigService.getActiveRemote(); + + console.log(chalk.blue(`Deploying to ${remoteName} (${serverUrl})...`)); + console.log(chalk.gray(`App path: ${appPath}`)); + console.log(''); + + const result = await appDeploy({ + appPath, + serverUrl, + token, + onProgress: (message) => console.log(chalk.gray(message)), + }); + + if (!result.success) { + console.error(chalk.red(result.error.message)); + process.exit(1); + } + + console.log(chalk.green('✓ Deployed successfully')); + } +} diff --git a/packages/twenty-sdk/src/cli/commands/app/app-dev.ts b/packages/twenty-sdk/src/cli/commands/dev.ts similarity index 100% rename from packages/twenty-sdk/src/cli/commands/app/app-dev.ts rename to packages/twenty-sdk/src/cli/commands/dev.ts diff --git a/packages/twenty-sdk/src/cli/commands/logic-function/logic-function-execute.ts b/packages/twenty-sdk/src/cli/commands/exec.ts similarity index 95% rename from packages/twenty-sdk/src/cli/commands/logic-function/logic-function-execute.ts rename to packages/twenty-sdk/src/cli/commands/exec.ts index 68b10ccf29d..ca284dbfe67 100644 --- a/packages/twenty-sdk/src/cli/commands/logic-function/logic-function-execute.ts +++ b/packages/twenty-sdk/src/cli/commands/exec.ts @@ -1,8 +1,5 @@ -import { functionExecute } from '@/cli/public-operations/function-execute'; -import { - APP_ERROR_CODES, - FUNCTION_ERROR_CODES, -} from '@/cli/public-operations/types'; +import { functionExecute } from '@/cli/operations/execute'; +import { APP_ERROR_CODES, FUNCTION_ERROR_CODES } from '@/cli/types'; import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory'; import chalk from 'chalk'; import { isDefined } from 'twenty-shared/utils'; @@ -80,7 +77,7 @@ export class LogicFunctionExecuteCommand { } else { console.log( chalk.yellow( - 'No functions found for this application. Have you synced your app with `yarn app:dev`?', + 'No functions found for this application. Have you synced your app with `twenty dev`?', ), ); } diff --git a/packages/twenty-sdk/src/cli/commands/logic-function/logic-function-logs.ts b/packages/twenty-sdk/src/cli/commands/logs.ts similarity index 100% rename from packages/twenty-sdk/src/cli/commands/logic-function/logic-function-logs.ts rename to packages/twenty-sdk/src/cli/commands/logs.ts diff --git a/packages/twenty-sdk/src/cli/commands/app/app-publish.ts b/packages/twenty-sdk/src/cli/commands/publish.ts similarity index 59% rename from packages/twenty-sdk/src/cli/commands/app/app-publish.ts rename to packages/twenty-sdk/src/cli/commands/publish.ts index b0943ddc324..eeda764d0ea 100644 --- a/packages/twenty-sdk/src/cli/commands/app/app-publish.ts +++ b/packages/twenty-sdk/src/cli/commands/publish.ts @@ -1,12 +1,10 @@ -import { appPublish } from '@/cli/public-operations/app-publish'; +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; - server?: string; - token?: string; tag?: string; }; @@ -16,22 +14,12 @@ export class AppPublishCommand { await checkSdkVersionCompatibility(appPath); - const isServerPublish = !!options.server; - - console.log( - chalk.blue( - isServerPublish - ? `Publishing to server ${options.server}...` - : 'Publishing to npm...', - ), - ); + console.log(chalk.blue('Publishing to npm...')); console.log(chalk.gray(`App path: ${appPath}`)); console.log(''); const result = await appPublish({ appPath, - server: options.server, - token: options.token, npmTag: options.tag, onProgress: (message) => console.log(chalk.gray(message)), }); @@ -41,12 +29,6 @@ export class AppPublishCommand { process.exit(1); } - if (result.data.target === 'npm') { - console.log(chalk.green('✓ Published to npm successfully')); - } else { - console.log( - chalk.green('✓ Published to server and installed successfully'), - ); - } + console.log(chalk.green('✓ Published to npm successfully')); } } diff --git a/packages/twenty-sdk/src/cli/commands/remote.ts b/packages/twenty-sdk/src/cli/commands/remote.ts new file mode 100644 index 00000000000..3a3a1400395 --- /dev/null +++ b/packages/twenty-sdk/src/cli/commands/remote.ts @@ -0,0 +1,286 @@ +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 chalk from 'chalk'; +import type { Command } from 'commander'; +import inquirer from 'inquirer'; + +const deriveRemoteName = (url: string): string => { + try { + const hostname = new URL(url).hostname; + + return hostname.replace(/\./g, '-'); + } catch { + return 'remote'; + } +}; + +const authenticate = async (apiUrl: string, token?: string): Promise => { + const result = token + ? await authLogin({ apiKey: token, apiUrl }) + : await runOAuthWithApiKeyFallback(apiUrl); + + if (!result.success) { + console.error(chalk.red('✗ Authentication failed.')); + process.exit(1); + } +}; + +const runOAuthWithApiKeyFallback = async ( + apiUrl: string, +): Promise<{ success: boolean }> => { + await inquirer.prompt([ + { + type: 'input', + name: 'confirm', + message: 'Press Enter to open the browser for authentication...', + }, + ]); + + const oauthResult = await authLoginOAuth({ apiUrl }); + + if (oauthResult.success) { + return oauthResult; + } + + 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', + }, + ]); + + return authLogin({ apiKey: keyAnswer.apiKey, apiUrl }); +}; + +export const registerRemoteCommands = (program: Command): void => { + const remote = program + .command('remote') + .description('Manage remote Twenty servers'); + + remote + .command('add [nameOrUrl]') + .description('Add a new remote or re-authenticate an existing one') + .option('--as ', 'Name for this remote') + .option('--local', 'Connect to local development server') + .option('--token ', 'API key for non-interactive auth') + .option('--url ', 'Server URL (alternative to positional arg)') + .action( + async ( + nameOrUrl: string | undefined, + options: { + as?: string; + local?: boolean; + token?: string; + url?: string; + }, + ) => { + const configService = new ConfigService(); + const existingRemotes = await configService.getRemotes(); + + if (options.local) { + const remoteName = options.as ?? 'local'; + const token = + options.token ?? + ( + await inquirer.prompt<{ apiKey: string }>([ + { + type: 'password', + name: 'apiKey', + message: 'API Key for local server:', + mask: '*', + validate: (input: string) => + input.length > 0 || 'API key is required', + }, + ]) + ).apiKey; + + ConfigService.setActiveRemote(remoteName); + await authenticate('http://localhost:3000', token); + console.log(chalk.green(`✓ Authenticated remote "${remoteName}".`)); + + return; + } + + // Re-authenticate an existing remote by name + const isExistingRemote = + nameOrUrl !== undefined && existingRemotes.includes(nameOrUrl); + + if (isExistingRemote) { + const config = await configService.getConfigForRemote(nameOrUrl); + + ConfigService.setActiveRemote(nameOrUrl); + await authenticate(config.apiUrl, options.token); + console.log(chalk.green(`✓ Re-authenticated remote "${nameOrUrl}".`)); + + return; + } + + // Resolve the URL — from args, flags, or interactive prompt + const apiUrl = + nameOrUrl ?? + options.url ?? + (options.token + ? 'http://localhost:3000' + : ( + await inquirer.prompt<{ apiUrl: string }>([ + { + type: 'input', + name: 'apiUrl', + message: 'Twenty server URL:', + validate: (input: string) => { + try { + new URL(input); + + return true; + } catch { + return 'Please enter a valid URL'; + } + }, + }, + ]) + ).apiUrl); + + const name = options.as ?? deriveRemoteName(apiUrl); + + ConfigService.setActiveRemote(name); + await authenticate(apiUrl, options.token); + + const defaultRemote = await configService.getDefaultRemote(); + + if (defaultRemote === 'local') { + await configService.setDefaultRemote(name); + } + + console.log(chalk.green(`✓ Authenticated remote "${name}".`)); + }, + ); + + remote + .command('list') + .description('List all configured remotes') + .action(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.accessToken + ? '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(''); + console.log( + chalk.gray("Use 'twenty remote switch ' to change default"), + ); + }); + + remote + .command('switch [name]') + .description('Set the default remote') + .action(async (nameArg?: string) => { + const configService = new ConfigService(); + + const remoteName = + nameArg ?? + ( + await inquirer.prompt<{ remote: string }>([ + { + type: 'list', + 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}".`)); + }); + + remote + .command('status') + .description('Show active remote and authentication status') + .action(async () => { + const configService = new ConfigService(); + const apiService = new ApiService(); + const activeRemote = ConfigService.getActiveRemote(); + const config = await configService.getConfig(); + + const authMethod = config.accessToken + ? '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}`); + }); + + remote + .command('remove ') + .description('Remove a remote') + .action(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.`)); + }); +}; diff --git a/packages/twenty-sdk/src/cli/commands/app/app-typecheck.ts b/packages/twenty-sdk/src/cli/commands/typecheck.ts similarity index 100% rename from packages/twenty-sdk/src/cli/commands/app/app-typecheck.ts rename to packages/twenty-sdk/src/cli/commands/typecheck.ts diff --git a/packages/twenty-sdk/src/cli/commands/app/app-uninstall.ts b/packages/twenty-sdk/src/cli/commands/uninstall.ts similarity index 95% rename from packages/twenty-sdk/src/cli/commands/app/app-uninstall.ts rename to packages/twenty-sdk/src/cli/commands/uninstall.ts index 41b3600c589..59cd170bbcc 100644 --- a/packages/twenty-sdk/src/cli/commands/app/app-uninstall.ts +++ b/packages/twenty-sdk/src/cli/commands/uninstall.ts @@ -1,4 +1,4 @@ -import { appUninstall } from '@/cli/public-operations/app-uninstall'; +import { appUninstall } from '@/cli/operations/uninstall'; import { type ApiResponse } from '@/cli/utilities/api/api-response-type'; import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory'; import chalk from 'chalk'; diff --git a/packages/twenty-sdk/src/cli/public-operations/app-build.ts b/packages/twenty-sdk/src/cli/operations/build.ts similarity index 98% rename from packages/twenty-sdk/src/cli/public-operations/app-build.ts rename to packages/twenty-sdk/src/cli/operations/build.ts index de37aba84ef..bd11992d8be 100644 --- a/packages/twenty-sdk/src/cli/public-operations/app-build.ts +++ b/packages/twenty-sdk/src/cli/operations/build.ts @@ -7,7 +7,7 @@ import { runTypecheck } from '@/cli/utilities/build/common/typecheck-plugin'; import { buildAndValidateManifest } from '@/cli/utilities/build/manifest/build-and-validate-manifest'; import { ClientService } from '@/cli/utilities/client/client-service'; import { runSafe } from '@/cli/utilities/run-safe'; -import { APP_ERROR_CODES, type CommandResult } from './types'; +import { APP_ERROR_CODES, type CommandResult } from '@/cli/types'; export type AppBuildOptions = { appPath: string; diff --git a/packages/twenty-sdk/src/cli/operations/deploy.ts b/packages/twenty-sdk/src/cli/operations/deploy.ts new file mode 100644 index 00000000000..cf90846c862 --- /dev/null +++ b/packages/twenty-sdk/src/cli/operations/deploy.ts @@ -0,0 +1,82 @@ +import fs from 'fs'; + +import { ApiService } from '@/cli/utilities/api/api-service'; +import { runSafe } from '@/cli/utilities/run-safe'; +import { appBuild } from './build'; +import { APP_ERROR_CODES, type CommandResult } from '@/cli/types'; + +export type AppDeployOptions = { + appPath: string; + serverUrl: string; + token?: string; + onProgress?: (message: string) => void; +}; + +export type AppDeployResult = { + universalIdentifier: string; +}; + +const innerAppDeploy = async ( + options: AppDeployOptions, +): Promise> => { + const { appPath, serverUrl, token, onProgress } = options; + + const buildResult = await appBuild({ + appPath, + tarball: true, + onProgress, + }); + + if (!buildResult.success) { + return buildResult; + } + + onProgress?.(`Uploading ${buildResult.data.tarballPath}...`); + + const tarballBuffer = fs.readFileSync(buildResult.data.tarballPath!); + + const apiService = new ApiService({ + serverUrl, + 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}`, + }, + }; + } + + onProgress?.('Installing application...'); + + const installResult = await apiService.installTarballApp({ + universalIdentifier: uploadResult.data.universalIdentifier, + }); + + if (!installResult.success) { + return { + success: false, + error: { + code: APP_ERROR_CODES.DEPLOY_FAILED, + message: `Install failed: ${installResult.error}`, + }, + }; + } + + return { + success: true, + data: { + universalIdentifier: uploadResult.data.universalIdentifier, + }, + }; +}; + +export const appDeploy = ( + options: AppDeployOptions, +): Promise> => + runSafe(() => innerAppDeploy(options), APP_ERROR_CODES.DEPLOY_FAILED); diff --git a/packages/twenty-sdk/src/cli/public-operations/function-execute.ts b/packages/twenty-sdk/src/cli/operations/execute.ts similarity index 95% rename from packages/twenty-sdk/src/cli/public-operations/function-execute.ts rename to packages/twenty-sdk/src/cli/operations/execute.ts index c2efed93867..e90832dfe58 100644 --- a/packages/twenty-sdk/src/cli/public-operations/function-execute.ts +++ b/packages/twenty-sdk/src/cli/operations/execute.ts @@ -8,11 +8,11 @@ import { FUNCTION_ERROR_CODES, type CommandResult, type FunctionExecutionResult, -} from './types'; +} from '@/cli/types'; export type FunctionExecuteOptions = { appPath: string; - workspace?: string; + remote?: string; payload?: Record; } & ( | { postInstall: true } @@ -49,8 +49,8 @@ const resolveIdentifier = (options: FunctionExecuteOptions): string => { const innerFunctionExecute = async ( options: FunctionExecuteOptions, ): Promise> => { - if (options.workspace) { - ConfigService.setActiveWorkspace(options.workspace); + if (options.remote) { + ConfigService.setActiveRemote(options.remote); } const apiService = new ApiService(); @@ -61,7 +61,7 @@ const innerFunctionExecute = async ( success: false, error: { code: APP_ERROR_CODES.MANIFEST_NOT_FOUND, - message: 'Manifest not found. Run `app:build` or `app:dev` first.', + message: 'Manifest not found. Run `build` or `dev` first.', }, }; } diff --git a/packages/twenty-sdk/src/cli/operations/index.ts b/packages/twenty-sdk/src/cli/operations/index.ts new file mode 100644 index 00000000000..5c29dd5bbc4 --- /dev/null +++ b/packages/twenty-sdk/src/cli/operations/index.ts @@ -0,0 +1,36 @@ +// 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 { appPublish } from './publish'; +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'; + +// Shared types and error codes +export { + APP_ERROR_CODES, + AUTH_ERROR_CODES, + FUNCTION_ERROR_CODES, +} from '@/cli/types'; +export type { + AuthListRemote, + AuthStatusResult, + CommandError, + CommandResult, + FunctionExecutionResult, + TypecheckResult, +} from '@/cli/types'; diff --git a/packages/twenty-sdk/src/cli/operations/login-oauth.ts b/packages/twenty-sdk/src/cli/operations/login-oauth.ts new file mode 100644 index 00000000000..a8fb03af3c9 --- /dev/null +++ b/packages/twenty-sdk/src/cli/operations/login-oauth.ts @@ -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 => { + 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, + accessToken, + refreshToken, + oauthClientId: 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 => + runSafe(() => innerAuthLoginOAuth(options), AUTH_ERROR_CODES.AUTH_FAILED); diff --git a/packages/twenty-sdk/src/cli/public-operations/auth-login.ts b/packages/twenty-sdk/src/cli/operations/login.ts similarity index 73% rename from packages/twenty-sdk/src/cli/public-operations/auth-login.ts rename to packages/twenty-sdk/src/cli/operations/login.ts index dabfbb44755..0aaa05478ba 100644 --- a/packages/twenty-sdk/src/cli/public-operations/auth-login.ts +++ b/packages/twenty-sdk/src/cli/operations/login.ts @@ -1,26 +1,32 @@ 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 './types'; +import { AUTH_ERROR_CODES, type CommandResult } from '@/cli/types'; export type AuthLoginOptions = { apiKey: string; apiUrl: string; - workspace?: string; + remote?: string; }; const innerAuthLogin = async ( options: AuthLoginOptions, ): Promise => { - const { apiKey, apiUrl, workspace } = options; + const { apiKey, apiUrl, remote } = options; - if (workspace) { - ConfigService.setActiveWorkspace(workspace); + if (remote) { + ConfigService.setActiveRemote(remote); } const configService = new ConfigService(); - await configService.setConfig({ apiUrl, apiKey }); + await configService.setConfig({ + apiUrl, + apiKey, + accessToken: undefined, + refreshToken: undefined, + oauthClientId: undefined, + }); const apiService = new ApiService(); const validateAuth = await apiService.validateAuth(); diff --git a/packages/twenty-sdk/src/cli/public-operations/auth-logout.ts b/packages/twenty-sdk/src/cli/operations/logout.ts similarity index 76% rename from packages/twenty-sdk/src/cli/public-operations/auth-logout.ts rename to packages/twenty-sdk/src/cli/operations/logout.ts index ad5ec01fdb8..507bb5d5f82 100644 --- a/packages/twenty-sdk/src/cli/public-operations/auth-logout.ts +++ b/packages/twenty-sdk/src/cli/operations/logout.ts @@ -1,16 +1,16 @@ import { ConfigService } from '@/cli/utilities/config/config-service'; import { runSafe } from '@/cli/utilities/run-safe'; -import { AUTH_ERROR_CODES, type CommandResult } from './types'; +import { AUTH_ERROR_CODES, type CommandResult } from '@/cli/types'; export type AuthLogoutOptions = { - workspace?: string; + remote?: string; }; const innerAuthLogout = async ( options?: AuthLogoutOptions, ): Promise => { - if (options?.workspace) { - ConfigService.setActiveWorkspace(options.workspace); + if (options?.remote) { + ConfigService.setActiveRemote(options.remote); } const configService = new ConfigService(); diff --git a/packages/twenty-sdk/src/cli/operations/publish.ts b/packages/twenty-sdk/src/cli/operations/publish.ts new file mode 100644 index 00000000000..9e71c144721 --- /dev/null +++ b/packages/twenty-sdk/src/cli/operations/publish.ts @@ -0,0 +1,57 @@ +import { execSync } 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> => { + const { appPath, onProgress } = options; + + const buildResult = await appBuild({ + appPath, + onProgress, + }); + + if (!buildResult.success) { + return buildResult; + } + + onProgress?.('Publishing to npm...'); + + const tagArg = options.npmTag ? ` --tag ${options.npmTag}` : ''; + + try { + execSync(`npm publish${tagArg}`, { + cwd: buildResult.data.outputDir, + stdio: 'inherit', + }); + } catch { + return { + success: false, + error: { + code: APP_ERROR_CODES.PUBLISH_FAILED, + message: + 'npm publish failed. Make sure you are logged in (`npm login`) and the package name is available.', + }, + }; + } + + return { success: true, data: { target: 'npm' } }; +}; + +export const appPublish = ( + options: AppPublishOptions, +): Promise> => + runSafe(() => innerAppPublish(options), APP_ERROR_CODES.PUBLISH_FAILED); diff --git a/packages/twenty-sdk/src/cli/public-operations/app-uninstall.ts b/packages/twenty-sdk/src/cli/operations/uninstall.ts similarity index 84% rename from packages/twenty-sdk/src/cli/public-operations/app-uninstall.ts rename to packages/twenty-sdk/src/cli/operations/uninstall.ts index ada5476c588..3237af33d9a 100644 --- a/packages/twenty-sdk/src/cli/public-operations/app-uninstall.ts +++ b/packages/twenty-sdk/src/cli/operations/uninstall.ts @@ -2,18 +2,18 @@ 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 './types'; +import { APP_ERROR_CODES, type CommandResult } from '@/cli/types'; export type AppUninstallOptions = { appPath: string; - workspace?: string; + remote?: string; }; const innerAppUninstall = async ( options: AppUninstallOptions, ): Promise => { - if (options.workspace) { - ConfigService.setActiveWorkspace(options.workspace); + if (options.remote) { + ConfigService.setActiveRemote(options.remote); } const apiService = new ApiService(); @@ -24,7 +24,7 @@ const innerAppUninstall = async ( success: false, error: { code: APP_ERROR_CODES.MANIFEST_NOT_FOUND, - message: 'Manifest not found. Run `app:build` or `app:dev` first.', + message: 'Manifest not found. Run `build` or `dev` first.', }, }; } diff --git a/packages/twenty-sdk/src/cli/public-operations/app-publish.ts b/packages/twenty-sdk/src/cli/public-operations/app-publish.ts deleted file mode 100644 index 3d3a4e04e41..00000000000 --- a/packages/twenty-sdk/src/cli/public-operations/app-publish.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { execSync } from 'child_process'; -import fs from 'fs'; - -import { ApiService } from '@/cli/utilities/api/api-service'; -import { runSafe } from '@/cli/utilities/run-safe'; -import { appBuild } from './app-build'; -import { APP_ERROR_CODES, type CommandResult } from './types'; - -export type AppPublishOptions = { - appPath: string; - server?: string; - token?: string; - npmTag?: string; - onProgress?: (message: string) => void; -}; - -export type AppPublishResult = { - target: 'npm' | 'server'; - universalIdentifier?: string; -}; - -const innerAppPublish = async ( - options: AppPublishOptions, -): Promise> => { - const { appPath, onProgress } = options; - const isServerPublish = !!options.server; - - const buildResult = await appBuild({ - appPath, - tarball: isServerPublish, - onProgress, - }); - - if (!buildResult.success) { - return buildResult; - } - - if (isServerPublish) { - return publishToServer({ - tarballPath: buildResult.data.tarballPath!, - server: options.server!, - token: options.token, - onProgress, - }); - } - - return publishToNpm({ - outputDir: buildResult.data.outputDir, - npmTag: options.npmTag, - onProgress, - }); -}; - -const publishToNpm = async ({ - outputDir, - npmTag, - onProgress, -}: { - outputDir: string; - npmTag?: string; - onProgress?: (message: string) => void; -}): Promise> => { - onProgress?.('Publishing to npm...'); - - const tagArg = npmTag ? ` --tag ${npmTag}` : ''; - - try { - execSync(`npm publish${tagArg}`, { - cwd: outputDir, - stdio: 'inherit', - }); - } catch { - return { - success: false, - error: { - code: APP_ERROR_CODES.PUBLISH_FAILED, - message: - 'npm publish failed. Make sure you are logged in (`npm login`) and the package name is available.', - }, - }; - } - - return { success: true, data: { target: 'npm' } }; -}; - -const publishToServer = async ({ - tarballPath, - server, - token, - onProgress, -}: { - tarballPath: string; - server: string; - token?: string; - onProgress?: (message: string) => void; -}): Promise> => { - onProgress?.(`Uploading ${tarballPath}...`); - - const tarballBuffer = fs.readFileSync(tarballPath); - - const apiService = new ApiService({ - serverUrl: server, - token, - }); - - const uploadResult = await apiService.uploadAppTarball({ tarballBuffer }); - - if (!uploadResult.success) { - return { - success: false, - error: { - code: APP_ERROR_CODES.PUBLISH_FAILED, - message: `Upload failed: ${uploadResult.error}`, - }, - }; - } - - onProgress?.('Installing application...'); - - const installResult = await apiService.installTarballApp({ - universalIdentifier: uploadResult.data.universalIdentifier, - }); - - if (!installResult.success) { - return { - success: false, - error: { - code: APP_ERROR_CODES.PUBLISH_FAILED, - message: `Install failed: ${installResult.error}`, - }, - }; - } - - return { - success: true, - data: { - target: 'server', - universalIdentifier: uploadResult.data.universalIdentifier, - }, - }; -}; - -export const appPublish = ( - options: AppPublishOptions, -): Promise> => - runSafe(() => innerAppPublish(options), APP_ERROR_CODES.PUBLISH_FAILED); diff --git a/packages/twenty-sdk/src/cli/public-operations/index.ts b/packages/twenty-sdk/src/cli/public-operations/index.ts deleted file mode 100644 index b793cad9746..00000000000 --- a/packages/twenty-sdk/src/cli/public-operations/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Auth -export { authLogin } from './auth-login'; -export type { AuthLoginOptions } from './auth-login'; -export { authLogout } from './auth-logout'; -export type { AuthLogoutOptions } from './auth-logout'; - -// App -export { appBuild } from './app-build'; -export type { AppBuildOptions, AppBuildResult } from './app-build'; -export { appPublish } from './app-publish'; -export type { AppPublishOptions, AppPublishResult } from './app-publish'; -export { appUninstall } from './app-uninstall'; -export type { AppUninstallOptions } from './app-uninstall'; - -// Functions -export { functionExecute } from './function-execute'; -export type { FunctionExecuteOptions } from './function-execute'; - -// Shared types and error codes -export { - APP_ERROR_CODES, - AUTH_ERROR_CODES, - FUNCTION_ERROR_CODES, -} from './types'; -export type { - AuthListWorkspace, - AuthStatusResult, - CommandError, - CommandResult, - FunctionExecutionResult, - TypecheckResult, -} from './types'; diff --git a/packages/twenty-sdk/src/cli/public-operations/types.ts b/packages/twenty-sdk/src/cli/types.ts similarity index 87% rename from packages/twenty-sdk/src/cli/public-operations/types.ts rename to packages/twenty-sdk/src/cli/types.ts index 335259a541c..ebbb0952687 100644 --- a/packages/twenty-sdk/src/cli/public-operations/types.ts +++ b/packages/twenty-sdk/src/cli/types.ts @@ -10,8 +10,9 @@ export type CommandResult = export const AUTH_ERROR_CODES = { AUTH_FAILED: 'AUTH_FAILED', - NO_WORKSPACES: 'NO_WORKSPACES', - WORKSPACE_NOT_FOUND: 'WORKSPACE_NOT_FOUND', + NO_REMOTES: 'NO_REMOTES', + REMOTE_NOT_FOUND: 'REMOTE_NOT_FOUND', + OAUTH_NOT_SUPPORTED: 'OAUTH_NOT_SUPPORTED', } as const; export const APP_ERROR_CODES = { @@ -22,6 +23,7 @@ export const APP_ERROR_CODES = { UNINSTALL_FAILED: 'UNINSTALL_FAILED', SYNC_FAILED: 'SYNC_FAILED', TYPECHECK_FAILED: 'TYPECHECK_FAILED', + DEPLOY_FAILED: 'DEPLOY_FAILED', } as const; export const FUNCTION_ERROR_CODES = { @@ -31,14 +33,14 @@ export const FUNCTION_ERROR_CODES = { } as const; export type AuthStatusResult = { - workspace: string; + remote: string; apiUrl: string; apiKeyMasked: string | null; isAuthenticated: boolean; isValid: boolean; }; -export type AuthListWorkspace = { +export type AuthListRemote = { name: string; apiUrl: string; hasCredentials: boolean; diff --git a/packages/twenty-sdk/src/cli/utilities/api/api-client.ts b/packages/twenty-sdk/src/cli/utilities/api/api-client.ts new file mode 100644 index 00000000000..fcc85960320 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/api/api-client.ts @@ -0,0 +1,177 @@ +import { ConfigService } from '@/cli/utilities/config/config-service'; +import axios, { type AxiosInstance } from 'axios'; +import chalk from 'chalk'; + +export class ApiClient { + readonly client: AxiosInstance; + readonly configService: ConfigService; + private readonly tokenOverride?: string; + readonly serverUrlOverride?: string; + + constructor(options?: { + disableInterceptors?: boolean; + serverUrl?: string; + token?: string; + }) { + const { disableInterceptors = false, serverUrl, token } = 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) { + const authToken = await this.resolveAuthToken(); + + if (authToken) { + config.headers.Authorization = `Bearer ${authToken}`; + } + } + + return config; + }); + + if (disableInterceptors) { + return; + } + + this.client.interceptors.response.use( + (response) => response, + async (error) => { + if (error.response?.status === 401) { + console.error( + chalk.red( + 'Authentication failed. Run `twenty remote add` to authenticate.', + ), + ); + } 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; + }, + ); + } + + 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 { + const config = await this.configService.getConfig(); + + if (!config.refreshToken || !config.oauthClientId) { + return null; + } + + try { + const tokenResponse = await axios.post(`${config.apiUrl}/oauth/token`, { + grant_type: 'refresh_token', + refresh_token: config.refreshToken, + client_id: config.oauthClientId, + }); + + const { access_token: newAccessToken, refresh_token: newRefreshToken } = + tokenResponse.data; + + await this.configService.setConfig({ + accessToken: newAccessToken, + ...(newRefreshToken ? { refreshToken: newRefreshToken } : {}), + }); + + return newAccessToken; + } catch { + return null; + } + } + + async resolveAuthToken(): Promise { + if (this.tokenOverride) { + return this.tokenOverride; + } + + const envToken = process.env.TWENTY_TOKEN; + + if (envToken) { + return envToken; + } + + const config = await this.configService.getConfig(); + const accessToken = config.accessToken; + + if (accessToken && this.isTokenExpired(accessToken)) { + const refreshed = await this.refreshToken(); + + if (refreshed) { + return refreshed; + } + } + + return accessToken ?? 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; + } + } +} diff --git a/packages/twenty-sdk/src/cli/utilities/api/api-service.ts b/packages/twenty-sdk/src/cli/utilities/api/api-service.ts index 249c2b3ee73..97619e27d3a 100644 --- a/packages/twenty-sdk/src/cli/utilities/api/api-service.ts +++ b/packages/twenty-sdk/src/cli/utilities/api/api-service.ts @@ -1,998 +1,105 @@ +import { ApiClient } from '@/cli/utilities/api/api-client'; import { type ApiResponse } from '@/cli/utilities/api/api-response-type'; -import { ConfigService } from '@/cli/utilities/config/config-service'; -import axios, { type AxiosInstance, type AxiosResponse } from 'axios'; -import chalk from 'chalk'; -import * as fs from 'fs'; -import { buildClientSchema, getIntrospectionQuery, printSchema } from 'graphql'; -import { createClient } from 'graphql-sse'; -import * as path from 'path'; +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 Manifest } from 'twenty-shared/application'; -import { type FileFolder } from 'twenty-shared/types'; -import { pascalCase } from 'twenty-shared/utils'; + +type ApiServiceOptions = { + disableInterceptors?: boolean; + serverUrl?: string; + token?: string; +}; export class ApiService { - private client: AxiosInstance; - private configService: ConfigService; + private apiClient: ApiClient; + private applicationApi: ApplicationApi; + private schemaApi: SchemaApi; + private logicFunctionApi: LogicFunctionApi; + private fileApi: FileApi; - constructor(options?: { - disableInterceptors?: boolean; - serverUrl?: string; - token?: string; - }) { - const { disableInterceptors = false, serverUrl, token } = options || {}; - this.configService = new ConfigService(); - this.client = axios.create(); - - this.client.interceptors.request.use(async (config) => { - const twentyConfig = await this.configService.getConfig(); - - config.baseURL = serverUrl ?? twentyConfig.apiUrl; - - const authToken = token ?? twentyConfig.apiKey; - - if (!config.headers.Authorization && authToken) { - config.headers.Authorization = `Bearer ${authToken}`; - } - - return config; - }); - - if (disableInterceptors) { - return; - } - - this.client.interceptors.response.use( - (response) => response, - (error) => { - if (error.response?.status === 401) { - console.error( - chalk.red( - 'Authentication failed. Please run `twenty auth:login` first.', - ), - ); - } 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; - }, - ); + 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); } - 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 { - return { - authValid: false, - serverUp: false, - }; - } + validateAuth(): Promise<{ authValid: boolean; serverUp: boolean }> { + return this.apiClient.validateAuth(); } - 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: AxiosResponse = 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, - }; - } + refreshToken(): Promise { + return this.apiClient.refreshToken(); } - async renewApplicationToken(applicationRefreshToken: string): Promise< - ApiResponse<{ - applicationAccessToken: { token: string; expiresAt: string }; - applicationRefreshToken: { token: string; expiresAt: string }; - }> - > { - try { - const mutation = ` - mutation RenewApplicationToken($applicationRefreshToken: String!) { - renewApplicationToken(applicationRefreshToken: $applicationRefreshToken) { - applicationAccessToken { - token - expiresAt - } - applicationRefreshToken { - token - expiresAt - } - } - } - `; - - const response: AxiosResponse = await this.client.post( - '/metadata', - { - query: mutation, - variables: { applicationRefreshToken }, - }, - { - 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.renewApplicationToken, - }; - } 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 createApplicationRegistration(input: { - name: string; - description?: 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 createDevelopmentApplication(input: { - universalIdentifier: string; - name: string; - }): Promise> { - 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): Promise { - try { - const mutation = ` - mutation SyncApplication($manifest: JSON!) { - syncApplication(manifest: $manifest) { - applicationUniversalIdentifier - actions - } - } - `; - - const variables = { manifest }; - - 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], - }; - } - - return { - success: true, - data: response.data.data.syncApplication, - message: `Successfully synced application: ${manifest.application.displayName}`, - }; - } catch (error) { - if (axios.isAxiosError(error) && error.response) { - const graphqlErrors = error.response.data?.errors; - - if (Array.isArray(graphqlErrors) && graphqlErrors.length > 0) { - return { - success: false, - error: graphqlErrors[0]?.message || error.message, - }; - } - - return { - success: false, - error: - error.response.data?.message || - `HTTP ${error.response.status}: ${error.message}`, - }; - } - - return { - success: false, - error: error instanceof Error ? error.message : error, - }; - } - } - - async uninstallApplication( - universalIdentifier: string, - ): Promise { - try { - const mutation = ` - mutation UninstallApplication($universalIdentifier: String!) { - uninstallApplication(universalIdentifier: $universalIdentifier) - } - `; - - const variables = { universalIdentifier }; - - 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 || '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; - } - } - - async getSchema(options?: { - authToken?: string; - }): Promise> { - return this.introspectEndpoint('/graphql', options); - } - - async getMetadataSchema(options?: { - authToken?: string; - }): Promise> { - return this.introspectEndpoint('/metadata', options); - } - - private async introspectEndpoint( - endpoint: string, - options?: { authToken?: string }, - ): Promise> { - try { - const introspectionQuery = getIntrospectionQuery(); - - const headers: Record = { - 'Content-Type': 'application/json', - Accept: '*/*', - }; - - if (options?.authToken) { - headers.Authorization = `Bearer ${options.authToken}`; - } - - 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; - } - } - - async findLogicFunctions(): Promise< - ApiResponse< - Array<{ - id: string; - name: string; - universalIdentifier: string; - applicationId: string | null; - }> + findApplicationRegistrationByUniversalIdentifier( + ...args: Parameters< + ApplicationApi['findApplicationRegistrationByUniversalIdentifier'] > - > { - try { - const query = ` - query FindManyLogicFunctions { - findManyLogicFunctions { - id - name - universalIdentifier - applicationId - } - } - `; - - 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]?.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; - }): 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.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.configService.getConfig(); - - const wsClient = createClient({ - url: twentyConfig.apiUrl + '/metadata', - headers: { - Authorization: `Bearer ${twentyConfig.apiKey}`, - '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'), - }, + ) { + return this.applicationApi.findApplicationRegistrationByUniversalIdentifier( + ...args, ); } - // 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) { - return { - success: false, - error: error.response.data?.errors?.[0]?.message || error.message, - }; - } - - return { - success: false, - error, - }; - } + createApplicationRegistration( + ...args: Parameters + ) { + return this.applicationApi.createApplicationRegistration(...args); } - async installTarballApp({ - universalIdentifier, - }: { - universalIdentifier: string; - }): Promise> { - try { - const mutation = ` - mutation InstallMarketplaceApp($universalIdentifier: String!) { - installMarketplaceApp(universalIdentifier: $universalIdentifier) - } - `; - - 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]?.message || 'Failed to install application', - }; - } - - return { - success: true, - data: response.data.data.installMarketplaceApp, - }; - } catch (error) { - if (axios.isAxiosError(error) && error.response) { - return { - success: false, - error: error.response.data?.errors?.[0]?.message || error.message, - }; - } - - return { - success: false, - error, - }; - } + createDevelopmentApplication( + ...args: Parameters + ) { + return this.applicationApi.createDevelopmentApplication(...args); } - async uploadFile({ - filePath, - builtHandlerPath, - fileFolder, - applicationUniversalIdentifier, - }: { - filePath: string; - builtHandlerPath: string; - fileFolder: FileFolder; - applicationUniversalIdentifier: string; - }): Promise> { - 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 = this.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, - }; - } + syncApplication(manifest: Manifest): Promise { + return this.applicationApi.syncApplication(manifest); } - private getMimeType(filename: string): string { - const ext = path.extname(filename).toLowerCase(); - const mimeTypes: Record = { - '.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', - }; + uninstallApplication(universalIdentifier: string): Promise { + return this.applicationApi.uninstallApplication(universalIdentifier); + } - return mimeTypes[ext] || 'application/octet-stream'; + getSchema(options?: { authToken?: string }): Promise> { + return this.schemaApi.getSchema(options); + } + + getMetadataSchema(options?: { + authToken?: string; + }): Promise> { + return this.schemaApi.getMetadataSchema(options); + } + + findLogicFunctions( + ...args: Parameters + ) { + return this.logicFunctionApi.findLogicFunctions(...args); + } + + executeLogicFunction( + ...args: Parameters + ) { + return this.logicFunctionApi.executeLogicFunction(...args); + } + + subscribeToLogs(...args: Parameters) { + return this.logicFunctionApi.subscribeToLogs(...args); + } + + uploadAppTarball(...args: Parameters) { + return this.fileApi.uploadAppTarball(...args); + } + + installTarballApp(...args: Parameters) { + return this.fileApi.installTarballApp(...args); + } + + uploadFile(...args: Parameters) { + return this.fileApi.uploadFile(...args); } } diff --git a/packages/twenty-sdk/src/cli/utilities/api/application-api.ts b/packages/twenty-sdk/src/cli/utilities/api/application-api.ts new file mode 100644 index 00000000000..ee300b70716 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/api/application-api.ts @@ -0,0 +1,286 @@ +import { type ApiResponse } from '@/cli/utilities/api/api-response-type'; +import axios, { type AxiosInstance, type AxiosResponse } from 'axios'; +import { type Manifest } from 'twenty-shared/application'; + +export class ApplicationApi { + constructor(private readonly client: AxiosInstance) {} + + 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 createApplicationRegistration(input: { + name: string; + description?: 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 createDevelopmentApplication(input: { + universalIdentifier: string; + name: string; + }): Promise> { + 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): Promise { + try { + const mutation = ` + mutation SyncApplication($manifest: JSON!) { + syncApplication(manifest: $manifest) { + applicationUniversalIdentifier + actions + } + } + `; + + const variables = { manifest }; + + 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], + }; + } + + return { + success: true, + data: response.data.data.syncApplication, + message: `Successfully synced application: ${manifest.application.displayName}`, + }; + } catch (error) { + if (axios.isAxiosError(error) && error.response) { + const graphqlErrors = error.response.data?.errors; + + if (Array.isArray(graphqlErrors) && graphqlErrors.length > 0) { + return { + success: false, + error: graphqlErrors[0]?.message || error.message, + }; + } + + return { + success: false, + error: + error.response.data?.message || + `HTTP ${error.response.status}: ${error.message}`, + }; + } + + return { + success: false, + error: error instanceof Error ? error.message : error, + }; + } + } + + async uninstallApplication( + universalIdentifier: string, + ): Promise { + try { + const mutation = ` + mutation UninstallApplication($universalIdentifier: String!) { + uninstallApplication(universalIdentifier: $universalIdentifier) + } + `; + + const variables = { universalIdentifier }; + + 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 || '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; + } + } +} diff --git a/packages/twenty-sdk/src/cli/utilities/api/file-api.ts b/packages/twenty-sdk/src/cli/utilities/api/file-api.ts new file mode 100644 index 00000000000..81bfe83c1ef --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/api/file-api.ts @@ -0,0 +1,277 @@ +import { type ApiResponse } from '@/cli/utilities/api/api-response-type'; +import axios, { type AxiosInstance, type AxiosResponse } from 'axios'; +import * as fs from 'fs'; +import * as path from 'path'; +import { type FileFolder } from 'twenty-shared/types'; +import { pascalCase } from 'twenty-shared/utils'; + +const MIME_TYPES: Record = { + '.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 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) { + return { + success: false, + error: error.response.data?.errors?.[0]?.message || error.message, + }; + } + + return { + success: false, + error, + }; + } + } + + async installTarballApp({ + universalIdentifier, + }: { + universalIdentifier: string; + }): Promise> { + try { + const mutation = ` + mutation InstallMarketplaceApp($universalIdentifier: String!) { + installMarketplaceApp(universalIdentifier: $universalIdentifier) + } + `; + + 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]?.message || 'Failed to install application', + }; + } + + return { + success: true, + data: response.data.data.installMarketplaceApp, + }; + } 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 uploadFile({ + filePath, + builtHandlerPath, + fileFolder, + applicationUniversalIdentifier, + }: { + filePath: string; + builtHandlerPath: string; + fileFolder: FileFolder; + applicationUniversalIdentifier: string; + }): Promise> { + 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, + }; + } + } +} diff --git a/packages/twenty-sdk/src/cli/utilities/api/logic-function-api.ts b/packages/twenty-sdk/src/cli/utilities/api/logic-function-api.ts new file mode 100644 index 00000000000..54e6d9e8016 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/api/logic-function-api.ts @@ -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; + }): 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'), + }, + ); + } +} diff --git a/packages/twenty-sdk/src/cli/utilities/api/schema-api.ts b/packages/twenty-sdk/src/cli/utilities/api/schema-api.ts new file mode 100644 index 00000000000..5cc0a921212 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/api/schema-api.ts @@ -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?: { + authToken?: string; + }): Promise> { + return this.introspectEndpoint('/graphql', options); + } + + async getMetadataSchema(options?: { + authToken?: string; + }): Promise> { + return this.introspectEndpoint('/metadata', options); + } + + private async introspectEndpoint( + endpoint: string, + options?: { authToken?: string }, + ): Promise> { + try { + const introspectionQuery = getIntrospectionQuery(); + + const headers: Record = { + 'Content-Type': 'application/json', + Accept: '*/*', + }; + + if (options?.authToken) { + headers.Authorization = `Bearer ${options.authToken}`; + } + + 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; + } + } +} diff --git a/packages/twenty-sdk/src/cli/utilities/auth/__tests__/callback-server.test.ts b/packages/twenty-sdk/src/cli/utilities/auth/__tests__/callback-server.test.ts new file mode 100644 index 00000000000..2159793af18 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/auth/__tests__/callback-server.test.ts @@ -0,0 +1,92 @@ +import http from 'node:http'; + +import { startCallbackServer } from '../callback-server'; + +const httpGet = (url: string): Promise<{ status: number; body: string }> => + 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 })); + }) + .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 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(); + } + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/auth/__tests__/pkce.test.ts b/packages/twenty-sdk/src/cli/utilities/auth/__tests__/pkce.test.ts new file mode 100644 index 00000000000..94fb8d6ba07 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/auth/__tests__/pkce.test.ts @@ -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(/[+/=]/); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/auth/callback-server.ts b/packages/twenty-sdk/src/cli/utilities/auth/callback-server.ts new file mode 100644 index 00000000000..d295ff838c6 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/auth/callback-server.ts @@ -0,0 +1,203 @@ +import http from 'node:http'; + +type CallbackResult = + | { success: true; code: string } + | { success: false; error: string }; + +type CallbackServer = { + port: number; + callbackUrl: string; + waitForCallback: () => Promise; + close: () => void; +}; + +const TWENTY_LOGO_SVG = ` + + + +`; + +const pageHtml = ({ + title, + message, + isSuccess, +}: { + title: string; + message: string; + isSuccess: boolean; +}) => ` + + + + + ${title} — Twenty + + + + + + +
+ +
+ ${ + isSuccess + ? '' + : '' + } +
+

${title}

+

${message}

+
+ +`; + +const SUCCESS_HTML = pageHtml({ + title: 'Authentication successful', + message: 'You can close this window and return to the terminal.', + isSuccess: true, +}); + +const escapeHtml = (text: string): string => + text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const errorHtml = (error: string) => + pageHtml({ + title: 'Authentication failed', + message: `${escapeHtml(error)}
Please return to the terminal and try again.`, + isSuccess: false, + }); + +export const startCallbackServer = (options?: { + timeoutMs?: number; +}): Promise => { + const timeoutMs = options?.timeoutMs ?? 120_000; + + return new Promise((resolve, reject) => { + let callbackResolve: (result: CallbackResult) => void; + let timeoutHandle: ReturnType; + + const callbackPromise = new Promise((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); + res.end('Not found'); + + return; + } + + const code = url.searchParams.get('code'); + const error = url.searchParams.get('error'); + + const headers = { + 'Content-Type': 'text/html', + Connection: 'close', + }; + + if (code) { + res.writeHead(200, headers); + res.end(SUCCESS_HTML); + callbackResolve({ success: true, code }); + } else { + const errorMessage = + error ?? url.searchParams.get('error_description') ?? 'Unknown error'; + + res.writeHead(200, headers); + res.end(errorHtml(errorMessage)); + callbackResolve({ success: false, error: errorMessage }); + } + }); + + 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.closeAllConnections(); + server.close(); + }, + }); + }); + + server.on('error', reject); + }); +}; diff --git a/packages/twenty-sdk/src/cli/utilities/auth/open-browser.ts b/packages/twenty-sdk/src/cli/utilities/auth/open-browser.ts new file mode 100644 index 00000000000..c32e3175594 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/auth/open-browser.ts @@ -0,0 +1,22 @@ +import { execFile } from 'node:child_process'; + +export const openBrowser = (url: string): Promise => { + try { + new URL(url); + } catch { + return Promise.resolve(false); + } + + const [command, args]: [string, string[]] = + process.platform === 'darwin' + ? ['open', [url]] + : process.platform === 'win32' + ? ['cmd', ['/c', 'start', '', url]] + : ['xdg-open', [url]]; + + return new Promise((resolve) => { + execFile(command, args, (error) => { + resolve(!error); + }); + }); +}; diff --git a/packages/twenty-sdk/src/cli/utilities/auth/pkce.ts b/packages/twenty-sdk/src/cli/utilities/auth/pkce.ts new file mode 100644 index 00000000000..a254f040b13 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/auth/pkce.ts @@ -0,0 +1,16 @@ +import crypto from 'node:crypto'; + +export type PkceChallenge = { + codeVerifier: string; + codeChallenge: string; +}; + +export const generatePkceChallenge = (): PkceChallenge => { + const codeVerifier = crypto.randomBytes(32).toString('base64url'); + const codeChallenge = crypto + .createHash('sha256') + .update(codeVerifier) + .digest('base64url'); + + return { codeVerifier, codeChallenge }; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/synchronize-built-application.ts b/packages/twenty-sdk/src/cli/utilities/build/common/synchronize-built-application.ts index c2d69845cad..2fa09be7adb 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/synchronize-built-application.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/synchronize-built-application.ts @@ -1,7 +1,4 @@ -import { - APP_ERROR_CODES, - type CommandResult, -} from '@/cli/public-operations/types'; +import { APP_ERROR_CODES, type CommandResult } from '@/cli/types'; import { ApiService } from '@/cli/utilities/api/api-service'; import { type BuiltFileInfo } from '@/cli/utilities/build/common/build-application'; import { manifestUpdateChecksums } from '@/cli/utilities/build/manifest/manifest-update-checksums'; @@ -12,7 +9,7 @@ import { type Manifest } from 'twenty-shared/application'; export type AppSyncOptions = { appPath: string; - workspace?: string; + remote?: string; }; const ensureApplicationRegistrationExists = async ( diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts index 7eb35274229..7c468135b54 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts @@ -368,21 +368,27 @@ export const buildManifest = async ( }; } + const byId = (a: T, b: T) => + a.universalIdentifier.localeCompare(b.universalIdentifier); + + const byPath = (a: T, b: T) => + a.filePath.localeCompare(b.filePath); + const manifest = !application ? null : { application, - objects, - fields, - roles, - skills, - agents, - logicFunctions, - frontComponents, - publicAssets, - views, - navigationMenuItems, - pageLayouts, + objects: objects.sort(byId), + fields: fields.sort(byId), + roles: roles.sort(byId), + skills: skills.sort(byId), + agents: agents.sort(byId), + logicFunctions: logicFunctions.sort(byId), + frontComponents: frontComponents.sort(byId), + publicAssets: publicAssets.sort(byPath), + views: views.sort(byId), + navigationMenuItems: navigationMenuItems.sort(byId), + pageLayouts: pageLayouts.sort(byId), }; const entityFilePaths: EntityFilePaths = { diff --git a/packages/twenty-sdk/src/cli/utilities/config/config-service.ts b/packages/twenty-sdk/src/cli/utilities/config/config-service.ts index e1f61ea0cfa..0b10d80a95b 100644 --- a/packages/twenty-sdk/src/cli/utilities/config/config-service.ts +++ b/packages/twenty-sdk/src/cli/utilities/config/config-service.ts @@ -5,158 +5,233 @@ import { ensureDir, ensureFile } from '@/cli/utilities/file/fs-utils'; import { getConfigPath } from '@/cli/utilities/config/get-config-path'; -export type TwentyConfig = { +export type RemoteConfig = { apiUrl: string; apiKey?: string; - applicationAccessToken?: string; - applicationRefreshToken?: string; + accessToken?: string; + refreshToken?: string; oauthClientId?: string; - oauthClientSecret?: string; }; -type PersistedConfig = TwentyConfig & { - profiles?: Record; - defaultWorkspace?: string; +type PersistedConfig = { + version?: number; + defaultRemote?: string; + remotes?: Record; }; -const DEFAULT_WORKSPACE_NAME = 'default'; +const CONFIG_VERSION = 1; + +const DEFAULT_REMOTE_NAME = 'local'; export class ConfigService { private readonly configPath: string; - private static activeWorkspace = DEFAULT_WORKSPACE_NAME; + private static activeRemote = DEFAULT_REMOTE_NAME; constructor() { this.configPath = getConfigPath(); } - static setActiveWorkspace(name?: string) { - this.activeWorkspace = name ?? DEFAULT_WORKSPACE_NAME; + static setActiveRemote(name?: string) { + this.activeRemote = name ?? DEFAULT_REMOTE_NAME; } - static getActiveWorkspace(): string { - return this.activeWorkspace; + static getActiveRemote(): string { + return this.activeRemote; } - private getActiveWorkspaceName(): string { - return ConfigService.getActiveWorkspace(); + private getActiveRemoteName(): string { + return ConfigService.getActiveRemote(); } private async readRawConfig(): Promise { await ensureFile(this.configPath); const content = await readFile(this.configPath, 'utf8'); - return JSON.parse(content || '{}'); + const raw = JSON.parse(content || '{}'); + + return this.migrateConfigIfNeeded(raw); } - async getConfig(): Promise { - return this.getConfigForWorkspace(this.getActiveWorkspaceName()); + // TODO: Remove after 2026-04-30 — migrates legacy config format + // (profiles, top-level keys, applicationAccessToken/applicationRefreshToken) + // to the current format (remotes, accessToken/refreshToken) + private async migrateConfigIfNeeded( + raw: Record, + ): Promise { + if ((raw as PersistedConfig).version === CONFIG_VERSION) { + return raw as PersistedConfig; + } + + const hasLegacyProfiles = 'profiles' in raw; + const hasTopLevelApiUrl = 'apiUrl' in raw && !('remotes' in raw); + + if (!hasLegacyProfiles && !hasTopLevelApiUrl) { + return raw as PersistedConfig; + } + + const migrated: PersistedConfig = { version: CONFIG_VERSION }; + + const str = (value: unknown): string | undefined => + typeof value === 'string' ? value : undefined; + + const migrateRemoteFields = ( + source: Record, + ): RemoteConfig => ({ + apiUrl: str(source.apiUrl) ?? '', + apiKey: str(source.apiKey), + accessToken: + str(source.accessToken) ?? str(source.applicationAccessToken), + refreshToken: + str(source.refreshToken) ?? str(source.applicationRefreshToken), + oauthClientId: str(source.oauthClientId), + }); + + const profiles = + (raw.profiles as Record> | undefined) ?? + {}; + + migrated.remotes = {}; + + for (const [name, profile] of Object.entries(profiles)) { + const remoteName = name === 'default' ? DEFAULT_REMOTE_NAME : name; + + migrated.remotes[remoteName] = migrateRemoteFields(profile); + } + + // Current-format remotes override legacy profiles — they're newer. + const existingRemotes = + (raw.remotes as Record | undefined) ?? {}; + + for (const [name, remote] of Object.entries(existingRemotes)) { + const remoteName = name === 'default' ? DEFAULT_REMOTE_NAME : name; + + migrated.remotes[remoteName] = remote; + } + + if (hasTopLevelApiUrl && !migrated.remotes[DEFAULT_REMOTE_NAME]) { + migrated.remotes[DEFAULT_REMOTE_NAME] = migrateRemoteFields( + raw as Record, + ); + } + + const legacyDefault = raw.defaultWorkspace as string | undefined; + + if (legacyDefault) { + migrated.defaultRemote = + legacyDefault === 'default' ? DEFAULT_REMOTE_NAME : legacyDefault; + } + + await ensureDir(path.dirname(this.configPath)); + await writeFile(this.configPath, JSON.stringify(migrated, null, 2)); + + return migrated; } - async getConfigForWorkspace(workspaceName: string): Promise { + async getConfig(): Promise { + if (process.env.TWENTY_TOKEN && process.env.TWENTY_API_URL) { + return { + apiUrl: process.env.TWENTY_API_URL, + accessToken: process.env.TWENTY_TOKEN, + }; + } + + return this.getConfigForRemote(this.getActiveRemoteName()); + } + + async getConfigForRemote(remoteName: string): Promise { const defaultConfig = this.getDefaultConfig(); + try { const raw = await this.readRawConfig(); + const remoteConfig = raw.remotes?.[remoteName]; - const profileConfig = - workspaceName === DEFAULT_WORKSPACE_NAME && - !raw.profiles?.[DEFAULT_WORKSPACE_NAME] - ? raw - : raw.profiles?.[workspaceName]; - - // Fallback to legacy top-level values if profile value is missing - const apiUrl = profileConfig?.apiUrl ?? defaultConfig.apiUrl; - const apiKey = profileConfig?.apiKey; - const applicationAccessToken = profileConfig?.applicationAccessToken; - const applicationRefreshToken = profileConfig?.applicationRefreshToken; + if (!remoteConfig) { + return defaultConfig; + } return { - apiUrl, - apiKey, - applicationAccessToken, - applicationRefreshToken, + apiUrl: remoteConfig.apiUrl ?? defaultConfig.apiUrl, + apiKey: remoteConfig.apiKey, + accessToken: remoteConfig.accessToken, + refreshToken: remoteConfig.refreshToken, + oauthClientId: remoteConfig.oauthClientId, }; } catch { return defaultConfig; } } - async setConfig(config: Partial): Promise { + async setConfig(config: Partial): Promise { const raw = await this.readRawConfig(); - const profile = this.getActiveWorkspaceName(); + const remote = this.getActiveRemoteName(); - // Ensure profiles map exists - if (!raw.profiles) { - raw.profiles = {}; + raw.version = CONFIG_VERSION; + + if (!raw.remotes) { + raw.remotes = {}; } - const currentProfile = raw.profiles[profile] || { apiUrl: '' }; + const currentRemote = raw.remotes[remote] || { apiUrl: '' }; - raw.profiles[profile] = { ...currentProfile, ...config }; + raw.remotes[remote] = { ...currentRemote, ...config }; await ensureDir(path.dirname(this.configPath)); await writeFile(this.configPath, JSON.stringify(raw, null, 2)); } async clearConfig(): Promise { - // Clear only the active profile credentials (non-breaking for other profiles) const raw = await this.readRawConfig(); - const profile = this.getActiveWorkspaceName(); + const remote = this.getActiveRemoteName(); - if (!raw.profiles) { - raw.profiles = {}; + if (!raw.remotes) { + raw.remotes = {}; } - if (raw.profiles[profile]) { - delete raw.profiles[profile]; - } - - // Also clear legacy top-level apiKey for compatibility when active profile is default - if (profile === DEFAULT_WORKSPACE_NAME) { - const defaultConfig = this.getDefaultConfig(); - delete raw.apiKey; - raw.apiUrl = defaultConfig.apiUrl; + if (raw.remotes[remote]) { + delete raw.remotes[remote]; } await ensureDir(path.dirname(this.configPath)); await writeFile(this.configPath, JSON.stringify(raw, null, 2)); } - private getDefaultConfig(): TwentyConfig { + private getDefaultConfig(): RemoteConfig { return { apiUrl: 'http://localhost:3000', }; } - async getAvailableWorkspaces(): Promise { + async getRemotes(): Promise { try { const raw = await this.readRawConfig(); - const workspaces = new Set(); + const remotes = new Set(); - // Always include the default workspace - workspaces.add(DEFAULT_WORKSPACE_NAME); + remotes.add(DEFAULT_REMOTE_NAME); - // Add all profiles - if (raw.profiles) { - Object.keys(raw.profiles).forEach((name) => workspaces.add(name)); + if (raw.remotes) { + Object.keys(raw.remotes).forEach((name) => remotes.add(name)); } - return Array.from(workspaces).sort(); + return Array.from(remotes).sort(); } catch { - return [DEFAULT_WORKSPACE_NAME]; + return [DEFAULT_REMOTE_NAME]; } } - async getDefaultWorkspace(): Promise { + async getDefaultRemote(): Promise { try { const raw = await this.readRawConfig(); - return raw.defaultWorkspace ?? DEFAULT_WORKSPACE_NAME; + + return raw.defaultRemote ?? DEFAULT_REMOTE_NAME; } catch { - return DEFAULT_WORKSPACE_NAME; + return DEFAULT_REMOTE_NAME; } } - async setDefaultWorkspace(name: string): Promise { + async setDefaultRemote(name: string): Promise { const raw = await this.readRawConfig(); - raw.defaultWorkspace = name; + + raw.defaultRemote = name; + await ensureDir(path.dirname(this.configPath)); await writeFile(this.configPath, JSON.stringify(raw, null, 2)); } diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator.ts index 2d169a911cc..2f51dad42b2 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator.ts @@ -4,7 +4,6 @@ import { ConfigService } from '@/cli/utilities/config/config-service'; import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state'; import { BuildManifestOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/build-manifest-orchestrator-step'; import { CheckServerOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step'; -import { EnsureValidTokensOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/ensure-valid-tokens-orchestrator-step'; import { GenerateApiClientOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/generate-api-client-orchestrator-step'; import { RegisterAppOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/register-app-orchestrator-step'; import { @@ -33,7 +32,6 @@ export class DevModeOrchestrator { private clientService: ClientService; private skipTypecheck = true; private checkServerStep: CheckServerOrchestratorStep; - private ensureValidTokensStep: EnsureValidTokensOrchestratorStep; private buildManifestStep: BuildManifestOrchestratorStep; private registerAppStep: RegisterAppOrchestratorStep; private uploadFilesStep: UploadFilesOrchestratorStep; @@ -55,11 +53,6 @@ export class DevModeOrchestrator { ...stepDeps, apiService, }); - this.ensureValidTokensStep = new EnsureValidTokensOrchestratorStep({ - ...stepDeps, - apiService, - configService, - }); this.buildManifestStep = new BuildManifestOrchestratorStep(stepDeps); this.registerAppStep = new RegisterAppOrchestratorStep({ ...stepDeps, @@ -167,10 +160,6 @@ export class DevModeOrchestrator { return; } - await this.ensureValidTokensStep.execute({ - applicationId: this.state.steps.resolveApplication.output.applicationId, - }); - const buildResult = await this.buildManifestStep.execute({ appPath: this.state.appPath, }); @@ -244,10 +233,6 @@ export class DevModeOrchestrator { { message: 'Application created', status: 'success' }, ]); - await this.ensureValidTokensStep.exchangeTokens({ - applicationId: createResult.data.id, - }); - this.uploadFilesStep.initialize({ appPath: this.state.appPath, universalIdentifier: manifest.application.universalIdentifier, diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step.ts index 008e6dcf96d..0d4a75a15d4 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step.ts @@ -34,7 +34,17 @@ export class CheckServerOrchestratorStep { step.output = { isReady: false, errorLogged: true }; step.status = 'error'; this.state.applyStepEvents([ - { message: 'Cannot reach server', status: 'error' }, + { + message: + 'Cannot reach Twenty at localhost:3000.\n\n' + + ' Start a local server with Docker:\n' + + ' curl -sL https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-docker/docker-compose.yml -o docker-compose.yml\n' + + ' docker compose up -d\n\n' + + ' Or from the monorepo:\n' + + ' yarn start\n\n' + + ' Waiting for server...', + status: 'error', + }, ]); this.state.updatePipeline({ status: 'error' }); } @@ -47,7 +57,11 @@ export class CheckServerOrchestratorStep { step.output = { isReady: false, errorLogged: true }; step.status = 'error'; this.state.applyStepEvents([ - { message: 'Authentication failed', status: 'error' }, + { + message: + 'Authentication failed. Run `twenty remote add --local` to authenticate.', + status: 'error', + }, ]); this.state.updatePipeline({ status: 'error' }); } diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/ensure-valid-tokens-orchestrator-step.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/ensure-valid-tokens-orchestrator-step.ts deleted file mode 100644 index 141b6f0e48b..00000000000 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/ensure-valid-tokens-orchestrator-step.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { type ApiService } from '@/cli/utilities/api/api-service'; -import { type ConfigService } from '@/cli/utilities/config/config-service'; -import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state'; - -export class EnsureValidTokensOrchestratorStep { - private apiService: ApiService; - private configService: ConfigService; - private state: OrchestratorState; - private notify: () => void; - - constructor({ - apiService, - configService, - state, - notify, - }: { - apiService: ApiService; - configService: ConfigService; - state: OrchestratorState; - notify: () => void; - }) { - this.apiService = apiService; - this.configService = configService; - this.state = state; - this.notify = notify; - } - - async execute(input: { applicationId: string | null }): Promise { - if (!input.applicationId) { - return; - } - - const step = this.state.steps.ensureValidTokens; - - step.status = 'in_progress'; - this.notify(); - - const config = await this.configService.getConfig(); - - if ( - config.applicationAccessToken && - !this.isTokenExpired(config.applicationAccessToken) - ) { - step.status = 'done'; - this.notify(); - - return; - } - - if ( - config.applicationRefreshToken && - !this.isTokenExpired(config.applicationRefreshToken) - ) { - const renewResult = await this.apiService.renewApplicationToken( - config.applicationRefreshToken, - ); - - if (renewResult.success) { - await this.configService.setConfig({ - applicationAccessToken: renewResult.data.applicationAccessToken.token, - applicationRefreshToken: - renewResult.data.applicationRefreshToken.token, - }); - - this.state.applyStepEvents([ - { message: 'Renewing application tokens', status: 'info' }, - { message: 'Application tokens renewed', status: 'success' }, - ]); - step.status = 'done'; - this.notify(); - - return; - } - - this.state.applyStepEvents([ - { message: 'Renewing application tokens', status: 'info' }, - { - message: `Failed to renew application tokens: ${JSON.stringify(renewResult.error, null, 2)}`, - status: 'error', - }, - ]); - - await this.exchangeTokens({ applicationId: input.applicationId }); - - return; - } - - await this.exchangeTokens({ applicationId: input.applicationId }); - } - - async exchangeTokens(input: { applicationId: string }): Promise { - const tokenResult = await this.apiService.generateApplicationToken( - input.applicationId, - ); - - if (!tokenResult.success) { - this.state.applyStepEvents([ - { message: 'Generating application tokens', status: 'info' }, - { - message: `Failed to generate application tokens: ${JSON.stringify(tokenResult.error, null, 2)}`, - status: 'error', - }, - ]); - this.state.steps.ensureValidTokens.status = 'error'; - this.notify(); - - return; - } - - await this.configService.setConfig({ - applicationAccessToken: tokenResult.data.applicationAccessToken.token, - applicationRefreshToken: tokenResult.data.applicationRefreshToken.token, - }); - - this.state.applyStepEvents([ - { message: 'Generating application tokens', status: 'info' }, - { message: 'Application tokens stored in config', status: 'success' }, - ]); - this.state.steps.ensureValidTokens.status = 'done'; - this.notify(); - } - - private isTokenExpired(token: string): boolean { - try { - const payload = JSON.parse( - Buffer.from(token.split('.')[1], 'base64').toString(), - ); - - return Date.now() >= payload.exp * 1000 - 60_000; - } catch { - return true; - } - } -} diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/generate-api-client-orchestrator-step.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/generate-api-client-orchestrator-step.ts index d73fa30da18..23c517c3b1c 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/generate-api-client-orchestrator-step.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/generate-api-client-orchestrator-step.ts @@ -36,7 +36,7 @@ export class GenerateApiClientOrchestratorStep { await this.clientService.generateCoreClient({ appPath: input.appPath, - authToken: config.applicationAccessToken, + authToken: config.accessToken, }); step.status = 'done'; diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/register-app-orchestrator-step.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/register-app-orchestrator-step.ts index 1159e21e2ed..a2403e87d2c 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/register-app-orchestrator-step.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/register-app-orchestrator-step.ts @@ -88,7 +88,6 @@ export class RegisterAppOrchestratorStep { await this.configService.setConfig({ oauthClientId: createResult.data.applicationRegistration.oAuthClientId, - oauthClientSecret: createResult.data.clientSecret, }); this.state.applyStepEvents([ diff --git a/packages/twenty-sdk/src/cli/utilities/run-safe.ts b/packages/twenty-sdk/src/cli/utilities/run-safe.ts index 3d1013ec23f..15edb7edaf7 100644 --- a/packages/twenty-sdk/src/cli/utilities/run-safe.ts +++ b/packages/twenty-sdk/src/cli/utilities/run-safe.ts @@ -1,4 +1,4 @@ -import { type CommandResult } from '@/cli/public-operations/types'; +import { type CommandResult } from '@/cli/types'; export const runSafe = async ( operation: () => Promise>, diff --git a/packages/twenty-sdk/src/clients/generated/core/index.ts b/packages/twenty-sdk/src/clients/generated/core/index.ts index c88e9ecb664..aa9460a41d8 100644 --- a/packages/twenty-sdk/src/clients/generated/core/index.ts +++ b/packages/twenty-sdk/src/clients/generated/core/index.ts @@ -1,2 +1,2 @@ -// Stub — overwritten by `twenty app:build` or `twenty app:dev` +// Stub — overwritten by `twenty build` or `twenty dev` export class CoreApiClient {} diff --git a/packages/twenty-sdk/src/clients/generated/core/schema.ts b/packages/twenty-sdk/src/clients/generated/core/schema.ts index 0192ef8cd3c..905326723cf 100644 --- a/packages/twenty-sdk/src/clients/generated/core/schema.ts +++ b/packages/twenty-sdk/src/clients/generated/core/schema.ts @@ -1,2 +1,2 @@ -// Stub — overwritten by `twenty app:build` or `twenty app:dev` +// Stub — overwritten by `twenty build` or `twenty dev` export type CoreSchema = {}; diff --git a/packages/twenty-sdk/src/clients/generated/metadata/schema.graphql b/packages/twenty-sdk/src/clients/generated/metadata/schema.graphql index fa6b0589824..e286890e44b 100644 --- a/packages/twenty-sdk/src/clients/generated/metadata/schema.graphql +++ b/packages/twenty-sdk/src/clients/generated/metadata/schema.graphql @@ -1750,28 +1750,6 @@ type UpsertRowLevelPermissionPredicatesResult { predicateGroups: [RowLevelPermissionPredicateGroup!]! } -type Relation { - type: RelationType! - sourceObjectMetadata: Object! - targetObjectMetadata: Object! - sourceFieldMetadata: Field! - targetFieldMetadata: Field! -} - -"""Relation type""" -enum RelationType { - ONE_TO_MANY - MANY_TO_ONE -} - -type FieldConnection { - """Paging information""" - pageInfo: PageInfo! - - """Array of edges.""" - edges: [FieldEdge!]! -} - type VersionDistributionEntry { version: String! count: Int! @@ -1800,6 +1778,28 @@ type RotateClientSecret { clientSecret: String! } +type Relation { + type: RelationType! + sourceObjectMetadata: Object! + targetObjectMetadata: Object! + sourceFieldMetadata: Field! + targetFieldMetadata: Field! +} + +"""Relation type""" +enum RelationType { + ONE_TO_MANY + MANY_TO_ONE +} + +type FieldConnection { + """Paging information""" + pageInfo: PageInfo! + + """Array of edges.""" + edges: [FieldEdge!]! +} + type DeleteSso { identityProviderId: UUID! } @@ -3013,10 +3013,6 @@ type Query { currentUser: User! currentWorkspace: Workspace! getPublicWorkspaceDataByDomain(origin: String): PublicWorkspaceData! - checkUserExists(email: String!, captchaToken: String): CheckUserExist! - checkWorkspaceInviteHashIsValid(inviteHash: String!): WorkspaceInviteHashValid! - findWorkspaceFromInviteHash(inviteHash: String!): Workspace! - validatePasswordResetToken(passwordResetToken: String!): ValidatePasswordResetToken! findApplicationRegistrationByClientId(clientId: String!): PublicApplicationRegistration findApplicationRegistrationByUniversalIdentifier(universalIdentifier: String!): ApplicationRegistration findManyApplicationRegistrations: [ApplicationRegistration!]! @@ -3024,6 +3020,10 @@ type Query { findApplicationRegistrationStats(id: String!): ApplicationRegistrationStats! findApplicationRegistrationVariables(applicationRegistrationId: String!): [ApplicationRegistrationVariable!]! applicationRegistrationTarballUrl(id: String!): String + checkUserExists(email: String!, captchaToken: String): CheckUserExist! + checkWorkspaceInviteHashIsValid(inviteHash: String!): WorkspaceInviteHashValid! + findWorkspaceFromInviteHash(inviteHash: String!): Workspace! + validatePasswordResetToken(passwordResetToken: String!): ValidatePasswordResetToken! getSSOIdentityProviders: [FindAvailableSSOIDP!]! webhooks: [Webhook!]! webhook(id: UUID!): Webhook @@ -3289,6 +3289,15 @@ type Mutation { updateWorkspace(data: UpdateWorkspaceInput!): Workspace! deleteCurrentWorkspace: Workspace! checkCustomDomainValidRecords: DomainValidRecords + createApplicationRegistration(input: CreateApplicationRegistrationInput!): CreateApplicationRegistration! + updateApplicationRegistration(input: UpdateApplicationRegistrationInput!): ApplicationRegistration! + deleteApplicationRegistration(id: String!): Boolean! + rotateApplicationRegistrationClientSecret(id: String!): RotateClientSecret! + createApplicationRegistrationVariable(input: CreateApplicationRegistrationVariableInput!): ApplicationRegistrationVariable! + updateApplicationRegistrationVariable(input: UpdateApplicationRegistrationVariableInput!): ApplicationRegistrationVariable! + deleteApplicationRegistrationVariable(id: String!): Boolean! + uploadAppTarball(file: Upload!, universalIdentifier: String): ApplicationRegistration! + transferApplicationRegistrationOwnership(applicationRegistrationId: String!, targetWorkspaceSubdomain: String!): ApplicationRegistration! getAuthorizationUrlForSSO(input: GetAuthorizationUrlForSSOInput!): GetAuthorizationUrlForSSO! getLoginTokenFromCredentials(email: String!, password: String!, captchaToken: String, locale: String, verifyEmailRedirectPath: String, origin: String!): LoginToken! signIn(email: String!, password: String!, captchaToken: String, locale: String, verifyEmailRedirectPath: String): AvailableWorkspacesAndAccessTokens! @@ -3305,15 +3314,6 @@ type Mutation { generateApiKeyToken(apiKeyId: UUID!, expiresAt: String!): ApiKeyToken! emailPasswordResetLink(email: String!, workspaceId: UUID): EmailPasswordResetLink! updatePasswordViaResetToken(passwordResetToken: String!, newPassword: String!): InvalidatePassword! - createApplicationRegistration(input: CreateApplicationRegistrationInput!): CreateApplicationRegistration! - updateApplicationRegistration(input: UpdateApplicationRegistrationInput!): ApplicationRegistration! - deleteApplicationRegistration(id: String!): Boolean! - rotateApplicationRegistrationClientSecret(id: String!): RotateClientSecret! - createApplicationRegistrationVariable(input: CreateApplicationRegistrationVariableInput!): ApplicationRegistrationVariable! - updateApplicationRegistrationVariable(input: UpdateApplicationRegistrationVariableInput!): ApplicationRegistrationVariable! - deleteApplicationRegistrationVariable(id: String!): Boolean! - uploadAppTarball(file: Upload!, universalIdentifier: String): ApplicationRegistration! - transferApplicationRegistrationOwnership(applicationRegistrationId: String!, targetWorkspaceSubdomain: String!): ApplicationRegistration! initiateOTPProvisioning(loginToken: String!, origin: String!): InitiateTwoFactorAuthenticationProvisioning! initiateOTPProvisioningForAuthenticatedUser: InitiateTwoFactorAuthenticationProvisioning! deleteTwoFactorAuthenticationMethod(twoFactorAuthenticationMethodId: UUID!): DeleteTwoFactorAuthenticationMethod! @@ -4135,11 +4135,6 @@ input UpdateWorkspaceInput { useRecommendedModels: Boolean } -input GetAuthorizationUrlForSSOInput { - identityProviderId: UUID! - workspaceInviteHash: String -} - input CreateApplicationRegistrationInput { name: String! description: String @@ -4187,6 +4182,11 @@ input UpdateApplicationRegistrationVariablePayload { description: String } +input GetAuthorizationUrlForSSOInput { + identityProviderId: UUID! + workspaceInviteHash: String +} + input SetupOIDCSsoInput { name: String! issuer: String! diff --git a/packages/twenty-sdk/src/clients/generated/metadata/schema.ts b/packages/twenty-sdk/src/clients/generated/metadata/schema.ts index 5b5c3aea9d3..9a44b8e3a9c 100644 --- a/packages/twenty-sdk/src/clients/generated/metadata/schema.ts +++ b/packages/twenty-sdk/src/clients/generated/metadata/schema.ts @@ -1456,27 +1456,6 @@ export interface UpsertRowLevelPermissionPredicatesResult { __typename: 'UpsertRowLevelPermissionPredicatesResult' } -export interface Relation { - type: RelationType - sourceObjectMetadata: Object - targetObjectMetadata: Object - sourceFieldMetadata: Field - targetFieldMetadata: Field - __typename: 'Relation' -} - - -/** Relation type */ -export type RelationType = 'ONE_TO_MANY' | 'MANY_TO_ONE' - -export interface FieldConnection { - /** Paging information */ - pageInfo: PageInfo - /** Array of edges. */ - edges: FieldEdge[] - __typename: 'FieldConnection' -} - export interface VersionDistributionEntry { version: Scalars['String'] count: Scalars['Int'] @@ -1510,6 +1489,27 @@ export interface RotateClientSecret { __typename: 'RotateClientSecret' } +export interface Relation { + type: RelationType + sourceObjectMetadata: Object + targetObjectMetadata: Object + sourceFieldMetadata: Field + targetFieldMetadata: Field + __typename: 'Relation' +} + + +/** Relation type */ +export type RelationType = 'ONE_TO_MANY' | 'MANY_TO_ONE' + +export interface FieldConnection { + /** Paging information */ + pageInfo: PageInfo + /** Array of edges. */ + edges: FieldEdge[] + __typename: 'FieldConnection' +} + export interface DeleteSso { identityProviderId: Scalars['UUID'] __typename: 'DeleteSso' @@ -2625,10 +2625,6 @@ export interface Query { currentUser: User currentWorkspace: Workspace getPublicWorkspaceDataByDomain: PublicWorkspaceData - checkUserExists: CheckUserExist - checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid - findWorkspaceFromInviteHash: Workspace - validatePasswordResetToken: ValidatePasswordResetToken findApplicationRegistrationByClientId?: PublicApplicationRegistration findApplicationRegistrationByUniversalIdentifier?: ApplicationRegistration findManyApplicationRegistrations: ApplicationRegistration[] @@ -2636,6 +2632,10 @@ export interface Query { findApplicationRegistrationStats: ApplicationRegistrationStats findApplicationRegistrationVariables: ApplicationRegistrationVariable[] applicationRegistrationTarballUrl?: Scalars['String'] + checkUserExists: CheckUserExist + checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid + findWorkspaceFromInviteHash: Workspace + validatePasswordResetToken: ValidatePasswordResetToken getSSOIdentityProviders: FindAvailableSSOIDP[] webhooks: Webhook[] webhook?: Webhook @@ -2800,6 +2800,15 @@ export interface Mutation { updateWorkspace: Workspace deleteCurrentWorkspace: Workspace checkCustomDomainValidRecords?: DomainValidRecords + createApplicationRegistration: CreateApplicationRegistration + updateApplicationRegistration: ApplicationRegistration + deleteApplicationRegistration: Scalars['Boolean'] + rotateApplicationRegistrationClientSecret: RotateClientSecret + createApplicationRegistrationVariable: ApplicationRegistrationVariable + updateApplicationRegistrationVariable: ApplicationRegistrationVariable + deleteApplicationRegistrationVariable: Scalars['Boolean'] + uploadAppTarball: ApplicationRegistration + transferApplicationRegistrationOwnership: ApplicationRegistration getAuthorizationUrlForSSO: GetAuthorizationUrlForSSO getLoginTokenFromCredentials: LoginToken signIn: AvailableWorkspacesAndAccessTokens @@ -2816,15 +2825,6 @@ export interface Mutation { generateApiKeyToken: ApiKeyToken emailPasswordResetLink: EmailPasswordResetLink updatePasswordViaResetToken: InvalidatePassword - createApplicationRegistration: CreateApplicationRegistration - updateApplicationRegistration: ApplicationRegistration - deleteApplicationRegistration: Scalars['Boolean'] - rotateApplicationRegistrationClientSecret: RotateClientSecret - createApplicationRegistrationVariable: ApplicationRegistrationVariable - updateApplicationRegistrationVariable: ApplicationRegistrationVariable - deleteApplicationRegistrationVariable: Scalars['Boolean'] - uploadAppTarball: ApplicationRegistration - transferApplicationRegistrationOwnership: ApplicationRegistration initiateOTPProvisioning: InitiateTwoFactorAuthenticationProvisioning initiateOTPProvisioningForAuthenticatedUser: InitiateTwoFactorAuthenticationProvisioning deleteTwoFactorAuthenticationMethod: DeleteTwoFactorAuthenticationMethod @@ -4424,25 +4424,6 @@ export interface UpsertRowLevelPermissionPredicatesResultGenqlSelection{ __scalar?: boolean | number } -export interface RelationGenqlSelection{ - type?: boolean | number - sourceObjectMetadata?: ObjectGenqlSelection - targetObjectMetadata?: ObjectGenqlSelection - sourceFieldMetadata?: FieldGenqlSelection - targetFieldMetadata?: FieldGenqlSelection - __typename?: boolean | number - __scalar?: boolean | number -} - -export interface FieldConnectionGenqlSelection{ - /** Paging information */ - pageInfo?: PageInfoGenqlSelection - /** Array of edges. */ - edges?: FieldEdgeGenqlSelection - __typename?: boolean | number - __scalar?: boolean | number -} - export interface VersionDistributionEntryGenqlSelection{ version?: boolean | number count?: boolean | number @@ -4481,6 +4462,25 @@ export interface RotateClientSecretGenqlSelection{ __scalar?: boolean | number } +export interface RelationGenqlSelection{ + type?: boolean | number + sourceObjectMetadata?: ObjectGenqlSelection + targetObjectMetadata?: ObjectGenqlSelection + sourceFieldMetadata?: FieldGenqlSelection + targetFieldMetadata?: FieldGenqlSelection + __typename?: boolean | number + __scalar?: boolean | number +} + +export interface FieldConnectionGenqlSelection{ + /** Paging information */ + pageInfo?: PageInfoGenqlSelection + /** Array of edges. */ + edges?: FieldEdgeGenqlSelection + __typename?: boolean | number + __scalar?: boolean | number +} + export interface DeleteSsoGenqlSelection{ identityProviderId?: boolean | number __typename?: boolean | number @@ -5691,10 +5691,6 @@ export interface QueryGenqlSelection{ currentUser?: UserGenqlSelection currentWorkspace?: WorkspaceGenqlSelection getPublicWorkspaceDataByDomain?: (PublicWorkspaceDataGenqlSelection & { __args?: {origin?: (Scalars['String'] | null)} }) - checkUserExists?: (CheckUserExistGenqlSelection & { __args: {email: Scalars['String'], captchaToken?: (Scalars['String'] | null)} }) - checkWorkspaceInviteHashIsValid?: (WorkspaceInviteHashValidGenqlSelection & { __args: {inviteHash: Scalars['String']} }) - findWorkspaceFromInviteHash?: (WorkspaceGenqlSelection & { __args: {inviteHash: Scalars['String']} }) - validatePasswordResetToken?: (ValidatePasswordResetTokenGenqlSelection & { __args: {passwordResetToken: Scalars['String']} }) findApplicationRegistrationByClientId?: (PublicApplicationRegistrationGenqlSelection & { __args: {clientId: Scalars['String']} }) findApplicationRegistrationByUniversalIdentifier?: (ApplicationRegistrationGenqlSelection & { __args: {universalIdentifier: Scalars['String']} }) findManyApplicationRegistrations?: ApplicationRegistrationGenqlSelection @@ -5702,6 +5698,10 @@ export interface QueryGenqlSelection{ findApplicationRegistrationStats?: (ApplicationRegistrationStatsGenqlSelection & { __args: {id: Scalars['String']} }) findApplicationRegistrationVariables?: (ApplicationRegistrationVariableGenqlSelection & { __args: {applicationRegistrationId: Scalars['String']} }) applicationRegistrationTarballUrl?: { __args: {id: Scalars['String']} } + checkUserExists?: (CheckUserExistGenqlSelection & { __args: {email: Scalars['String'], captchaToken?: (Scalars['String'] | null)} }) + checkWorkspaceInviteHashIsValid?: (WorkspaceInviteHashValidGenqlSelection & { __args: {inviteHash: Scalars['String']} }) + findWorkspaceFromInviteHash?: (WorkspaceGenqlSelection & { __args: {inviteHash: Scalars['String']} }) + validatePasswordResetToken?: (ValidatePasswordResetTokenGenqlSelection & { __args: {passwordResetToken: Scalars['String']} }) getSSOIdentityProviders?: FindAvailableSSOIDPGenqlSelection webhooks?: WebhookGenqlSelection webhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} }) @@ -5891,6 +5891,15 @@ export interface MutationGenqlSelection{ updateWorkspace?: (WorkspaceGenqlSelection & { __args: {data: UpdateWorkspaceInput} }) deleteCurrentWorkspace?: WorkspaceGenqlSelection checkCustomDomainValidRecords?: DomainValidRecordsGenqlSelection + createApplicationRegistration?: (CreateApplicationRegistrationGenqlSelection & { __args: {input: CreateApplicationRegistrationInput} }) + updateApplicationRegistration?: (ApplicationRegistrationGenqlSelection & { __args: {input: UpdateApplicationRegistrationInput} }) + deleteApplicationRegistration?: { __args: {id: Scalars['String']} } + rotateApplicationRegistrationClientSecret?: (RotateClientSecretGenqlSelection & { __args: {id: Scalars['String']} }) + createApplicationRegistrationVariable?: (ApplicationRegistrationVariableGenqlSelection & { __args: {input: CreateApplicationRegistrationVariableInput} }) + updateApplicationRegistrationVariable?: (ApplicationRegistrationVariableGenqlSelection & { __args: {input: UpdateApplicationRegistrationVariableInput} }) + deleteApplicationRegistrationVariable?: { __args: {id: Scalars['String']} } + uploadAppTarball?: (ApplicationRegistrationGenqlSelection & { __args: {file: Scalars['Upload'], universalIdentifier?: (Scalars['String'] | null)} }) + transferApplicationRegistrationOwnership?: (ApplicationRegistrationGenqlSelection & { __args: {applicationRegistrationId: Scalars['String'], targetWorkspaceSubdomain: Scalars['String']} }) getAuthorizationUrlForSSO?: (GetAuthorizationUrlForSSOGenqlSelection & { __args: {input: GetAuthorizationUrlForSSOInput} }) getLoginTokenFromCredentials?: (LoginTokenGenqlSelection & { __args: {email: Scalars['String'], password: Scalars['String'], captchaToken?: (Scalars['String'] | null), locale?: (Scalars['String'] | null), verifyEmailRedirectPath?: (Scalars['String'] | null), origin: Scalars['String']} }) signIn?: (AvailableWorkspacesAndAccessTokensGenqlSelection & { __args: {email: Scalars['String'], password: Scalars['String'], captchaToken?: (Scalars['String'] | null), locale?: (Scalars['String'] | null), verifyEmailRedirectPath?: (Scalars['String'] | null)} }) @@ -5907,15 +5916,6 @@ export interface MutationGenqlSelection{ generateApiKeyToken?: (ApiKeyTokenGenqlSelection & { __args: {apiKeyId: Scalars['UUID'], expiresAt: Scalars['String']} }) emailPasswordResetLink?: (EmailPasswordResetLinkGenqlSelection & { __args: {email: Scalars['String'], workspaceId?: (Scalars['UUID'] | null)} }) updatePasswordViaResetToken?: (InvalidatePasswordGenqlSelection & { __args: {passwordResetToken: Scalars['String'], newPassword: Scalars['String']} }) - createApplicationRegistration?: (CreateApplicationRegistrationGenqlSelection & { __args: {input: CreateApplicationRegistrationInput} }) - updateApplicationRegistration?: (ApplicationRegistrationGenqlSelection & { __args: {input: UpdateApplicationRegistrationInput} }) - deleteApplicationRegistration?: { __args: {id: Scalars['String']} } - rotateApplicationRegistrationClientSecret?: (RotateClientSecretGenqlSelection & { __args: {id: Scalars['String']} }) - createApplicationRegistrationVariable?: (ApplicationRegistrationVariableGenqlSelection & { __args: {input: CreateApplicationRegistrationVariableInput} }) - updateApplicationRegistrationVariable?: (ApplicationRegistrationVariableGenqlSelection & { __args: {input: UpdateApplicationRegistrationVariableInput} }) - deleteApplicationRegistrationVariable?: { __args: {id: Scalars['String']} } - uploadAppTarball?: (ApplicationRegistrationGenqlSelection & { __args: {file: Scalars['Upload'], universalIdentifier?: (Scalars['String'] | null)} }) - transferApplicationRegistrationOwnership?: (ApplicationRegistrationGenqlSelection & { __args: {applicationRegistrationId: Scalars['String'], targetWorkspaceSubdomain: Scalars['String']} }) initiateOTPProvisioning?: (InitiateTwoFactorAuthenticationProvisioningGenqlSelection & { __args: {loginToken: Scalars['String'], origin: Scalars['String']} }) initiateOTPProvisioningForAuthenticatedUser?: InitiateTwoFactorAuthenticationProvisioningGenqlSelection deleteTwoFactorAuthenticationMethod?: (DeleteTwoFactorAuthenticationMethodGenqlSelection & { __args: {twoFactorAuthenticationMethodId: Scalars['UUID']} }) @@ -6226,8 +6226,6 @@ export interface ActivateWorkspaceInput {displayName?: (Scalars['String'] | null export interface UpdateWorkspaceInput {subdomain?: (Scalars['String'] | null),customDomain?: (Scalars['String'] | null),displayName?: (Scalars['String'] | null),logo?: (Scalars['String'] | null),inviteHash?: (Scalars['String'] | null),isPublicInviteLinkEnabled?: (Scalars['Boolean'] | null),allowImpersonation?: (Scalars['Boolean'] | null),isGoogleAuthEnabled?: (Scalars['Boolean'] | null),isMicrosoftAuthEnabled?: (Scalars['Boolean'] | null),isPasswordAuthEnabled?: (Scalars['Boolean'] | null),isGoogleAuthBypassEnabled?: (Scalars['Boolean'] | null),isMicrosoftAuthBypassEnabled?: (Scalars['Boolean'] | null),isPasswordAuthBypassEnabled?: (Scalars['Boolean'] | null),defaultRoleId?: (Scalars['UUID'] | null),isTwoFactorAuthenticationEnforced?: (Scalars['Boolean'] | null),trashRetentionDays?: (Scalars['Float'] | null),eventLogRetentionDays?: (Scalars['Float'] | null),fastModel?: (Scalars['String'] | null),smartModel?: (Scalars['String'] | null),aiAdditionalInstructions?: (Scalars['String'] | null),editableProfileFields?: (Scalars['String'][] | null),autoEnableNewAiModels?: (Scalars['Boolean'] | null),disabledAiModelIds?: (Scalars['String'][] | null),enabledAiModelIds?: (Scalars['String'][] | null),useRecommendedModels?: (Scalars['Boolean'] | null)} -export interface GetAuthorizationUrlForSSOInput {identityProviderId: Scalars['UUID'],workspaceInviteHash?: (Scalars['String'] | null)} - export interface CreateApplicationRegistrationInput {name: Scalars['String'],description?: (Scalars['String'] | null),logoUrl?: (Scalars['String'] | null),author?: (Scalars['String'] | null),universalIdentifier?: (Scalars['String'] | null),oAuthRedirectUris?: (Scalars['String'][] | null),oAuthScopes?: (Scalars['String'][] | null),websiteUrl?: (Scalars['String'] | null),termsUrl?: (Scalars['String'] | null)} export interface UpdateApplicationRegistrationInput {id: Scalars['String'],update: UpdateApplicationRegistrationPayload} @@ -6240,6 +6238,8 @@ export interface UpdateApplicationRegistrationVariableInput {id: Scalars['String export interface UpdateApplicationRegistrationVariablePayload {value?: (Scalars['String'] | null),description?: (Scalars['String'] | null)} +export interface GetAuthorizationUrlForSSOInput {identityProviderId: Scalars['UUID'],workspaceInviteHash?: (Scalars['String'] | null)} + export interface SetupOIDCSsoInput {name: Scalars['String'],issuer: Scalars['String'],clientID: Scalars['String'],clientSecret: Scalars['String']} export interface SetupSAMLSsoInput {name: Scalars['String'],issuer: Scalars['String'],id: Scalars['UUID'],ssoURL: Scalars['String'],certificate: Scalars['String'],fingerprint?: (Scalars['String'] | null)} @@ -7299,22 +7299,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null - const Relation_possibleTypes: string[] = ['Relation'] - export const isRelation = (obj?: { __typename?: any } | null): obj is Relation => { - if (!obj?.__typename) throw new Error('__typename is missing in "isRelation"') - return Relation_possibleTypes.includes(obj.__typename) - } - - - - const FieldConnection_possibleTypes: string[] = ['FieldConnection'] - export const isFieldConnection = (obj?: { __typename?: any } | null): obj is FieldConnection => { - if (!obj?.__typename) throw new Error('__typename is missing in "isFieldConnection"') - return FieldConnection_possibleTypes.includes(obj.__typename) - } - - - const VersionDistributionEntry_possibleTypes: string[] = ['VersionDistributionEntry'] export const isVersionDistributionEntry = (obj?: { __typename?: any } | null): obj is VersionDistributionEntry => { if (!obj?.__typename) throw new Error('__typename is missing in "isVersionDistributionEntry"') @@ -7355,6 +7339,22 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null + const Relation_possibleTypes: string[] = ['Relation'] + export const isRelation = (obj?: { __typename?: any } | null): obj is Relation => { + if (!obj?.__typename) throw new Error('__typename is missing in "isRelation"') + return Relation_possibleTypes.includes(obj.__typename) + } + + + + const FieldConnection_possibleTypes: string[] = ['FieldConnection'] + export const isFieldConnection = (obj?: { __typename?: any } | null): obj is FieldConnection => { + if (!obj?.__typename) throw new Error('__typename is missing in "isFieldConnection"') + return FieldConnection_possibleTypes.includes(obj.__typename) + } + + + const DeleteSso_possibleTypes: string[] = ['DeleteSso'] export const isDeleteSso = (obj?: { __typename?: any } | null): obj is DeleteSso => { if (!obj?.__typename) throw new Error('__typename is missing in "isDeleteSso"') diff --git a/packages/twenty-sdk/src/clients/generated/metadata/types.ts b/packages/twenty-sdk/src/clients/generated/metadata/types.ts index 4048e30a88b..cae8c3419dc 100644 --- a/packages/twenty-sdk/src/clients/generated/metadata/types.ts +++ b/packages/twenty-sdk/src/clients/generated/metadata/types.ts @@ -49,7 +49,7 @@ export default { 155, 160, 164, - 183, + 188, 217, 219, 227, @@ -780,10 +780,10 @@ export default { 3 ], "relation": [ - 182 + 187 ], "morphRelations": [ - 182 + 187 ], "object": [ 46 @@ -3464,38 +3464,6 @@ export default { 1 ] }, - "Relation": { - "type": [ - 183 - ], - "sourceObjectMetadata": [ - 46 - ], - "targetObjectMetadata": [ - 46 - ], - "sourceFieldMetadata": [ - 34 - ], - "targetFieldMetadata": [ - 34 - ], - "__typename": [ - 1 - ] - }, - "RelationType": {}, - "FieldConnection": { - "pageInfo": [ - 170 - ], - "edges": [ - 179 - ], - "__typename": [ - 1 - ] - }, "VersionDistributionEntry": { "version": [ 1 @@ -3515,7 +3483,7 @@ export default { 1 ], "versionDistribution": [ - 185 + 182 ], "__typename": [ 1 @@ -3560,6 +3528,38 @@ export default { 1 ] }, + "Relation": { + "type": [ + 188 + ], + "sourceObjectMetadata": [ + 46 + ], + "targetObjectMetadata": [ + 46 + ], + "sourceFieldMetadata": [ + 34 + ], + "targetFieldMetadata": [ + 34 + ], + "__typename": [ + 1 + ] + }, + "RelationType": {}, + "FieldConnection": { + "pageInfo": [ + 170 + ], + "edges": [ + 179 + ], + "__typename": [ + 1 + ] + }, "DeleteSso": { "identityProviderId": [ 3 @@ -6109,7 +6109,7 @@ export default { } ], "fields": [ - 184, + 189, { "paging": [ 39, @@ -6186,6 +6186,63 @@ export default { ] } ], + "findApplicationRegistrationByClientId": [ + 185, + { + "clientId": [ + 1, + "String!" + ] + } + ], + "findApplicationRegistrationByUniversalIdentifier": [ + 7, + { + "universalIdentifier": [ + 1, + "String!" + ] + } + ], + "findManyApplicationRegistrations": [ + 7 + ], + "findOneApplicationRegistration": [ + 7, + { + "id": [ + 1, + "String!" + ] + } + ], + "findApplicationRegistrationStats": [ + 183, + { + "id": [ + 1, + "String!" + ] + } + ], + "findApplicationRegistrationVariables": [ + 5, + { + "applicationRegistrationId": [ + 1, + "String!" + ] + } + ], + "applicationRegistrationTarballUrl": [ + 1, + { + "id": [ + 1, + "String!" + ] + } + ], "checkUserExists": [ 213, { @@ -6225,63 +6282,6 @@ export default { ] } ], - "findApplicationRegistrationByClientId": [ - 188, - { - "clientId": [ - 1, - "String!" - ] - } - ], - "findApplicationRegistrationByUniversalIdentifier": [ - 7, - { - "universalIdentifier": [ - 1, - "String!" - ] - } - ], - "findManyApplicationRegistrations": [ - 7 - ], - "findOneApplicationRegistration": [ - 7, - { - "id": [ - 1, - "String!" - ] - } - ], - "findApplicationRegistrationStats": [ - 186, - { - "id": [ - 1, - "String!" - ] - } - ], - "findApplicationRegistrationVariables": [ - 5, - { - "applicationRegistrationId": [ - 1, - "String!" - ] - } - ], - "applicationRegistrationTarballUrl": [ - 1, - { - "id": [ - 1, - "String!" - ] - } - ], "getSSOIdentityProviders": [ 193 ], @@ -7772,11 +7772,99 @@ export default { "checkCustomDomainValidRecords": [ 162 ], + "createApplicationRegistration": [ + 184, + { + "input": [ + 434, + "CreateApplicationRegistrationInput!" + ] + } + ], + "updateApplicationRegistration": [ + 7, + { + "input": [ + 435, + "UpdateApplicationRegistrationInput!" + ] + } + ], + "deleteApplicationRegistration": [ + 6, + { + "id": [ + 1, + "String!" + ] + } + ], + "rotateApplicationRegistrationClientSecret": [ + 186, + { + "id": [ + 1, + "String!" + ] + } + ], + "createApplicationRegistrationVariable": [ + 5, + { + "input": [ + 437, + "CreateApplicationRegistrationVariableInput!" + ] + } + ], + "updateApplicationRegistrationVariable": [ + 5, + { + "input": [ + 438, + "UpdateApplicationRegistrationVariableInput!" + ] + } + ], + "deleteApplicationRegistrationVariable": [ + 6, + { + "id": [ + 1, + "String!" + ] + } + ], + "uploadAppTarball": [ + 7, + { + "file": [ + 394, + "Upload!" + ], + "universalIdentifier": [ + 1 + ] + } + ], + "transferApplicationRegistrationOwnership": [ + 7, + { + "applicationRegistrationId": [ + 1, + "String!" + ], + "targetWorkspaceSubdomain": [ + 1, + "String!" + ] + } + ], "getAuthorizationUrlForSSO": [ 203, { "input": [ - 434, + 440, "GetAuthorizationUrlForSSOInput!" ] } @@ -8026,94 +8114,6 @@ export default { ] } ], - "createApplicationRegistration": [ - 187, - { - "input": [ - 435, - "CreateApplicationRegistrationInput!" - ] - } - ], - "updateApplicationRegistration": [ - 7, - { - "input": [ - 436, - "UpdateApplicationRegistrationInput!" - ] - } - ], - "deleteApplicationRegistration": [ - 6, - { - "id": [ - 1, - "String!" - ] - } - ], - "rotateApplicationRegistrationClientSecret": [ - 189, - { - "id": [ - 1, - "String!" - ] - } - ], - "createApplicationRegistrationVariable": [ - 5, - { - "input": [ - 438, - "CreateApplicationRegistrationVariableInput!" - ] - } - ], - "updateApplicationRegistrationVariable": [ - 5, - { - "input": [ - 439, - "UpdateApplicationRegistrationVariableInput!" - ] - } - ], - "deleteApplicationRegistrationVariable": [ - 6, - { - "id": [ - 1, - "String!" - ] - } - ], - "uploadAppTarball": [ - 7, - { - "file": [ - 394, - "Upload!" - ], - "universalIdentifier": [ - 1 - ] - } - ], - "transferApplicationRegistrationOwnership": [ - 7, - { - "applicationRegistrationId": [ - 1, - "String!" - ], - "targetWorkspaceSubdomain": [ - 1, - "String!" - ] - } - ], "initiateOTPProvisioning": [ 196, { @@ -10408,17 +10408,6 @@ export default { 1 ] }, - "GetAuthorizationUrlForSSOInput": { - "identityProviderId": [ - 3 - ], - "workspaceInviteHash": [ - 1 - ], - "__typename": [ - 1 - ] - }, "CreateApplicationRegistrationInput": { "name": [ 1 @@ -10456,7 +10445,7 @@ export default { 1 ], "update": [ - 437 + 436 ], "__typename": [ 1 @@ -10519,7 +10508,7 @@ export default { 1 ], "update": [ - 440 + 439 ], "__typename": [ 1 @@ -10536,6 +10525,17 @@ export default { 1 ] }, + "GetAuthorizationUrlForSSOInput": { + "identityProviderId": [ + 3 + ], + "workspaceInviteHash": [ + 1 + ], + "__typename": [ + 1 + ] + }, "SetupOIDCSsoInput": { "name": [ 1 diff --git a/packages/twenty-sdk/vite.config.node.ts b/packages/twenty-sdk/vite.config.node.ts index dc8b18fb256..e5455c7c4f1 100644 --- a/packages/twenty-sdk/vite.config.node.ts +++ b/packages/twenty-sdk/vite.config.node.ts @@ -26,7 +26,7 @@ export default defineConfig(() => { entry: { index: 'src/sdk/index.ts', cli: 'src/cli/cli.ts', - operations: 'src/cli/public-operations/index.ts', + operations: 'src/cli/operations/index.ts', build: 'src/build/index.ts', clients: 'src/clients/index.ts', }, diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-seed-cli-application-registration.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-seed-cli-application-registration.command.ts new file mode 100644 index 00000000000..5fa095a95a6 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-seed-cli-application-registration.command.ts @@ -0,0 +1,61 @@ +import { InjectRepository } from '@nestjs/typeorm'; + +import { Command } from 'nest-commander'; +import { Repository } from 'typeorm'; + +import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner'; +import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; +import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; +import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner'; + +@Command({ + name: 'upgrade:1-20:seed-cli-application-registration', + description: + 'Seed the Twenty CLI application registration for OAuth-based CLI login', +}) +export class SeedCliApplicationRegistrationCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner { + private hasRun = false; + + constructor( + @InjectRepository(WorkspaceEntity) + protected readonly workspaceRepository: Repository, + protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager, + protected readonly dataSourceService: DataSourceService, + private readonly applicationRegistrationService: ApplicationRegistrationService, + ) { + super(workspaceRepository, twentyORMGlobalManager, dataSourceService); + } + + override async runOnWorkspace({ + workspaceId: _, + options, + }: RunOnWorkspaceArgs): Promise { + const dryRun = options.dryRun ?? false; + + if (this.hasRun) { + return; + } + + if (dryRun) { + this.logger.log( + '[DRY RUN] Skipping CLI application registration seeding', + ); + return; + } + + const result = + await this.applicationRegistrationService.createCliRegistrationIfNotExists(); + + this.hasRun = true; + + if (result) { + this.logger.log( + `CLI application registration created (clientId: ${result.oAuthClientId})`, + ); + } else { + this.logger.log('CLI application registration already exists, skipping'); + } + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-upgrade-version-command.module.ts index 0d6d9438062..6a2daea471b 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-upgrade-version-command.module.ts @@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { BackfillCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-command-menu-items.command'; import { BackfillPageLayoutsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-page-layouts.command'; +import { SeedCliApplicationRegistrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-seed-cli-application-registration.command'; +import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module'; import { MigrateRichTextToTextCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-migrate-rich-text-to-text.command'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; @@ -23,17 +25,20 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace WorkspaceMetadataVersionModule, WorkspaceMigrationRunnerModule, ApplicationModule, + ApplicationRegistrationModule, WorkspaceMigrationModule, FeatureFlagModule, ], providers: [ BackfillCommandMenuItemsCommand, BackfillPageLayoutsCommand, + SeedCliApplicationRegistrationCommand, MigrateRichTextToTextCommand, ], exports: [ BackfillCommandMenuItemsCommand, BackfillPageLayoutsCommand, + SeedCliApplicationRegistrationCommand, MigrateRichTextToTextCommand, ], }) diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts index 2aa9306ab85..2d0a47ca3bc 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts @@ -35,6 +35,7 @@ import { BackfillMissingStandardViewsCommand } from 'src/database/commands/upgra import { SeedServerIdCommand } from 'src/database/commands/upgrade-version-command/1-19/1-19-seed-server-id.command'; import { BackfillCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-command-menu-items.command'; import { BackfillPageLayoutsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-page-layouts.command'; +import { SeedCliApplicationRegistrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-seed-cli-application-registration.command'; import { MigrateRichTextToTextCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-migrate-rich-text-to-text.command'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -88,6 +89,7 @@ export class UpgradeCommand extends UpgradeCommandRunner { // 1.20 Commands protected readonly backfillCommandMenuItemsCommand: BackfillCommandMenuItemsCommand, protected readonly backfillPageLayoutsCommand: BackfillPageLayoutsCommand, + protected readonly seedCliApplicationRegistrationCommand: SeedCliApplicationRegistrationCommand, protected readonly migrateRichTextToTextCommand: MigrateRichTextToTextCommand, ) { super( @@ -138,6 +140,7 @@ export class UpgradeCommand extends UpgradeCommandRunner { this.migrateRichTextToTextCommand, this.backfillCommandMenuItemsCommand, this.backfillPageLayoutsCommand, + this.seedCliApplicationRegistrationCommand, ]; this.allCommands = { diff --git a/packages/twenty-server/src/engine/core-modules/application/application-development/application-development.resolver.ts b/packages/twenty-server/src/engine/core-modules/application/application-development/application-development.resolver.ts index b58444f1684..36871a5bc38 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-development/application-development.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-development/application-development.resolver.ts @@ -35,6 +35,8 @@ import { FileStorageService } from 'src/engine/core-modules/file-storage/file-st import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; +import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { DevelopmentGuard } from 'src/engine/guards/development.guard'; import { @@ -109,10 +111,14 @@ export class ApplicationDevelopmentResolver { async generateApplicationToken( @Args() { applicationId }: GenerateApplicationTokenInput, @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + @AuthUser({ allowUndefined: true }) user?: { id: string }, + @AuthUserWorkspaceId() userWorkspaceId?: string, ): Promise { return this.applicationTokenService.generateApplicationTokenPair({ workspaceId, applicationId, + userId: user?.id, + userWorkspaceId, }); } diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth/application-oauth.module.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth/application-oauth.module.ts index e7fbc9460cc..6ef469b40a7 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-oauth/application-oauth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth/application-oauth.module.ts @@ -13,13 +13,13 @@ import { OAuthTokenController } from 'src/engine/core-modules/application/applic import { OAuthService } from 'src/engine/core-modules/application/application-oauth/oauth.service'; import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module'; import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; -import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-server-config/domain-server-config.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module'; import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; +import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-server-config/domain-server-config.module'; @Module({ imports: [ @@ -39,6 +39,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/ ThrottlerModule, TwentyConfigModule, WorkspaceCacheStorageModule, + DomainServerConfigModule, ], controllers: [ OAuthTokenController, diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth/controllers/oauth-discovery.controller.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth/controllers/oauth-discovery.controller.ts index b3e5592491a..e39a8aa434f 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-oauth/controllers/oauth-discovery.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth/controllers/oauth-discovery.controller.ts @@ -1,24 +1,33 @@ import { Controller, Get, UseGuards } from '@nestjs/common'; import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application/application-oauth/constants/oauth-scopes'; -import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service'; +import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; +import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service'; +import { TWENTY_CLI_APPLICATION_REGISTRATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-cli-application-registration.constant'; @Controller('.well-known') export class OAuthDiscoveryController { constructor( private readonly twentyConfigService: TwentyConfigService, private readonly domainServerConfigService: DomainServerConfigService, + private readonly applicationRegistrationService: ApplicationRegistrationService, ) {} @Get('oauth-authorization-server') @UseGuards(PublicEndpointGuard, NoPermissionGuard) - getAuthorizationServerMetadata() { + async getAuthorizationServerMetadata() { const serverUrl = this.twentyConfigService.get('SERVER_URL'); + const frontUrl = this.domainServerConfigService.getFrontUrl().toString(); + const cliRegistration = + await this.applicationRegistrationService.findOneByUniversalIdentifier( + TWENTY_CLI_APPLICATION_REGISTRATION.universalIdentifier, + ); + return { issuer: serverUrl, authorization_endpoint: `${frontUrl.replace(/\/$/, '')}/authorize`, @@ -37,6 +46,9 @@ export class OAuthDiscoveryController { token_endpoint_auth_methods_supported: ['client_secret_post', 'none'], revocation_endpoint_auth_methods_supported: ['client_secret_post'], introspection_endpoint_auth_methods_supported: ['client_secret_post'], + ...(cliRegistration + ? { cli_client_id: cliRegistration.oAuthClientId } + : {}), }; } diff --git a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts index 40734e02521..88bdff92caf 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts @@ -10,6 +10,7 @@ import { v4 } from 'uuid'; import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application/application-oauth/constants/oauth-scopes'; import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity'; +import { TWENTY_CLI_APPLICATION_REGISTRATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-cli-application-registration.constant'; import { ApplicationRegistrationException, ApplicationRegistrationExceptionCode, @@ -327,12 +328,30 @@ export class ApplicationRegistrationService { await this.applicationRegistrationRepository.save(registration); } - async findManyBySourceType( - sourceType: ApplicationRegistrationSourceType, - ): Promise { - return this.applicationRegistrationRepository.find({ - where: { sourceType }, + async createCliRegistrationIfNotExists(): Promise { + const existing = await this.findOneByUniversalIdentifier( + TWENTY_CLI_APPLICATION_REGISTRATION.universalIdentifier, + ); + + if (isDefined(existing)) { + return null; + } + + const registration = this.applicationRegistrationRepository.create({ + universalIdentifier: + TWENTY_CLI_APPLICATION_REGISTRATION.universalIdentifier, + name: TWENTY_CLI_APPLICATION_REGISTRATION.name, + description: TWENTY_CLI_APPLICATION_REGISTRATION.description, + oAuthClientId: v4(), + oAuthClientSecretHash: null, + oAuthRedirectUris: [], + oAuthScopes: TWENTY_CLI_APPLICATION_REGISTRATION.oAuthScopes, + ownerWorkspaceId: null, + sourceType: ApplicationRegistrationSourceType.OAUTH_ONLY, + createdByUserId: null, }); + + return this.applicationRegistrationRepository.save(registration); } async findManyListed(): Promise { diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts index d8f7597bdd3..b70acebb0dc 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts @@ -519,15 +519,45 @@ export class AuthService { ); } - if ( - !applicationRegistration.oAuthRedirectUris.includes( - authorizeAppInput.redirectUrl, - ) - ) { - throw new AuthException( - `redirectUrl mismatch for '${clientId}'`, - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); + // RFC 8252 §7.3: Native apps using loopback redirect URIs may use any port. + // When a registration has no explicit redirect URIs (e.g. the seeded CLI registration), + // allow any loopback redirect URI. + const hasRegisteredRedirectUris = + applicationRegistration.oAuthRedirectUris.length > 0; + + if (hasRegisteredRedirectUris) { + if ( + !applicationRegistration.oAuthRedirectUris.includes( + authorizeAppInput.redirectUrl, + ) + ) { + throw new AuthException( + `redirectUrl mismatch for '${clientId}'`, + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + } else { + let redirectUrl: URL; + + try { + redirectUrl = new URL(authorizeAppInput.redirectUrl); + } catch { + throw new AuthException( + `Invalid redirectUrl for '${clientId}'`, + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + const isLoopback = + redirectUrl.hostname === 'localhost' || + redirectUrl.hostname === '127.0.0.1'; + + if (!isLoopback) { + throw new AuthException( + `redirectUrl mismatch for '${clientId}'`, + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } } // Validate requested scopes are a subset of the registration's allowed scopes diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts index 7c42caa2927..9467ade0734 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts @@ -76,9 +76,6 @@ const createSignInUpServiceForTests = () => { { emitCustomBatchEvent: jest.fn(), } as any, - { - getHttpClient: jest.fn(), - } as any, mockTwentyConfigService as any, { generateSubdomain: jest.fn(), diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts index 29e7191531d..d7ae0ba3f6b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts @@ -33,7 +33,6 @@ import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-p import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service'; import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; -import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service'; import { TelemetryEventType } from 'src/engine/core-modules/telemetry/telemetry-event.type'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; @@ -59,7 +58,6 @@ export class SignInUpService { private readonly userWorkspaceService: UserWorkspaceService, private readonly onboardingService: OnboardingService, private readonly workspaceEventEmitter: WorkspaceEventEmitter, - private readonly secureHttpClientService: SecureHttpClientService, private readonly twentyConfigService: TwentyConfigService, private readonly subdomainManagerService: SubdomainManagerService, private readonly userService: UserService, diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/dev-seeder.module.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/dev-seeder.module.ts index e151a1a88be..817d3468edf 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/dev-seeder.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/dev-seeder.module.ts @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module'; +import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module'; @@ -40,6 +41,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace UserRoleModule, ApiKeyModule, ApplicationModule, + ApplicationRegistrationModule, FeatureFlagModule, FileStorageModule, TypeOrmModule.forFeature([WorkspaceEntity, ObjectMetadataEntity]), diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts index 45d2663c05b..8f24cf73c7a 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts @@ -5,6 +5,7 @@ import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; import { DataSource } from 'typeorm'; import { FeatureFlagKey } from 'twenty-shared/types'; +import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; @@ -37,6 +38,7 @@ export class DevSeederService { private readonly devSeederPermissionsService: DevSeederPermissionsService, private readonly devSeederDataService: DevSeederDataService, private readonly applicationService: ApplicationService, + private readonly applicationRegistrationService: ApplicationRegistrationService, private readonly workspaceCacheService: WorkspaceCacheService, @InjectDataSource() private readonly coreDataSource: DataSource, @@ -54,6 +56,8 @@ export class DevSeederService { appVersion, }); + await this.applicationRegistrationService.createCliRegistrationIfNotExists(); + const schemaName = await this.workspaceDataSourceService.createWorkspaceDBSchema( workspaceId, diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/twenty-cli-application-registration.constant.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/twenty-cli-application-registration.constant.ts new file mode 100644 index 00000000000..56e9b3542ac --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/twenty-cli-application-registration.constant.ts @@ -0,0 +1,6 @@ +export const TWENTY_CLI_APPLICATION_REGISTRATION = { + universalIdentifier: '20202020-82da-4d5a-afd2-53c52090f5e1', + name: 'Twenty CLI', + description: 'Official CLI for Twenty application development', + oAuthScopes: ['api', 'profile'], +};