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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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