Twenty sdk cli oauth (#18638)

<img width="1418" height="804" alt="image"
src="https://github.com/user-attachments/assets/de6c8222-6496-4a71-bc21-7e5e1269d5cb"
/>

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
martmull
2026-03-17 11:43:17 +01:00
committed by GitHub
parent 111debc1ce
commit 731e297147
96 changed files with 3191 additions and 2453 deletions
@@ -0,0 +1,140 @@
import { execSync } from 'child_process';
import path from 'path';
import { buildApplication } from '@/cli/utilities/build/common/build-application';
import { synchronizeBuiltApplication } from '@/cli/utilities/build/common/synchronize-built-application';
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 '@/cli/types';
export type AppBuildOptions = {
appPath: string;
tarball?: boolean;
onProgress?: (message: string) => void;
};
export type AppBuildResult = {
outputDir: string;
fileCount: number;
tarballPath?: string;
};
const innerAppBuild = async (
options: AppBuildOptions,
): Promise<CommandResult<AppBuildResult>> => {
const { appPath, onProgress } = options;
onProgress?.('Building manifest...');
const manifestResult = await buildAndValidateManifest(appPath);
if (!manifestResult.success) {
return {
success: false,
error: {
code: APP_ERROR_CODES.MANIFEST_BUILD_FAILED,
message: manifestResult.errors.join('\n'),
},
};
}
const { manifest, filePaths } = manifestResult;
for (const warning of manifestResult.warnings) {
onProgress?.(`${warning}`);
}
onProgress?.('Building application files...');
const firstBuildResult = await buildApplication({
appPath,
manifest,
filePaths,
});
onProgress?.('Syncing application schema...');
const firstSyncResult = await synchronizeBuiltApplication({
appPath,
manifest,
builtFileInfos: firstBuildResult.builtFileInfos,
});
if (!firstSyncResult.success) {
return firstSyncResult;
}
onProgress?.('Generating API client...');
const clientService = new ClientService();
await clientService.generateCoreClient({ appPath });
onProgress?.('Running typecheck...');
const typecheckErrors = await runTypecheck(appPath);
if (typecheckErrors.length > 0) {
const errorMessages = typecheckErrors.map(
(error) =>
`${error.file}(${error.line},${error.column + 1}): ${error.text}`,
);
return {
success: false,
error: {
code: APP_ERROR_CODES.TYPECHECK_FAILED,
message: `Typecheck failed:\n${errorMessages.join('\n')}`,
},
};
}
onProgress?.('Rebuilding with generated client...');
const finalBuildResult = await buildApplication({
appPath,
manifest,
filePaths,
});
onProgress?.('Syncing built files...');
const finalSyncResult = await synchronizeBuiltApplication({
appPath,
manifest,
builtFileInfos: finalBuildResult.builtFileInfos,
});
if (!finalSyncResult.success) {
return finalSyncResult;
}
const outputDir = path.join(appPath, '.twenty', 'output');
const result: AppBuildResult = {
outputDir,
fileCount: finalBuildResult.builtFileInfos.size,
};
if (options.tarball) {
onProgress?.('Packing tarball...');
const packOutput = execSync('npm pack --pack-destination .', {
cwd: outputDir,
encoding: 'utf-8',
}).trim();
const tarballName = packOutput.split('\n').pop()!;
result.tarballPath = path.join(outputDir, tarballName);
}
return { success: true, data: result };
};
export const appBuild = (
options: AppBuildOptions,
): Promise<CommandResult<AppBuildResult>> =>
runSafe(() => innerAppBuild(options), APP_ERROR_CODES.BUILD_FAILED);
@@ -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<CommandResult<AppDeployResult>> => {
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<CommandResult<AppDeployResult>> =>
runSafe(() => innerAppDeploy(options), APP_ERROR_CODES.DEPLOY_FAILED);
@@ -0,0 +1,164 @@
import { ApiService } from '@/cli/utilities/api/api-service';
import { readManifestFromFile } from '@/cli/utilities/build/manifest/manifest-reader';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { type Manifest } from 'twenty-shared/application';
import { runSafe } from '@/cli/utilities/run-safe';
import {
APP_ERROR_CODES,
FUNCTION_ERROR_CODES,
type CommandResult,
type FunctionExecutionResult,
} from '@/cli/types';
export type FunctionExecuteOptions = {
appPath: string;
remote?: string;
payload?: Record<string, unknown>;
} & (
| { postInstall: true }
| { functionUniversalIdentifier: string }
| { functionName: string }
);
type LogicFunction = {
id: string;
name: string;
universalIdentifier: string;
applicationId: string | null;
};
const belongsToApplication = (
logicFunction: LogicFunction,
manifest: Manifest,
): boolean => {
return manifest.logicFunctions.some(
(manifestFn) =>
manifestFn.universalIdentifier === logicFunction.universalIdentifier,
);
};
const resolveIdentifier = (options: FunctionExecuteOptions): string => {
if ('postInstall' in options) return 'post install';
if ('functionUniversalIdentifier' in options)
return options.functionUniversalIdentifier;
if ('functionName' in options) return options.functionName;
return 'unknown';
};
const innerFunctionExecute = async (
options: FunctionExecuteOptions,
): Promise<CommandResult<FunctionExecutionResult>> => {
if (options.remote) {
ConfigService.setActiveRemote(options.remote);
}
const apiService = new ApiService();
const manifest = await readManifestFromFile(options.appPath);
if (!manifest) {
return {
success: false,
error: {
code: APP_ERROR_CODES.MANIFEST_NOT_FOUND,
message: 'Manifest not found. Run `build` or `dev` first.',
},
};
}
const functionsResult = await apiService.findLogicFunctions();
if (!functionsResult.success) {
const errorMessage =
functionsResult.error instanceof Error
? functionsResult.error.message
: String(functionsResult.error ?? 'Failed to fetch functions');
return {
success: false,
error: {
code: FUNCTION_ERROR_CODES.FETCH_FUNCTIONS_FAILED,
message: errorMessage,
},
};
}
const appFunctions = functionsResult.data.filter(
(logicFunction) =>
logicFunction.universalIdentifier &&
belongsToApplication(logicFunction, manifest),
);
const targetFunction = appFunctions.find((logicFunction) => {
if ('postInstall' in options && options.postInstall) {
return (
logicFunction.universalIdentifier ===
manifest.application.postInstallLogicFunctionUniversalIdentifier
);
}
if ('functionUniversalIdentifier' in options) {
return (
logicFunction.universalIdentifier ===
options.functionUniversalIdentifier
);
}
if ('functionName' in options) {
return logicFunction.name === options.functionName;
}
return false;
});
if (!targetFunction) {
return {
success: false,
error: {
code: FUNCTION_ERROR_CODES.FUNCTION_NOT_FOUND,
message: `Function "${resolveIdentifier(options)}" not found in application.`,
details: {
identifier: resolveIdentifier(options),
availableFunctions: appFunctions.map((logicFunction) => ({
name: logicFunction.name,
universalIdentifier: logicFunction.universalIdentifier,
})),
},
},
};
}
const result = await apiService.executeLogicFunction({
functionId: targetFunction.id,
payload: options.payload ?? {},
});
if (!result.success) {
const errorMessage =
result.error instanceof Error
? result.error.message
: String(result.error ?? 'Execution failed');
return {
success: false,
error: {
code: FUNCTION_ERROR_CODES.EXECUTION_FAILED,
message: errorMessage,
},
};
}
return {
success: true,
data: {
functionName: targetFunction.name,
...result.data!,
},
};
};
export const functionExecute = (
options: FunctionExecuteOptions,
): Promise<CommandResult<FunctionExecutionResult>> =>
runSafe(
() => innerFunctionExecute(options),
FUNCTION_ERROR_CODES.EXECUTION_FAILED,
);
@@ -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';
@@ -0,0 +1,143 @@
import { ApiService } from '@/cli/utilities/api/api-service';
import { startCallbackServer } from '@/cli/utilities/auth/callback-server';
import { openBrowser } from '@/cli/utilities/auth/open-browser';
import { generatePkceChallenge } from '@/cli/utilities/auth/pkce';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { runSafe } from '@/cli/utilities/run-safe';
import axios from 'axios';
import { AUTH_ERROR_CODES, type CommandResult } from '@/cli/types';
export type AuthLoginOAuthOptions = {
apiUrl: string;
remote?: string;
timeoutMs?: number;
};
export type OAuthDiscoveryResponse = {
authorization_endpoint: string;
token_endpoint: string;
cli_client_id?: string;
};
const innerAuthLoginOAuth = async (
options: AuthLoginOAuthOptions,
): Promise<CommandResult> => {
const { apiUrl, remote, timeoutMs } = options;
if (remote) {
ConfigService.setActiveRemote(remote);
}
const configService = new ConfigService();
const discoveryUrl = `${apiUrl}/.well-known/oauth-authorization-server`;
let discovery: OAuthDiscoveryResponse;
try {
const response = await axios.get(discoveryUrl);
discovery = response.data;
} catch {
return {
success: false,
error: {
code: AUTH_ERROR_CODES.OAUTH_NOT_SUPPORTED,
message: `Could not reach the OAuth discovery endpoint at ${discoveryUrl}. Ensure the server is running. Use --api-key instead.`,
},
};
}
if (!discovery.cli_client_id) {
return {
success: false,
error: {
code: AUTH_ERROR_CODES.OAUTH_NOT_SUPPORTED,
message:
'Server does not expose a CLI client ID. Ensure the server is up to date. Use --api-key instead.',
},
};
}
const clientId = discovery.cli_client_id;
const { codeVerifier, codeChallenge } = generatePkceChallenge();
const callbackServer = await startCallbackServer({ timeoutMs });
try {
const authUrl = new URL(discovery.authorization_endpoint);
authUrl.searchParams.set('clientId', clientId);
authUrl.searchParams.set('codeChallenge', codeChallenge);
authUrl.searchParams.set('redirectUrl', callbackServer.callbackUrl);
const browserOpened = await openBrowser(authUrl.toString());
if (!browserOpened) {
console.log(
`\nOpen this URL in your browser to authenticate:\n${authUrl.toString()}\n`,
);
}
const callbackResult = await callbackServer.waitForCallback();
if (!callbackResult.success) {
return {
success: false,
error: {
code: AUTH_ERROR_CODES.AUTH_FAILED,
message: callbackResult.error,
},
};
}
const tokenResponse = await axios.post(discovery.token_endpoint, {
grant_type: 'authorization_code',
code: callbackResult.code,
code_verifier: codeVerifier,
redirect_uri: callbackServer.callbackUrl,
client_id: clientId,
});
const { access_token: accessToken, refresh_token: refreshToken } =
tokenResponse.data;
await configService.setConfig({
apiUrl,
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<CommandResult> =>
runSafe(() => innerAuthLoginOAuth(options), AUTH_ERROR_CODES.AUTH_FAILED);
@@ -0,0 +1,50 @@
import { ApiService } from '@/cli/utilities/api/api-service';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { runSafe } from '@/cli/utilities/run-safe';
import { AUTH_ERROR_CODES, type CommandResult } from '@/cli/types';
export type AuthLoginOptions = {
apiKey: string;
apiUrl: string;
remote?: string;
};
const innerAuthLogin = async (
options: AuthLoginOptions,
): Promise<CommandResult> => {
const { apiKey, apiUrl, remote } = options;
if (remote) {
ConfigService.setActiveRemote(remote);
}
const configService = new ConfigService();
await configService.setConfig({
apiUrl,
apiKey,
accessToken: undefined,
refreshToken: undefined,
oauthClientId: undefined,
});
const apiService = new ApiService();
const validateAuth = await apiService.validateAuth();
if (!validateAuth.authValid) {
await configService.clearConfig();
return {
success: false,
error: {
code: AUTH_ERROR_CODES.AUTH_FAILED,
message: 'Authentication failed. Please check your credentials.',
},
};
}
return { success: true, data: undefined };
};
export const authLogin = (options: AuthLoginOptions): Promise<CommandResult> =>
runSafe(() => innerAuthLogin(options), AUTH_ERROR_CODES.AUTH_FAILED);
@@ -0,0 +1,26 @@
import { ConfigService } from '@/cli/utilities/config/config-service';
import { runSafe } from '@/cli/utilities/run-safe';
import { AUTH_ERROR_CODES, type CommandResult } from '@/cli/types';
export type AuthLogoutOptions = {
remote?: string;
};
const innerAuthLogout = async (
options?: AuthLogoutOptions,
): Promise<CommandResult> => {
if (options?.remote) {
ConfigService.setActiveRemote(options.remote);
}
const configService = new ConfigService();
await configService.clearConfig();
return { success: true, data: undefined };
};
export const authLogout = (
options?: AuthLogoutOptions,
): Promise<CommandResult> =>
runSafe(() => innerAuthLogout(options), AUTH_ERROR_CODES.AUTH_FAILED);
@@ -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<CommandResult<AppPublishResult>> => {
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<CommandResult<AppPublishResult>> =>
runSafe(() => innerAppPublish(options), APP_ERROR_CODES.PUBLISH_FAILED);
@@ -0,0 +1,57 @@
import { ApiService } from '@/cli/utilities/api/api-service';
import { readManifestFromFile } from '@/cli/utilities/build/manifest/manifest-reader';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { runSafe } from '@/cli/utilities/run-safe';
import { APP_ERROR_CODES, type CommandResult } from '@/cli/types';
export type AppUninstallOptions = {
appPath: string;
remote?: string;
};
const innerAppUninstall = async (
options: AppUninstallOptions,
): Promise<CommandResult> => {
if (options.remote) {
ConfigService.setActiveRemote(options.remote);
}
const apiService = new ApiService();
const manifest = await readManifestFromFile(options.appPath);
if (!manifest) {
return {
success: false,
error: {
code: APP_ERROR_CODES.MANIFEST_NOT_FOUND,
message: 'Manifest not found. Run `build` or `dev` first.',
},
};
}
const result = await apiService.uninstallApplication(
manifest.application.universalIdentifier,
);
if (!result.success) {
const errorMessage =
result.error instanceof Error
? result.error.message
: String(result.error ?? 'Unknown error');
return {
success: false,
error: {
code: APP_ERROR_CODES.UNINSTALL_FAILED,
message: errorMessage,
},
};
}
return { success: true, data: undefined };
};
export const appUninstall = (
options: AppUninstallOptions,
): Promise<CommandResult> =>
runSafe(() => innerAppUninstall(options), APP_ERROR_CODES.UNINSTALL_FAILED);