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 7c43214930..f71b541908 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 @@ -2,6 +2,7 @@ import { vi } from 'vitest'; const mockApiService = { validateAuth: vi.fn().mockResolvedValue({ authValid: true, serverUp: true }), + getWorkspaceFrontendUrl: vi.fn().mockResolvedValue('http://localhost:3000'), generateApplicationToken: vi.fn().mockResolvedValue({ success: true, data: { @@ -37,6 +38,7 @@ const mockApiService = { vi.mock('@/cli/utilities/api/api-service', () => ({ ApiService: class { validateAuth = mockApiService.validateAuth; + getWorkspaceFrontendUrl = mockApiService.getWorkspaceFrontendUrl; generateApplicationToken = mockApiService.generateApplicationToken; refreshToken = mockApiService.refreshToken; findApplicationRegistrationByUniversalIdentifier = diff --git a/packages/twenty-sdk/src/cli/commands/dev/dev.ts b/packages/twenty-sdk/src/cli/commands/dev/dev.ts index 2e95cff296..b808633865 100644 --- a/packages/twenty-sdk/src/cli/commands/dev/dev.ts +++ b/packages/twenty-sdk/src/cli/commands/dev/dev.ts @@ -1,4 +1,3 @@ -import { ConfigService } from '@/cli/utilities/config/config-service'; import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory'; import { DevModeOrchestrator } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator'; import { OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state'; @@ -37,11 +36,8 @@ export class AppDevCommand { await checkServerVersionCompatibility(); } - const config = await new ConfigService().getConfig(); - const orchestratorState = new OrchestratorState({ appPath, - frontendUrl: config.apiUrl, }); if (!options.headless) { diff --git a/packages/twenty-sdk/src/cli/utilities/api/__tests__/api-client.spec.ts b/packages/twenty-sdk/src/cli/utilities/api/__tests__/api-client.spec.ts new file mode 100644 index 0000000000..f4536c8b28 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/api/__tests__/api-client.spec.ts @@ -0,0 +1,91 @@ +import { ApiClient } from '@/cli/utilities/api/api-client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/cli/utilities/config/config-service', () => ({ + ConfigService: class { + getConfig = vi.fn().mockResolvedValue({ apiUrl: 'http://localhost:2020' }); + setConfig = vi.fn(); + }, +})); + +describe('ApiClient — frontend URL resolution', () => { + let client: ApiClient; + + beforeEach(() => { + client = new ApiClient({ disableInterceptors: true }); + }); + + describe('getFrontendUrl', () => { + it('returns origin of authorization_endpoint', async () => { + vi.spyOn(client.client, 'get').mockResolvedValueOnce({ + data: { authorization_endpoint: 'http://app.localhost:3001/authorize' }, + }); + + expect(await client.getFrontendUrl()).toBe('http://app.localhost:3001'); + }); + + it('returns null when authorization_endpoint is missing', async () => { + vi.spyOn(client.client, 'get').mockResolvedValueOnce({ data: {} }); + + expect(await client.getFrontendUrl()).toBeNull(); + }); + + it('returns null on network error', async () => { + vi.spyOn(client.client, 'get').mockRejectedValueOnce(new Error('boom')); + + expect(await client.getFrontendUrl()).toBeNull(); + }); + }); + + describe('getWorkspaceFrontendUrl', () => { + it('prefers customUrl over subdomainUrl', async () => { + vi.spyOn(client.client, 'post').mockResolvedValueOnce({ + data: { + data: { + currentWorkspace: { + workspaceUrls: { + customUrl: 'https://crm.acme.com', + subdomainUrl: 'http://apple.localhost:3001', + }, + }, + }, + }, + }); + + expect(await client.getWorkspaceFrontendUrl()).toBe( + 'https://crm.acme.com', + ); + }); + + it('returns subdomainUrl when customUrl is absent', async () => { + vi.spyOn(client.client, 'post').mockResolvedValueOnce({ + data: { + data: { + currentWorkspace: { + workspaceUrls: { + subdomainUrl: 'http://apple.localhost:3001', + }, + }, + }, + }, + }); + + expect(await client.getWorkspaceFrontendUrl()).toBe( + 'http://apple.localhost:3001', + ); + }); + + it('falls back to OAuth discovery URL when workspace query returns null', async () => { + vi.spyOn(client.client, 'post').mockResolvedValueOnce({ + data: { data: { currentWorkspace: null } }, + }); + vi.spyOn(client.client, 'get').mockResolvedValueOnce({ + data: { authorization_endpoint: 'http://app.localhost:3001/authorize' }, + }); + + expect(await client.getWorkspaceFrontendUrl()).toBe( + 'http://app.localhost:3001', + ); + }); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/api/api-client.ts b/packages/twenty-sdk/src/cli/utilities/api/api-client.ts index 6a22d64798..0f897ba719 100644 --- a/packages/twenty-sdk/src/cli/utilities/api/api-client.ts +++ b/packages/twenty-sdk/src/cli/utilities/api/api-client.ts @@ -1,6 +1,8 @@ import { ConfigService } from '@/cli/utilities/config/config-service'; +import { isNonEmptyString } from '@sniptt/guards'; import axios, { type AxiosInstance } from 'axios'; import chalk from 'chalk'; +import { isDefined } from 'twenty-shared/utils'; export class ApiClient { readonly client: AxiosInstance; @@ -70,6 +72,74 @@ export class ApiClient { ); } + async getFrontendUrl(): Promise { + try { + const response = await this.client.get( + '/.well-known/oauth-authorization-server', + { headers: { Accept: 'application/json' } }, + ); + const authorizationEndpoint = response.data?.authorization_endpoint; + + if (!isNonEmptyString(authorizationEndpoint)) { + return null; + } + + return new URL(authorizationEndpoint).origin; + } catch { + return null; + } + } + + async getWorkspaceFrontendUrl(): Promise { + const workspaceFrontendUrl = await this.getCurrentWorkspaceFrontendUrl(); + + if (isDefined(workspaceFrontendUrl)) { + return workspaceFrontendUrl; + } + + return this.getFrontendUrl(); + } + + private async getCurrentWorkspaceFrontendUrl(): Promise { + try { + const query = ` + query CurrentWorkspaceForFrontendUrl { + currentWorkspace { + workspaceUrls { + subdomainUrl + customUrl + } + } + } + `; + + const response = await this.client.post( + '/metadata', + { query }, + { + headers: { + 'Content-Type': 'application/json', + Accept: '*/*', + }, + }, + ); + + const workspaceUrls = + response.data?.data?.currentWorkspace?.workspaceUrls; + const workspaceFrontendUrl = isNonEmptyString(workspaceUrls?.customUrl) + ? workspaceUrls.customUrl + : workspaceUrls?.subdomainUrl; + + if (!isNonEmptyString(workspaceFrontendUrl)) { + return null; + } + + return new URL(workspaceFrontendUrl).origin; + } catch { + return null; + } + } + async validateAuth(): Promise<{ authValid: boolean; serverUp: boolean }> { try { const query = ` 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 6aa6cb8381..fbf403f63d 100644 --- a/packages/twenty-sdk/src/cli/utilities/api/api-service.ts +++ b/packages/twenty-sdk/src/cli/utilities/api/api-service.ts @@ -32,6 +32,10 @@ export class ApiService { return this.apiClient.validateAuth(); } + getWorkspaceFrontendUrl(): Promise { + return this.apiClient.getWorkspaceFrontendUrl(); + } + refreshToken(): Promise { return this.apiClient.refreshToken(); } diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts index b721e452d5..90df3db593 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts @@ -123,9 +123,8 @@ export class OrchestratorState { private eventIdCounter = 0; onChange?: () => void; - constructor(options: { appPath: string; frontendUrl?: string }) { + constructor(options: { appPath: string }) { this.appPath = options.appPath; - this.frontendUrl = options.frontendUrl; this.previousObjectsFieldsFingerprint = null; 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 dd3bb9fe63..17127992a6 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 @@ -2,6 +2,7 @@ import { type ApiService } from '@/cli/utilities/api/api-service'; import { ConfigService } from '@/cli/utilities/config/config-service'; import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state'; import { detectLocalServer } from '@/cli/utilities/server/detect-local-server'; +import { isDefined } from 'twenty-shared/utils'; export type CheckServerOrchestratorStepOutput = { isReady: boolean; @@ -88,6 +89,14 @@ export class CheckServerOrchestratorStep { step.output = { isReady: true, errorLogged: false }; step.status = 'done'; + if (!isDefined(this.state.frontendUrl)) { + const frontendUrl = await this.apiService.getWorkspaceFrontendUrl(); + + if (isDefined(frontendUrl)) { + this.state.frontendUrl = frontendUrl; + } + } + if (!wasReady) { this.notify(); } diff --git a/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts b/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts index b718115be3..e0c3b04857 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts @@ -7,6 +7,7 @@ import { type OrchestratorStateEntityInfo, } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state'; import { SyncableEntity } from 'twenty-shared/application'; +import { isDefined } from 'twenty-shared/utils'; export type DevUiStatus = | 'idle' @@ -157,14 +158,13 @@ export const groupEntitiesByType = ( }; export const getApplicationUrl = (state: OrchestratorState): string | null => { - if ( - !state.frontendUrl || - !state.steps.resolveApplication.output.universalIdentifier - ) { + const applicationId = state.steps.resolveApplication.output.applicationId; + + if (!isDefined(state.frontendUrl) || !isDefined(applicationId)) { return null; } - return `${state.frontendUrl}/settings/applications`; + return `${state.frontendUrl}/settings/applications/${applicationId}`; }; export const mergeStepStatuses = (