Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code 6b7f5de3a7 SendInvitations crashes when workspace member doesn't exist during onboarding
https://sonarly.com/issue/4106?type=bug

The sendInvitations resolver throws EntityNotFoundError because it assumes a workspace member always exists for authenticated users, but the JWT auth strategy deliberately skips workspace member validation during workspace creation states (PENDING_CREATION/ONGOING_CREATION).

Fix: Added workspace activation status validation to both `sendInvitations` and `resendWorkspaceInvitation` methods in `workspace-invitation.resolver.ts`. Before attempting to look up the workspace member, the resolver now checks `isWorkspaceActiveOrSuspended(workspace)` and throws a `WorkspaceException` with code `WORKSPACE_NOT_READY` if the workspace is still in `PENDING_CREATION` or `ONGOING_CREATION` state.

This prevents the `EntityNotFoundError` crash by failing fast with a meaningful, user-friendly error before the `findOneOrFail` query that assumes a workspace member exists.

Also added the `WORKSPACE_NOT_READY` exception code and its user-friendly message to `workspace.exception.ts`, following the existing exhaustive switch pattern.
2026-03-06 17:53:18 +00:00
Sonarly Claude Code 629d524837 chore: additional changes for Google OAuth sign-up fails when profile picture do 2026-03-06 17:46:37 +00:00
Sonarly Claude Code 243734d29e chore: improve monitoring for Google OAuth sign-up fails when profile picture do
The Sentry error title was `"Failed to fetch image from URL: "` with an empty suffix because the original error's `.message` was an empty string. This made it impossible to determine the actual failure type (ECONNRESET, ETIMEDOUT, TLS error, etc.).

**Change in `image.ts`**: When re-throwing the error in `getImageBufferFromUrl`, the code now falls back to the error's `.code` property (present on AxiosError and Node.js system errors like `ECONNRESET`, `ETIMEDOUT`, `ERR_NETWORK`) when `.message` is empty. This ensures Sentry and log messages always contain actionable diagnostic information instead of empty strings.

Before: `Failed to fetch image from <url>: ` (empty — un-debuggable)
After: `Failed to fetch image from <url>: ECONNRESET` (actionable)
2026-03-06 17:46:34 +00:00
Sonarly Claude Code 569951ba6b Google OAuth sign-up fails when profile picture download encounters network error
https://sonarly.com/issue/10350?type=bug

A transient network failure fetching a Google profile picture during OAuth sign-up causes the entire workspace creation transaction to roll back, completely blocking new user registration.

Fix: The original root cause fix (commit `4c001778c2`) addressed the v1.18.1 legacy code path by wrapping `uploadImageFromUrl` in a try/catch within `computeDefaultAvatarUrlLegacy`. The codebase has since been refactored to remove the legacy path and always use `computeDefaultAvatarUrlMigrated`.

However, **the migrated path has the same vulnerability**: `uploadWorkspaceMemberProfilePictureFromUrl` is called without a try/catch at line 483-491 of `user-workspace.service.ts`. While the image *fetch* is internally protected by `fetchImageBufferFromUrl`'s try/catch, the subsequent file *storage* upload (`uploadCorePicture` → `fileStorageService.writeFile`) is not. A storage service failure after a successful image download would still crash the sign-up transaction.

**Change**: Wrapped the `uploadWorkspaceMemberProfilePictureFromUrl` call in `computeDefaultAvatarUrlMigrated` with a try/catch that logs a warning and returns `undefined`, matching the pattern already used for the existing-user branch of the same method (lines 461-478) and the original v1.18.1 fix.
2026-03-06 17:46:34 +00:00
55 changed files with 2196 additions and 1841 deletions
@@ -47,7 +47,7 @@ describe('scaffoldIntegrationTest', () => {
const content = await fs.readFile(testPath, 'utf8');
expect(content).toContain(
"import { appGenerateClient, appUninstall } from 'twenty-sdk/cli'",
"import { appBuild, appUninstall } from 'twenty-sdk/cli'",
);
expect(content).toContain(
"import { MetadataApiClient } from 'twenty-sdk/generated'",
@@ -57,7 +57,7 @@ describe('scaffoldIntegrationTest', () => {
);
expect(content).toContain('TWENTY_TEST_API_KEY');
expect(content).toContain('assertServerIsReachable');
expect(content).toContain('appGenerateClient');
expect(content).toContain('appBuild');
expect(content).toContain('appUninstall');
expect(content).toContain('findManyApplications');
expect(content).toContain('APPLICATION_UNIVERSAL_IDENTIFIER');
@@ -128,7 +128,7 @@ const createIntegrationTest = async ({
fileName: string;
}) => {
const content = `import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config';
import { appGenerateClient, appUninstall } from 'twenty-sdk/cli';
import { appBuild, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-sdk/generated';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
@@ -158,14 +158,14 @@ describe('App installation', () => {
beforeAll(async () => {
await assertServerIsReachable();
const generateResult = await appGenerateClient({
const buildResult = await appBuild({
appPath: APP_PATH,
onProgress: (message: string) => console.log(\`[generate-client] \${message}\`),
onProgress: (message: string) => console.log(\`[build] \${message}\`),
});
if (!generateResult.success) {
if (!buildResult.success) {
throw new Error(
\`Client generation failed: \${generateResult.error?.message ?? 'Unknown error'}\`,
\`App build failed: \${buildResult.error?.message ?? 'Unknown error'}\`,
);
}
@@ -1,5 +1,5 @@
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config';
import { appGenerateClient, appUninstall } from 'twenty-sdk/cli';
import { appBuild, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-sdk/generated';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
@@ -29,15 +29,14 @@ describe('App installation', () => {
beforeAll(async () => {
await assertServerIsReachable();
const generateResult = await appGenerateClient({
const buildResult = await appBuild({
appPath: APP_PATH,
onProgress: (message: string) =>
console.log(`[generate-client] ${message}`),
onProgress: (message: string) => console.log(`[build] ${message}`),
});
if (!generateResult.success) {
if (!buildResult.success) {
throw new Error(
`Client generation failed: ${generateResult.error?.message ?? 'Unknown error'}`,
`App build failed: ${buildResult.error?.message ?? 'Unknown error'}`,
);
}
@@ -1484,12 +1484,6 @@ export type DestroyViewSortInput = {
id: Scalars['UUID'];
};
export type DevelopmentApplication = {
__typename?: 'DevelopmentApplication';
id: Scalars['String'];
universalIdentifier: Scalars['String'];
};
export type DomainRecord = {
__typename?: 'DomainRecord';
key: Scalars['String'];
@@ -2373,7 +2367,6 @@ export type Mutation = {
createCoreViewGroup: CoreViewGroup;
createCoreViewSort: CoreViewSort;
createDatabaseConfigVariable: Scalars['Boolean'];
createDevelopmentApplication: DevelopmentApplication;
createEmailingDomain: EmailingDomain;
createFrontComponent: FrontComponent;
createManyCoreViewFieldGroups: Array<CoreViewFieldGroup>;
@@ -2456,6 +2449,7 @@ export type Mutation = {
initiateOTPProvisioningForAuthenticatedUser: InitiateTwoFactorAuthenticationProvisioning;
installApplication: Scalars['Boolean'];
installMarketplaceApp: Scalars['Boolean'];
registerNpmPackage: ApplicationRegistration;
removeQueryFromEventStream: Scalars['Boolean'];
removeRoleFromAgent: Scalars['Boolean'];
renewApplicationToken: ApplicationTokenPair;
@@ -2654,12 +2648,6 @@ export type MutationCreateDatabaseConfigVariableArgs = {
};
export type MutationCreateDevelopmentApplicationArgs = {
name: Scalars['String'];
universalIdentifier: Scalars['String'];
};
export type MutationCreateEmailingDomainArgs = {
domain: Scalars['String'];
driver: EmailingDomainDriver;
@@ -3055,6 +3043,11 @@ export type MutationInstallMarketplaceAppArgs = {
};
export type MutationRegisterNpmPackageArgs = {
packageName: Scalars['String'];
};
export type MutationRemoveQueryFromEventStreamArgs = {
input: RemoveQueryFromEventStreamInput;
};
@@ -6272,6 +6265,13 @@ export type InstallMarketplaceAppMutationVariables = Exact<{
export type InstallMarketplaceAppMutation = { __typename?: 'Mutation', installMarketplaceApp: boolean };
export type RegisterNpmPackageMutationVariables = Exact<{
packageName: Scalars['String'];
}>;
export type RegisterNpmPackageMutation = { __typename?: 'Mutation', registerNpmPackage: { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string } };
export type UpgradeApplicationMutationVariables = Exact<{
appRegistrationId: Scalars['String'];
targetVersion: Scalars['String'];
@@ -11604,6 +11604,41 @@ export function useInstallMarketplaceAppMutation(baseOptions?: Apollo.MutationHo
export type InstallMarketplaceAppMutationHookResult = ReturnType<typeof useInstallMarketplaceAppMutation>;
export type InstallMarketplaceAppMutationResult = Apollo.MutationResult<InstallMarketplaceAppMutation>;
export type InstallMarketplaceAppMutationOptions = Apollo.BaseMutationOptions<InstallMarketplaceAppMutation, InstallMarketplaceAppMutationVariables>;
export const RegisterNpmPackageDocument = gql`
mutation RegisterNpmPackage($packageName: String!) {
registerNpmPackage(packageName: $packageName) {
id
universalIdentifier
name
}
}
`;
export type RegisterNpmPackageMutationFn = Apollo.MutationFunction<RegisterNpmPackageMutation, RegisterNpmPackageMutationVariables>;
/**
* __useRegisterNpmPackageMutation__
*
* To run a mutation, you first call `useRegisterNpmPackageMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useRegisterNpmPackageMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [registerNpmPackageMutation, { data, loading, error }] = useRegisterNpmPackageMutation({
* variables: {
* packageName: // value for 'packageName'
* },
* });
*/
export function useRegisterNpmPackageMutation(baseOptions?: Apollo.MutationHookOptions<RegisterNpmPackageMutation, RegisterNpmPackageMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<RegisterNpmPackageMutation, RegisterNpmPackageMutationVariables>(RegisterNpmPackageDocument, options);
}
export type RegisterNpmPackageMutationHookResult = ReturnType<typeof useRegisterNpmPackageMutation>;
export type RegisterNpmPackageMutationResult = Apollo.MutationResult<RegisterNpmPackageMutation>;
export type RegisterNpmPackageMutationOptions = Apollo.BaseMutationOptions<RegisterNpmPackageMutation, RegisterNpmPackageMutationVariables>;
export const UpgradeApplicationDocument = gql`
mutation UpgradeApplication($appRegistrationId: String!, $targetVersion: String!) {
upgradeApplication(
@@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const REGISTER_NPM_PACKAGE = gql`
mutation RegisterNpmPackage($packageName: String!) {
registerNpmPackage(packageName: $packageName) {
id
universalIdentifier
name
}
}
`;
@@ -0,0 +1,50 @@
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useMutation } from '@apollo/client';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { REGISTER_NPM_PACKAGE } from '~/modules/marketplace/graphql/mutations/registerNpmPackage';
export const useRegisterNpmPackage = () => {
const [registerNpmPackageMutation] = useMutation(REGISTER_NPM_PACKAGE);
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
const [isRegistering, setIsRegistering] = useState(false);
const register = async (params: {
packageName: string;
}): Promise<boolean> => {
setIsRegistering(true);
try {
const result = await registerNpmPackageMutation({
variables: { packageName: params.packageName },
});
const registration = result.data?.registerNpmPackage;
if (!isDefined(registration)) {
enqueueErrorSnackBar({ message: t`Registration failed.` });
return false;
}
enqueueSuccessSnackBar({
message: t`Package registered successfully.`,
});
return true;
} catch (error) {
const graphqlMessage = error instanceof Error ? error.message : undefined;
enqueueErrorSnackBar({
message: graphqlMessage ?? t`Failed to register npm package.`,
});
return false;
} finally {
setIsRegistering(false);
}
};
return { register, isRegistering };
};
@@ -0,0 +1,112 @@
import { styled } from '@linaria/react';
import { useState } from 'react';
import { FIND_MANY_APPLICATION_REGISTRATIONS } from '@/settings/application-registrations/graphql/queries/findManyApplicationRegistrations';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useApolloClient } from '@apollo/client';
import { useLingui } from '@lingui/react/macro';
import { H1Title, H1TitleFontColor } from 'twenty-ui/display';
import { Section, SectionAlignment, SectionFontColor } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useRegisterNpmPackage } from '~/modules/marketplace/hooks/useRegisterNpmPackage';
import {
StyledAppModal,
StyledAppModalButton,
StyledAppModalSection,
StyledAppModalTitle,
} from '~/pages/settings/applications/components/SettingsAppModalLayout';
export const REGISTER_NPM_APP_MODAL_ID = 'register-npm-app-modal';
const StyledInputGroup = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[3]};
`;
export const SettingsRegisterNpmAppModal = () => {
const { t } = useLingui();
const { closeModal } = useModal();
const { register, isRegistering } = useRegisterNpmPackage();
const apolloClient = useApolloClient();
const [packageName, setPackageName] = useState('');
const isValid = packageName.trim().length > 0;
const handleRegister = async () => {
if (!isValid) {
return;
}
const success = await register({
packageName: packageName.trim(),
});
if (success) {
await apolloClient.refetchQueries({
include: [FIND_MANY_APPLICATION_REGISTRATIONS],
});
closeModal(REGISTER_NPM_APP_MODAL_ID);
setPackageName('');
}
};
const handleCancel = () => {
closeModal(REGISTER_NPM_APP_MODAL_ID);
setPackageName('');
};
return (
<StyledAppModal
modalId={REGISTER_NPM_APP_MODAL_ID}
isClosable={true}
padding="large"
dataGloballyPreventClickOutside
>
<StyledAppModalTitle>
<H1Title
title={t`Register from npm`}
fontColor={H1TitleFontColor.Primary}
/>
</StyledAppModalTitle>
<StyledAppModalSection
alignment={SectionAlignment.Center}
fontColor={SectionFontColor.Primary}
>
{t`Enter the npm package name to register as an application.`}
</StyledAppModalSection>
<Section>
<StyledInputGroup>
<SettingsTextInput
instanceId="npm-package-name-input"
value={packageName}
onChange={setPackageName}
placeholder={t`e.g. @twentyhq/hello-world`}
fullWidth
disableHotkeys
label={t`Package name`}
autoFocusOnMount
/>
</StyledInputGroup>
</Section>
<StyledAppModalButton
onClick={handleCancel}
variant="secondary"
title={t`Cancel`}
fullWidth
/>
<StyledAppModalButton
onClick={handleRegister}
variant="secondary"
accent="blue"
title={t`Register`}
disabled={!isValid || isRegistering}
fullWidth
/>
</StyledAppModal>
);
};
@@ -1,165 +0,0 @@
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { UPDATE_APPLICATION_REGISTRATION } from '@/settings/application-registrations/graphql/mutations/updateApplicationRegistration';
import { FIND_APPLICATION_REGISTRATION_STATS } from '@/settings/application-registrations/graphql/queries/findApplicationRegistrationStats';
import { FIND_MANY_APPLICATION_REGISTRATIONS } from '@/settings/application-registrations/graphql/queries/findManyApplicationRegistrations';
import { FIND_ONE_APPLICATION_REGISTRATION } from '@/settings/application-registrations/graphql/queries/findOneApplicationRegistration';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useMutation, useQuery } from '@apollo/client';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import {
H2Title,
IconChartBar,
IconDownload,
IconExternalLink,
IconTag,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Card, Section } from 'twenty-ui/layout';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { ApplicationRegistrationSourceType } from '~/generated-metadata/graphql';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type ApplicationRegistrationData } from '~/pages/settings/applications/tabs/types/ApplicationRegistrationData';
const StyledButtonGroup = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[2]};
padding-top: ${themeCssVariables.spacing[2]};
`;
export const SettingsApplicationRegistrationDistributionTab = ({
registration,
}: {
registration: ApplicationRegistrationData;
}) => {
const { t } = useLingui();
const { enqueueErrorSnackBar } = useSnackBar();
const applicationRegistrationId = registration.id;
const isNpmSource =
registration.sourceType === ApplicationRegistrationSourceType.NPM;
const { data: statsData } = useQuery(FIND_APPLICATION_REGISTRATION_STATS, {
variables: { id: applicationRegistrationId },
skip: !applicationRegistrationId,
});
const [updateRegistration] = useMutation(UPDATE_APPLICATION_REGISTRATION, {
refetchQueries: [
FIND_ONE_APPLICATION_REGISTRATION,
FIND_MANY_APPLICATION_REGISTRATIONS,
],
});
const handleToggleListed = async () => {
try {
await updateRegistration({
variables: {
input: {
id: applicationRegistrationId,
update: {
isListed: !registration.isListed,
},
},
},
});
} catch {
enqueueErrorSnackBar({
message: t`Error updating marketplace listing`,
});
}
};
const marketplacePageUrl = getSettingsPath(
SettingsPath.AvailableApplicationDetail,
{
availableApplicationId: registration.universalIdentifier,
},
);
const stats = statsData?.findApplicationRegistrationStats;
const hasStats = (stats?.activeInstalls ?? 0) > 0;
const versionDistributionLabel =
stats?.versionDistribution
?.map(
(entry: { version: string; count: number }) =>
`${entry.version} (${entry.count})`,
)
.join(', ') || '—';
const statsItems = [
{
Icon: IconDownload,
label: t`Active installs`,
value: stats?.activeInstalls ?? '—',
},
{
Icon: IconTag,
label: t`Most installed version`,
value: stats?.mostInstalledVersion ?? '—',
},
{
Icon: IconChartBar,
label: t`Distribution`,
value: versionDistributionLabel,
},
];
return (
<>
<Section>
<H2Title
title={t`Marketplace Listing`}
description={t`Control visibility on the marketplace. Unlisted apps are still accessible via direct link.`}
/>
<Card rounded>
<SettingsOptionCardContentToggle
title={t`Listed on marketplace`}
description={
isNpmSource
? t`Managed by the marketplace catalog sync for npm packages`
: t`When enabled, this app appears in the marketplace browse page`
}
checked={registration.isListed}
onChange={handleToggleListed}
disabled={isNpmSource}
divider
/>
<SettingsOptionCardContentToggle
title={t`Featured`}
description={t`Featured apps are curated. Open a PR to request featured status.`}
checked={registration.isFeatured}
onChange={() => {}}
disabled
/>
</Card>
<StyledButtonGroup>
<Button
Icon={IconExternalLink}
title={t`View marketplace page`}
variant="secondary"
to={marketplacePageUrl}
/>
</StyledButtonGroup>
</Section>
{hasStats && (
<Section>
<H2Title
title={t`Install Stats`}
description={t`Usage across all workspaces on this server`}
/>
<SettingsAdminTableCard
rounded
items={statsItems}
gridAutoColumns="3fr 8fr"
/>
</Section>
)}
</>
);
};
@@ -1,614 +0,0 @@
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { DELETE_APPLICATION_REGISTRATION } from '@/settings/application-registrations/graphql/mutations/deleteApplicationRegistration';
import { TRANSFER_APPLICATION_REGISTRATION_OWNERSHIP } from '@/settings/application-registrations/graphql/mutations/transferApplicationRegistrationOwnership';
import { UPDATE_APPLICATION_REGISTRATION_VARIABLE } from '@/settings/application-registrations/graphql/mutations/updateApplicationRegistrationVariable';
import { APPLICATION_REGISTRATION_TARBALL_URL } from '@/settings/application-registrations/graphql/queries/applicationRegistrationTarballUrl';
import { FIND_APPLICATION_REGISTRATION_VARIABLES } from '@/settings/application-registrations/graphql/queries/findApplicationRegistrationVariables';
import { FIND_MANY_APPLICATION_REGISTRATIONS } from '@/settings/application-registrations/graphql/queries/findManyApplicationRegistrations';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useInstallMarketplaceApp } from '~/modules/marketplace/hooks/useInstallMarketplaceApp';
import { useMutation, useQuery } from '@apollo/client';
import { styled } from '@linaria/react';
import { Trans, useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import {
H1Title,
H1TitleFontColor,
H2Title,
IconArrowRight,
IconBox,
IconCheck,
IconDownload,
IconTag,
IconTextCaption,
IconTrash,
IconWorld,
Status,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section, SectionAlignment, SectionFontColor } from 'twenty-ui/layout';
import {
ApplicationRegistrationSourceType,
useFindManyApplicationsQuery,
useUninstallApplicationMutation,
} from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
StyledAppModal,
StyledAppModalButton,
StyledAppModalSection,
StyledAppModalTitle,
} from '~/pages/settings/applications/components/SettingsAppModalLayout';
import { type ApplicationRegistrationData } from '~/pages/settings/applications/tabs/types/ApplicationRegistrationData';
const DELETE_REGISTRATION_MODAL_ID = 'delete-application-registration-modal';
const TRANSFER_OWNERSHIP_MODAL_ID =
'transfer-application-registration-ownership-modal';
const UNINSTALL_APPLICATION_MODAL_ID =
'uninstall-application-from-registration-modal';
const StyledVariableRow = styled.div`
align-items: center;
border-bottom: 1px solid ${themeCssVariables.border.color.light};
display: flex;
gap: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[2]} 0;
`;
const StyledVariableInfo = styled.div`
display: flex;
flex: 1;
flex-direction: column;
gap: ${themeCssVariables.spacing['0.5']};
`;
const StyledVariableKey = styled.span`
color: ${themeCssVariables.font.color.primary};
font-family: monospace;
font-weight: ${themeCssVariables.font.weight.medium};
`;
const StyledVariableDescription = styled.span`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.sm};
`;
const StyledDangerButtonGroup = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledSourceRow = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledDownloadLink = styled.a`
color: ${themeCssVariables.font.color.secondary};
cursor: pointer;
text-decoration: underline;
&:hover {
color: ${themeCssVariables.font.color.primary};
}
`;
const StyledButtonGroup = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[2]};
padding-top: ${themeCssVariables.spacing[2]};
`;
type ServerVariable = {
id: string;
key: string;
description: string;
isSecret: boolean;
isRequired: boolean;
isFilled: boolean;
};
export const SettingsApplicationRegistrationGeneralTab = ({
registration,
hasActiveInstalls,
}: {
registration: ApplicationRegistrationData;
hasActiveInstalls: boolean;
}) => {
const { t } = useLingui();
const navigate = useNavigateSettings();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { openModal, closeModal } = useModal();
const [isInstalling, setIsInstalling] = useState(false);
const [isUninstalling, setIsUninstalling] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isTransferring, setIsTransferring] = useState(false);
const [transferSubdomain, setTransferSubdomain] = useState('');
const [variableValues, setVariableValues] = useState<Record<string, string>>(
{},
);
const applicationRegistrationId = registration.id;
const { data: variablesData } = useQuery(
FIND_APPLICATION_REGISTRATION_VARIABLES,
{
variables: { applicationRegistrationId },
skip: !applicationRegistrationId,
},
);
const { data: tarballUrlData } = useQuery(
APPLICATION_REGISTRATION_TARBALL_URL,
{
variables: { id: applicationRegistrationId },
skip: !applicationRegistrationId,
},
);
const [deleteRegistration] = useMutation(DELETE_APPLICATION_REGISTRATION, {
refetchQueries: [FIND_MANY_APPLICATION_REGISTRATIONS],
});
const [updateVariable] = useMutation(
UPDATE_APPLICATION_REGISTRATION_VARIABLE,
{
refetchQueries: [FIND_APPLICATION_REGISTRATION_VARIABLES],
},
);
const [transferOwnership] = useMutation(
TRANSFER_APPLICATION_REGISTRATION_OWNERSHIP,
{
refetchQueries: [FIND_MANY_APPLICATION_REGISTRATIONS],
},
);
const { install } = useInstallMarketplaceApp();
const [uninstallApplication] = useUninstallApplicationMutation();
const { data: applicationsData, refetch: refetchApplications } =
useFindManyApplicationsQuery();
const variables: ServerVariable[] =
variablesData?.findApplicationRegistrationVariables ?? [];
const isNpmSource =
registration.sourceType === ApplicationRegistrationSourceType.NPM;
const installedApp = (applicationsData?.findManyApplications ?? []).find(
(application) =>
application.universalIdentifier === registration.universalIdentifier,
);
const isInstalledOnWorkspace = isDefined(installedApp);
const installedAppUrl = isInstalledOnWorkspace
? getSettingsPath(SettingsPath.ApplicationDetail, {
applicationId: installedApp.id,
})
: undefined;
const handleInstallOnWorkspace = async () => {
setIsInstalling(true);
try {
const success = await install({
universalIdentifier: registration.universalIdentifier,
});
if (success) {
await refetchApplications();
enqueueSuccessSnackBar({
message: t`App installed on this workspace`,
});
}
} catch {
enqueueErrorSnackBar({
message: t`Error installing app`,
});
} finally {
setIsInstalling(false);
}
};
const handleUninstallFromWorkspace = async () => {
setIsUninstalling(true);
try {
await uninstallApplication({
variables: {
universalIdentifier: registration.universalIdentifier,
},
});
await refetchApplications();
enqueueSuccessSnackBar({
message: t`Application uninstalled from this workspace`,
});
} catch {
enqueueErrorSnackBar({
message: t`Error uninstalling application`,
});
} finally {
setIsUninstalling(false);
}
};
const handleDelete = async () => {
setIsLoading(true);
try {
await deleteRegistration({
variables: { id: applicationRegistrationId },
});
navigate(SettingsPath.Applications);
} catch {
enqueueErrorSnackBar({
message: t`Error deleting app`,
});
} finally {
setIsLoading(false);
}
};
const handleTransferOwnership = async () => {
const trimmed = transferSubdomain.trim();
if (!isNonEmptyString(trimmed)) {
return;
}
setIsTransferring(true);
try {
await transferOwnership({
variables: {
applicationRegistrationId,
targetWorkspaceSubdomain: trimmed,
},
});
enqueueSuccessSnackBar({
message: t`Ownership transferred successfully`,
});
setTransferSubdomain('');
navigate(SettingsPath.Applications);
} catch {
enqueueErrorSnackBar({
message: t`Failed to transfer ownership. Check that the subdomain is correct.`,
});
} finally {
setIsTransferring(false);
}
};
const handleSaveVariableValue = async (variable: ServerVariable) => {
const value = variableValues[variable.id];
const variableKey = variable.key;
if (!isNonEmptyString(value)) {
return;
}
try {
await updateVariable({
variables: {
input: {
id: variable.id,
update: {
value,
},
},
},
});
setVariableValues((previous) => {
const next = { ...previous };
delete next[variable.id];
return next;
});
enqueueSuccessSnackBar({
message: t`Variable ${variableKey} updated`,
});
} catch {
enqueueErrorSnackBar({
message: t`Error updating variable`,
});
}
};
const confirmationValue = t`yes`;
const generalItems = [
{
Icon: IconTag,
label: t`Name`,
value: registration.name,
},
...(isNonEmptyString(registration.description)
? [
{
Icon: IconTextCaption,
label: t`Description`,
value: registration.description,
},
]
: []),
{
Icon: IconWorld,
label: t`Universal ID`,
value: registration.universalIdentifier,
},
...(isNpmSource && isNonEmptyString(registration.sourcePackage)
? [
{
Icon: IconBox,
label: t`Package`,
value: registration.sourcePackage,
},
]
: registration.sourceType === ApplicationRegistrationSourceType.TARBALL
? [
{
Icon: IconBox,
label: t`Source`,
value: isNonEmptyString(
tarballUrlData?.applicationRegistrationTarballUrl,
) ? (
<StyledSourceRow>
<span>
<Trans>Tarball upload</Trans>
</span>
<StyledDownloadLink
href={tarballUrlData.applicationRegistrationTarballUrl}
download
>
<Trans>Download</Trans>
</StyledDownloadLink>
</StyledSourceRow>
) : (
t`Tarball upload`
),
},
]
: registration.sourceType === ApplicationRegistrationSourceType.LOCAL
? [
{
Icon: IconBox,
label: t`Source`,
value: t`Local development`,
},
]
: []),
];
return (
<>
<Section>
<H2Title
title={t`General`}
description={t`Name and description are managed via your app manifest (CLI)`}
/>
<SettingsAdminTableCard
rounded
items={generalItems}
gridAutoColumns="3fr 8fr"
/>
</Section>
<Section>
<H2Title
title={t`Installation`}
description={
isInstalledOnWorkspace
? t`This app is installed on the current workspace`
: t`Install this app on the current workspace`
}
/>
{isInstalledOnWorkspace ? (
<StyledButtonGroup>
<Button
Icon={IconArrowRight}
title={t`View installed app`}
variant="primary"
accent="blue"
to={installedAppUrl}
/>
<Button
Icon={IconTrash}
title={t`Uninstall`}
variant="secondary"
accent="danger"
onClick={() => openModal(UNINSTALL_APPLICATION_MODAL_ID)}
/>
</StyledButtonGroup>
) : (
<Button
Icon={IconDownload}
title={
isInstalling ? t`Installing...` : t`Install on this workspace`
}
variant="secondary"
accent="blue"
disabled={isInstalling}
onClick={handleInstallOnWorkspace}
/>
)}
</Section>
<ConfirmationModal
confirmationPlaceholder={confirmationValue}
confirmationValue={confirmationValue}
modalInstanceId={UNINSTALL_APPLICATION_MODAL_ID}
title={t`Uninstall Application?`}
subtitle={
<Trans>
This will remove the application and all its data from this
workspace. Please type {`"${confirmationValue}"`} to confirm.
</Trans>
}
onConfirmClick={handleUninstallFromWorkspace}
confirmButtonText={t`Uninstall`}
loading={isUninstalling}
/>
{variables.length > 0 && (
<Section>
<H2Title
title={t`Server Variables`}
description={t`Variables declared by the app manifest. Fill in values here — they apply to all workspace installations.`}
/>
{variables.map((variable) => (
<StyledVariableRow key={variable.id}>
<StyledVariableInfo>
<StyledVariableKey>
{variable.key}
{variable.isRequired && (
<span style={{ color: 'red' }}> *</span>
)}
</StyledVariableKey>
{isNonEmptyString(variable.description) && (
<StyledVariableDescription>
{variable.description}
</StyledVariableDescription>
)}
</StyledVariableInfo>
{variable.isFilled &&
!isNonEmptyString(variableValues[variable.id]) && (
<Status color="green" text={t`Configured`} />
)}
{!variable.isFilled &&
!isNonEmptyString(variableValues[variable.id]) && (
<Status
color={variable.isRequired ? 'red' : 'gray'}
text={variable.isRequired ? t`Required` : t`Not set`}
/>
)}
<SettingsTextInput
instanceId={`var-${variable.id}`}
value={variableValues[variable.id] ?? ''}
onChange={(value) =>
setVariableValues((previous) => ({
...previous,
[variable.id]: value,
}))
}
placeholder={
variable.isSecret ? t`Enter secret value` : t`Enter value`
}
fullWidth
/>
<Button
Icon={IconCheck}
variant="secondary"
size="small"
disabled={!isNonEmptyString(variableValues[variable.id])}
onClick={() => handleSaveVariableValue(variable)}
/>
</StyledVariableRow>
))}
</Section>
)}
<Section>
<H2Title
title={t`Danger zone`}
description={
hasActiveInstalls
? t`Uninstall this app from all workspaces before deleting it`
: t`Delete or transfer this app registration`
}
/>
<StyledDangerButtonGroup>
<Button
accent="danger"
variant="secondary"
title={t`Delete`}
Icon={IconTrash}
disabled={hasActiveInstalls}
onClick={() => openModal(DELETE_REGISTRATION_MODAL_ID)}
/>
<Button
accent="default"
variant="secondary"
title={t`Transfer ownership`}
Icon={IconArrowRight}
onClick={() => openModal(TRANSFER_OWNERSHIP_MODAL_ID)}
/>
</StyledDangerButtonGroup>
</Section>
<ConfirmationModal
confirmationPlaceholder={confirmationValue}
confirmationValue={confirmationValue}
modalInstanceId={DELETE_REGISTRATION_MODAL_ID}
title={t`Delete app`}
subtitle={
<Trans>
Please type {`"${confirmationValue}"`} to confirm you want to delete
this app. All workspace installations linked to it will lose their
OAuth credentials.
</Trans>
}
onConfirmClick={handleDelete}
confirmButtonText={t`Delete`}
loading={isLoading}
/>
<StyledAppModal
modalId={TRANSFER_OWNERSHIP_MODAL_ID}
isClosable
onClose={() => setTransferSubdomain('')}
padding="large"
dataGloballyPreventClickOutside
>
<StyledAppModalTitle>
<H1Title
title={t`Transfer ownership`}
fontColor={H1TitleFontColor.Primary}
/>
</StyledAppModalTitle>
<StyledAppModalSection
alignment={SectionAlignment.Center}
fontColor={SectionFontColor.Primary}
>
{t`Enter the workspace subdomain to transfer this app to. You will lose access to manage it.`}
</StyledAppModalSection>
<Section>
<SettingsTextInput
instanceId="transfer-ownership-subdomain"
value={transferSubdomain}
onChange={setTransferSubdomain}
placeholder={t`e.g. my-workspace`}
fullWidth
disableHotkeys
label={t`Target workspace subdomain`}
autoFocusOnMount
/>
</Section>
<StyledAppModalButton
onClick={() => {
closeModal(TRANSFER_OWNERSHIP_MODAL_ID);
setTransferSubdomain('');
}}
variant="secondary"
title={t`Cancel`}
fullWidth
/>
<StyledAppModalButton
onClick={handleTransferOwnership}
variant="secondary"
accent="danger"
title={t`Transfer`}
disabled={
!isNonEmptyString(transferSubdomain.trim()) || isTransferring
}
fullWidth
/>
</StyledAppModal>
</>
);
};
@@ -1,298 +0,0 @@
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { ROTATE_APPLICATION_REGISTRATION_CLIENT_SECRET } from '@/settings/application-registrations/graphql/mutations/rotateApplicationRegistrationClientSecret';
import { UPDATE_APPLICATION_REGISTRATION } from '@/settings/application-registrations/graphql/mutations/updateApplicationRegistration';
import { FIND_MANY_APPLICATION_REGISTRATIONS } from '@/settings/application-registrations/graphql/queries/findManyApplicationRegistrations';
import { FIND_ONE_APPLICATION_REGISTRATION } from '@/settings/application-registrations/graphql/queries/findOneApplicationRegistration';
import { ApiKeyInput } from '@/settings/developers/components/ApiKeyInput';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useMutation } from '@apollo/client';
import { styled } from '@linaria/react';
import { Trans, useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import {
H2Title,
IconKey,
IconRefresh,
IconShield,
IconTrash,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { applicationRegistrationClientSecretFamilyState } from '~/pages/settings/applications/states/applicationRegistrationClientSecretFamilyState';
import { type ApplicationRegistrationData } from '~/pages/settings/applications/tabs/types/ApplicationRegistrationData';
import { isValidUrl } from 'twenty-shared/utils';
const ROTATE_SECRET_MODAL_ID = 'rotate-application-registration-secret-modal';
const StyledInputContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
gap: ${themeCssVariables.spacing[2]};
width: 100%;
`;
const StyledRedirectUriRow = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[1]} 0;
`;
const StyledRedirectUriValue = styled.span`
color: ${themeCssVariables.font.color.primary};
font-family: monospace;
word-break: break-all;
`;
const StyledRotateContainer = styled.div`
padding-top: ${themeCssVariables.spacing[2]};
`;
const StyledSaveContainer = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[2]};
padding-top: ${themeCssVariables.spacing[2]};
`;
export const SettingsApplicationRegistrationOAuthTab = ({
registration,
}: {
registration: ApplicationRegistrationData;
}) => {
const { t } = useLingui();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { openModal } = useModal();
const applicationRegistrationId = registration.id;
const applicationRegistrationClientSecret = useAtomFamilyStateValue(
applicationRegistrationClientSecretFamilyState,
applicationRegistrationId,
);
const [isLoading, setIsLoading] = useState(false);
const [rotatedSecret, setRotatedSecret] = useState<string | null>(null);
const [formRedirectUris, setFormRedirectUris] = useState<string[]>(
registration.oAuthRedirectUris ?? [],
);
const [newRedirectUri, setNewRedirectUri] = useState('');
const [hasChanges, setHasChanges] = useState(false);
const [updateRegistration] = useMutation(UPDATE_APPLICATION_REGISTRATION, {
refetchQueries: [
FIND_ONE_APPLICATION_REGISTRATION,
FIND_MANY_APPLICATION_REGISTRATIONS,
],
});
const [rotateSecret] = useMutation(
ROTATE_APPLICATION_REGISTRATION_CLIENT_SECRET,
);
const markDirty = () => setHasChanges(true);
const handleSave = async () => {
setIsLoading(true);
try {
await updateRegistration({
variables: {
input: {
id: applicationRegistrationId,
update: {
oAuthRedirectUris: formRedirectUris,
},
},
},
});
setHasChanges(false);
enqueueSuccessSnackBar({ message: t`Redirect URIs updated` });
} catch {
enqueueErrorSnackBar({ message: t`Error updating redirect URIs` });
} finally {
setIsLoading(false);
}
};
const handleCancel = () => {
setFormRedirectUris(registration.oAuthRedirectUris ?? []);
setHasChanges(false);
};
const handleRotateSecret = async () => {
setIsLoading(true);
try {
const result = await rotateSecret({
variables: { id: applicationRegistrationId },
});
const secret =
result.data?.rotateApplicationRegistrationClientSecret?.clientSecret;
if (isNonEmptyString(secret)) {
setRotatedSecret(secret);
enqueueSuccessSnackBar({
message: t`Client secret rotated. Copy it now — it won't be shown again.`,
});
}
} catch {
enqueueErrorSnackBar({
message: t`Error rotating client secret`,
});
} finally {
setIsLoading(false);
}
};
const handleAddRedirectUri = () => {
const trimmed = newRedirectUri.trim();
if (!trimmed) {
return;
}
if (!isValidUrl(trimmed)) {
enqueueErrorSnackBar({ message: t`Please enter a valid URL` });
return;
}
if (formRedirectUris.includes(trimmed)) {
enqueueErrorSnackBar({ message: t`This redirect URI is already added` });
return;
}
setFormRedirectUris([...formRedirectUris, trimmed]);
setNewRedirectUri('');
markDirty();
};
const handleRemoveRedirectUri = (index: number) => {
setFormRedirectUris(
formRedirectUris.filter((_, uriIndex) => uriIndex !== index),
);
markDirty();
};
const displayedSecret = rotatedSecret ?? applicationRegistrationClientSecret;
const confirmationValue = t`yes`;
const credentialItems = [
{
Icon: IconKey,
label: t`Client ID`,
value: registration.oAuthClientId,
},
{
Icon: IconShield,
label: t`Scopes`,
value: (registration.oAuthScopes ?? []).join(', ') || '—',
},
];
return (
<>
<Section>
<H2Title
title={t`OAuth Credentials`}
description={t`Credentials and scopes for OAuth authorization flows`}
/>
<SettingsAdminTableCard
rounded
items={credentialItems}
gridAutoColumns="3fr 8fr"
/>
<StyledRotateContainer>
<Button
Icon={IconRefresh}
title={t`Rotate client secret`}
variant="secondary"
onClick={() => openModal(ROTATE_SECRET_MODAL_ID)}
/>
</StyledRotateContainer>
</Section>
{displayedSecret && (
<Section>
<H2Title
title={t`Client Secret`}
description={t`Copy this secret as it will not be visible again`}
/>
<ApiKeyInput apiKey={displayedSecret} />
</Section>
)}
<Section>
<H2Title
title={t`Redirect URIs`}
description={t`Allowed redirect URIs for OAuth flows`}
/>
{formRedirectUris.map((uri, index) => (
<StyledRedirectUriRow key={`${uri}-${index}`}>
<StyledRedirectUriValue>{uri}</StyledRedirectUriValue>
<Button
Icon={IconTrash}
variant="tertiary"
size="medium"
onClick={() => handleRemoveRedirectUri(index)}
/>
</StyledRedirectUriRow>
))}
<StyledInputContainer>
<SettingsTextInput
instanceId="application-registration-new-redirect-uri"
value={newRedirectUri}
onChange={setNewRedirectUri}
placeholder={t`https://example.com/callback`}
fullWidth
/>
<Button
title={t`Add`}
variant="secondary"
size="medium"
onClick={handleAddRedirectUri}
/>
</StyledInputContainer>
{hasChanges && (
<StyledSaveContainer>
<Button
title={t`Save`}
variant="primary"
accent="blue"
onClick={handleSave}
disabled={isLoading}
/>
<Button
title={t`Cancel`}
variant="secondary"
onClick={handleCancel}
/>
</StyledSaveContainer>
)}
</Section>
<ConfirmationModal
confirmationPlaceholder={confirmationValue}
confirmationValue={confirmationValue}
modalInstanceId={ROTATE_SECRET_MODAL_ID}
title={t`Rotate client secret`}
subtitle={
<Trans>
If you rotate this secret, any integration using the current secret
will stop working. Please type {`"${confirmationValue}"`} to
confirm.
</Trans>
}
onConfirmClick={handleRotateSecret}
confirmButtonText={t`Rotate secret`}
loading={isLoading}
/>
</>
);
};
@@ -2,6 +2,10 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe
import { FIND_MANY_APPLICATION_REGISTRATIONS } from '@/settings/application-registrations/graphql/queries/findManyApplicationRegistrations';
import { SettingsListCard } from '@/settings/components/SettingsListCard';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
@@ -14,21 +18,30 @@ import {
CommandBlock,
H2Title,
IconApps,
IconChevronDown,
IconChevronRight,
IconCopy,
IconDownload,
IconFileInfo,
IconUpload,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Button, ButtonGroup, IconButton } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { useContext } from 'react';
import { MenuItem } from 'twenty-ui/navigation';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import {
REGISTER_NPM_APP_MODAL_ID,
SettingsRegisterNpmAppModal,
} from '~/pages/settings/applications/components/SettingsRegisterNpmAppModal';
import {
SettingsUploadTarballModal,
UPLOAD_TARBALL_MODAL_ID,
} from '~/pages/settings/applications/components/SettingsUploadTarballModal';
const REGISTER_APP_DROPDOWN_ID = 'register-app-dropdown';
const StyledButtonContainer = styled.div`
margin: ${themeCssVariables.spacing[2]} 0;
`;
@@ -51,6 +64,7 @@ export const SettingsApplicationsDeveloperTab = () => {
const navigate = useNavigate();
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const { openModal } = useModal();
const { closeDropdown } = useCloseDropdown();
const { copyToClipboard } = useCopyToClipboard();
@@ -76,6 +90,16 @@ export const SettingsApplicationsDeveloperTab = () => {
/>
);
const handleRegisterFromNpm = () => {
closeDropdown(REGISTER_APP_DROPDOWN_ID);
openModal(REGISTER_NPM_APP_MODAL_ID);
};
const handleUploadTarball = () => {
closeDropdown(REGISTER_APP_DROPDOWN_ID);
openModal(UPLOAD_TARBALL_MODAL_ID);
};
return (
<>
<Section>
@@ -128,15 +152,43 @@ export const SettingsApplicationsDeveloperTab = () => {
)}
</Section>
<StyledButtonGroupContainer>
<Button
Icon={IconUpload}
title={t`Upload tarball`}
size="small"
variant="secondary"
onClick={() => openModal(UPLOAD_TARBALL_MODAL_ID)}
/>
<ButtonGroup size="small" variant="secondary">
<Button
Icon={IconDownload}
title={t`Register from npm`}
onClick={handleRegisterFromNpm}
/>
<Dropdown
dropdownId={REGISTER_APP_DROPDOWN_ID}
clickableComponent={
<IconButton
size="small"
variant="secondary"
Icon={IconChevronDown}
position="right"
/>
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItem
LeftIcon={IconDownload}
text={t`Register from npm`}
onClick={handleRegisterFromNpm}
/>
<MenuItem
LeftIcon={IconUpload}
text={t`Upload tarball`}
onClick={handleUploadTarball}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
</ButtonGroup>
</StyledButtonGroupContainer>
<SettingsRegisterNpmAppModal />
<SettingsUploadTarballModal />
</>
);
@@ -1,15 +0,0 @@
import type { ApplicationRegistrationSourceType } from '~/generated-metadata/graphql';
export type ApplicationRegistrationData = {
id: string;
name: string;
description: string;
universalIdentifier: string;
sourceType: ApplicationRegistrationSourceType;
sourcePackage?: string | null;
isListed: boolean;
isFeatured: boolean;
oAuthClientId: string;
oAuthScopes?: string[] | null;
oAuthRedirectUris?: string[] | null;
};
-18
View File
@@ -54,9 +54,6 @@ Commands:
auth:switch Switch the default workspace
auth:list List all configured workspaces
app:dev Watch and sync local application changes
app:generate-client Build, sync to local server, and generate the typed API client
app:build Build the application (no server needed)
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
@@ -133,21 +130,6 @@ Application development commands.
- 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.
- `twenty app:generate-client [appPath]` — One-shot build, sync to local server, and generate the typed API client. Requires a running local server.
- `twenty app:build [appPath]` — Build the application into `.twenty/output/`. No server needed.
- Options:
- `--tarball`: Also pack the output into a `.tgz` tarball.
- `twenty app:publish [appPath]` — Build and publish the application.
- Default (no flags): builds and runs `npm publish` on the output directory.
- Options:
- `--server <url>`: Publish to a Twenty server instead of npm (builds tarball, uploads, and installs).
- `--token <token>`: Auth token for the server.
- `--tag <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 app:uninstall [appPath]` — Uninstall the application from the current workspace.
@@ -1,7 +1,7 @@
import { resolve } from 'path';
import { vi } from 'vitest';
import { appGenerateClient } from '@/cli/public-operations/app-generate-client';
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 { ADD_NUMBERS_UNIVERSAL_IDENTIFIER } from '../src/logic-functions/add-numbers.function';
@@ -10,15 +10,15 @@ const APP_PATH = resolve(__dirname, '../');
describe('functionExecute E2E', () => {
beforeAll(async () => {
const generateResult = await appGenerateClient({ appPath: APP_PATH });
const buildResult = await appBuild({ appPath: APP_PATH });
if (!generateResult.success) {
if (!buildResult.success) {
throw new Error(
`appGenerateClient failed: ${generateResult.error.code} ${generateResult.error.message}`,
`appBuild failed: ${buildResult.error.code} ${buildResult.error.message}`,
);
}
// Although appGenerateClient uploads files before syncing the manifest, the server
// Although appBuild uploads files before syncing the manifest, the server
// may need a moment to make them readable by the execution engine.
// Retry a dummy execution until the handler file becomes available.
await vi.waitFor(
@@ -7,13 +7,10 @@ export const defineEntitiesTests = (appPath: string): void => {
describe('logicFunctions', () => {
it('should have built logicFunctions preserving source path structure', async () => {
const files = await readdir(outputDir, { recursive: true });
// api-client is generated post-sync and depends on server schema availability
const sortedFiles = files
.map((f) => f.toString())
.filter((f) => !f.startsWith('api-client'))
.sort();
const sortedFiles = files.map((f) => f.toString()).sort();
expect(sortedFiles).toEqual([
'api-client',
'manifest.json',
'package.json',
'public',
@@ -19,7 +19,7 @@ export const runAppDevInProcess = async (options: {
const command = new AppDevCommand();
await command.execute({ appPath, headless: true });
await command.execute({ appPath });
const startTime = Date.now();
@@ -2,6 +2,10 @@ import { vi } from 'vitest';
const mockApiService = {
validateAuth: vi.fn().mockResolvedValue({ authValid: true, serverUp: true }),
findOneApplication: vi.fn().mockResolvedValue({ success: true, data: null }),
createApplication: vi
.fn()
.mockResolvedValue({ success: true, data: { id: 'mock-id' } }),
generateApplicationToken: vi.fn().mockResolvedValue({
success: true,
data: {
@@ -35,10 +39,6 @@ const mockApiService = {
clientSecret: 'mock-client-secret',
},
}),
createDevelopmentApplication: vi.fn().mockResolvedValue({
success: true,
data: { id: 'mock-app-id', universalIdentifier: 'mock-uid' },
}),
syncApplication: vi.fn().mockResolvedValue({ success: true, data: true }),
uploadFile: vi.fn().mockResolvedValue({ success: true, data: true }),
};
@@ -46,13 +46,14 @@ const mockApiService = {
vi.mock('@/cli/utilities/api/api-service', () => ({
ApiService: class {
validateAuth = mockApiService.validateAuth;
findOneApplication = mockApiService.findOneApplication;
createApplication = mockApiService.createApplication;
generateApplicationToken = mockApiService.generateApplicationToken;
renewApplicationToken = mockApiService.renewApplicationToken;
findApplicationRegistrationByUniversalIdentifier =
mockApiService.findApplicationRegistrationByUniversalIdentifier;
createApplicationRegistration =
mockApiService.createApplicationRegistration;
createDevelopmentApplication = mockApiService.createDevelopmentApplication;
syncApplication = mockApiService.syncApplication;
uploadFile = mockApiService.uploadFile;
},
@@ -2,9 +2,9 @@ import { formatPath } from '@/cli/utilities/file/file-path';
import chalk from 'chalk';
import type { Command } from 'commander';
import { AppBuildCommand } from './app/app-build';
import { AppGenerateClientCommand } from './app/app-generate-client';
import { AppDevCommand } from './app/app-dev';
import { AppPublishCommand } from './app/app-publish';
import { AppPackCommand } from './app/app-pack';
import { AppPushCommand } from './app/app-push';
import { AppTypecheckCommand } from './app/app-typecheck';
import { AppUninstallCommand } from './app/app-uninstall';
import { AuthListCommand } from './auth/auth-list';
@@ -64,15 +64,24 @@ export const registerCommands = (program: Command): void => {
// App commands
const buildCommand = new AppBuildCommand();
const generateClientCommand = new AppGenerateClientCommand();
const devCommand = new AppDevCommand();
const publishCommand = new AppPublishCommand();
const packCommand = new AppPackCommand();
const pushCommand = new AppPushCommand();
const typecheckCommand = new AppTypecheckCommand();
const uninstallCommand = new AppUninstallCommand();
const addCommand = new EntityAddCommand();
const logsCommand = new LogicFunctionLogsCommand();
const executeCommand = new LogicFunctionExecuteCommand();
program
.command('app:build [appPath]')
.description('Build the application without watching for changes')
.action(async (appPath) => {
await buildCommand.execute({
appPath: formatPath(appPath),
});
});
program
.command('app:dev [appPath]')
.description('Watch and sync local application changes')
@@ -82,47 +91,6 @@ export const registerCommands = (program: Command): void => {
});
});
program
.command('app:generate-client [appPath]')
.description(
'Build, sync to local server, and generate the typed API client',
)
.action(async (appPath) => {
await generateClientCommand.execute({
appPath: formatPath(appPath),
});
});
program
.command('app:build [appPath]')
.description(
'Build the application into .twenty/output/ (no server needed)',
)
.option('--tarball', 'Also pack into a .tgz tarball')
.action(async (appPath, options) => {
await buildCommand.execute({
appPath: formatPath(appPath),
tarball: options.tarball,
});
});
program
.command('app:publish [appPath]')
.description(
'Build and publish to npm, or to a Twenty server with --server',
)
.option('--server <url>', 'Publish to a Twenty server instead of npm')
.option('--token <token>', 'Auth token for the server')
.option('--tag <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]')
.description('Run TypeScript type checking on the application')
@@ -148,6 +116,28 @@ export const registerCommands = (program: Command): void => {
}
});
program
.command('app:pack [appPath]')
.description('Build and pack the application into a .tgz tarball')
.action(async (appPath) => {
await packCommand.execute({ appPath: formatPath(appPath) });
});
program
.command('app:push [appPath]')
.description(
'Build, upload, and install a local application on a Twenty server (for air-gapped/dev deployments)',
)
.option('--server <url>', 'Twenty server URL')
.option('--token <token>', 'Auth token for the server')
.action(async (appPath, options) => {
await pushCommand.execute({
appPath: formatPath(appPath),
server: options.server,
token: options.token,
});
});
program
.command('entity:add [entityType]')
.option('--path <path>', 'Path in which the entity should be created.')
@@ -4,20 +4,18 @@ import chalk from 'chalk';
export type AppBuildCommandOptions = {
appPath?: string;
tarball?: boolean;
};
export class AppBuildCommand {
async execute(options: AppBuildCommandOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
console.log(chalk.blue('Building application...'));
console.log(chalk.blue('Building and syncing application...'));
console.log(chalk.gray(`App path: ${appPath}`));
console.log('');
const result = await appBuild({
appPath,
tarball: options.tarball,
onProgress: (message) => console.log(chalk.gray(message)),
});
@@ -28,13 +26,8 @@ export class AppBuildCommand {
console.log(
chalk.green(
`✓ Build succeeded (${result.data.fileCount} file${result.data.fileCount === 1 ? '' : 's'})`,
`✓ Build and sync succeeded (${result.data.fileCount} file${result.data.fileCount === 1 ? '' : 's'})`,
),
);
console.log(chalk.gray(`Output: ${result.data.outputDir}`));
if (result.data.tarballPath) {
console.log(chalk.gray(`Tarball: ${result.data.tarballPath}`));
}
}
}
@@ -6,7 +6,6 @@ import { OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orc
export type AppDevOptions = {
appPath?: string;
headless?: boolean;
};
export class AppDevCommand {
@@ -30,25 +29,20 @@ export class AppDevCommand {
frontendUrl: process.env.FRONTEND_URL,
});
if (!options.headless) {
const uiStateManager = new DevUiStateManager(orchestratorState);
const uiStateManager = new DevUiStateManager(orchestratorState);
orchestratorState.onChange = () => uiStateManager.notify();
orchestratorState.onChange = () => uiStateManager.notify();
const { unmount } = await renderDevUI(uiStateManager);
const { unmount } = await renderDevUI(uiStateManager);
this.unmountUI = unmount;
}
this.unmountUI = unmount;
this.orchestrator = new DevModeOrchestrator({
state: orchestratorState,
});
await this.orchestrator.start();
if (!options.headless) {
this.setupGracefulShutdown();
}
this.setupGracefulShutdown();
}
private setupGracefulShutdown(): void {
@@ -1,33 +0,0 @@
import { appGenerateClient } from '@/cli/public-operations/app-generate-client';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import chalk from 'chalk';
export type AppGenerateClientCommandOptions = {
appPath?: string;
};
export class AppGenerateClientCommand {
async execute(options: AppGenerateClientCommandOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
console.log(chalk.blue('Generating API client...'));
console.log(chalk.gray(`App path: ${appPath}`));
console.log('');
const result = await appGenerateClient({
appPath,
onProgress: (message) => console.log(chalk.gray(message)),
});
if (!result.success) {
console.error(chalk.red(result.error.message));
process.exit(1);
}
console.log(
chalk.green(
`✓ Client generated (${result.data.fileCount} file${result.data.fileCount === 1 ? '' : 's'})`,
),
);
}
}
@@ -0,0 +1,30 @@
import { appPack } from '@/cli/public-operations/app-pack';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import chalk from 'chalk';
export type AppPackCommandOptions = {
appPath?: string;
};
export class AppPackCommand {
async execute(options: AppPackCommandOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
console.log(chalk.blue('Building and packing application...'));
console.log(chalk.gray(`App path: ${appPath}`));
console.log('');
const result = await appPack({
appPath,
onProgress: (message) => console.log(chalk.gray(message)),
});
if (!result.success) {
console.error(chalk.red(result.error.message));
process.exit(1);
}
console.log(chalk.green('✓ Application packed successfully'));
console.log(chalk.gray(`Tarball: ${result.data.tarballPath}`));
}
}
@@ -1,48 +0,0 @@
import { appPublish } from '@/cli/public-operations/app-publish';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import chalk from 'chalk';
export type AppPublishCommandOptions = {
appPath?: string;
server?: string;
token?: string;
tag?: string;
};
export class AppPublishCommand {
async execute(options: AppPublishCommandOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
const isServerPublish = !!options.server;
console.log(
chalk.blue(
isServerPublish
? `Publishing to server ${options.server}...`
: '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)),
});
if (!result.success) {
console.error(chalk.red(result.error.message));
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'),
);
}
}
}
@@ -0,0 +1,62 @@
import { appPack } from '@/cli/public-operations/app-pack';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import { ApiService } from '@/cli/utilities/api/api-service';
import chalk from 'chalk';
import fs from 'fs';
export type AppPushCommandOptions = {
appPath?: string;
server?: string;
token?: string;
};
export class AppPushCommand {
async execute(options: AppPushCommandOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
console.log(chalk.blue('Building, packing, and pushing application...'));
console.log(chalk.gray(`App path: ${appPath}`));
console.log('');
const packResult = await appPack({
appPath,
onProgress: (message) => console.log(chalk.gray(message)),
});
if (!packResult.success) {
console.error(chalk.red(packResult.error.message));
process.exit(1);
}
const { tarballPath } = packResult.data;
console.log(chalk.gray(`Uploading ${tarballPath}...`));
const tarballBuffer = fs.readFileSync(tarballPath);
const apiService = new ApiService({
serverUrl: options.server,
token: options.token,
});
const uploadResult = await apiService.uploadAppTarball({ tarballBuffer });
if (!uploadResult.success) {
console.error(chalk.red(`Upload failed: ${uploadResult.error}`));
process.exit(1);
}
console.log(chalk.gray('Installing application...'));
const installResult = await apiService.installTarballApp({
universalIdentifier: uploadResult.data.universalIdentifier,
});
if (!installResult.success) {
console.error(chalk.red(`Install failed: ${installResult.error}`));
process.exit(1);
}
console.log(chalk.green('✓ Application pushed and installed successfully'));
}
}
@@ -1,25 +1,18 @@
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 { 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 { runSafe } from '@/cli/utilities/run-safe';
import { APP_ERROR_CODES, type CommandResult } from './types';
export type AppBuildOptions = {
appPath: string;
tarball?: boolean;
onProgress?: (message: string) => void;
};
export type AppBuildResult = {
outputDir: string;
fileCount: number;
tarballPath?: string;
};
const innerAppBuild = async (
@@ -46,27 +39,33 @@ const innerAppBuild = async (
for (const warning of manifestResult.warnings) {
onProgress?.(`${warning}`);
}
const clientService = new ClientService();
await clientService.ensureGeneratedClientStub({ appPath });
onProgress?.('Building application files...');
const buildResult = await buildApplication({
const firstBuildResult = await buildApplication({
appPath,
manifest,
filePaths,
});
onProgress?.('Updating manifest checksums...');
onProgress?.('Syncing application schema...');
const updatedManifest = manifestUpdateChecksums({
const firstSyncResult = await synchronizeBuiltApplication({
appPath,
manifest,
builtFileInfos: buildResult.builtFileInfos,
builtFileInfos: firstBuildResult.builtFileInfos,
});
await writeManifestToOutput(appPath, updatedManifest);
if (!firstSyncResult.success) {
return firstSyncResult;
}
onProgress?.('Generating API client...');
await clientService.generate({ appPath });
onProgress?.('Running typecheck...');
@@ -87,30 +86,35 @@ const innerAppBuild = async (
};
}
const outputDir = path.join(appPath, '.twenty', 'output');
onProgress?.('Rebuilding with generated client...');
const result: AppBuildResult = {
outputDir,
fileCount: buildResult.builtFileInfos.size,
};
const finalBuildResult = await buildApplication({
appPath,
manifest,
filePaths,
});
if (options.tarball) {
onProgress?.('Packing tarball...');
onProgress?.('Syncing built files...');
const packOutput = execSync('npm pack --pack-destination .', {
cwd: outputDir,
encoding: 'utf-8',
}).trim();
const finalSyncResult = await synchronizeBuiltApplication({
appPath,
manifest,
builtFileInfos: finalBuildResult.builtFileInfos,
});
const tarballName = packOutput.split('\n').pop()!;
result.tarballPath = path.join(outputDir, tarballName);
if (!finalSyncResult.success) {
return finalSyncResult;
}
return { success: true, data: result };
return {
success: true,
data: {
fileCount: finalBuildResult.builtFileInfos.size,
},
};
};
export const appBuild = (
options: AppBuildOptions,
): Promise<CommandResult<AppBuildResult>> =>
runSafe(() => innerAppBuild(options), APP_ERROR_CODES.BUILD_FAILED);
runSafe(() => innerAppBuild(options), APP_ERROR_CODES.SYNC_FAILED);
@@ -1,100 +0,0 @@
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 './types';
export type AppGenerateClientOptions = {
appPath: string;
onProgress?: (message: string) => void;
};
export type AppGenerateClientResult = {
fileCount: number;
};
const innerAppGenerateClient = async (
options: AppGenerateClientOptions,
): Promise<CommandResult<AppGenerateClientResult>> => {
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}`);
}
const clientService = new ClientService();
await clientService.ensureGeneratedClientStub({ appPath });
onProgress?.('Building application files...');
const buildResult = await buildApplication({
appPath,
manifest,
filePaths,
});
onProgress?.('Syncing application schema...');
const syncResult = await synchronizeBuiltApplication({
appPath,
manifest,
builtFileInfos: buildResult.builtFileInfos,
});
if (!syncResult.success) {
return syncResult;
}
onProgress?.('Generating API client...');
await clientService.generate({ 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')}`,
},
};
}
return {
success: true,
data: {
fileCount: buildResult.builtFileInfos.size,
},
};
};
export const appGenerateClient = (
options: AppGenerateClientOptions,
): Promise<CommandResult<AppGenerateClientResult>> =>
runSafe(() => innerAppGenerateClient(options), APP_ERROR_CODES.SYNC_FAILED);
@@ -0,0 +1,39 @@
import { execSync } from 'child_process';
import path from 'path';
import { appBuild, type AppBuildOptions } from './app-build';
import { runSafe } from '@/cli/utilities/run-safe';
import { APP_ERROR_CODES, type CommandResult } from './types';
export type AppPackResult = {
tarballPath: string;
};
const innerAppPack = async (
options: AppBuildOptions,
): Promise<CommandResult<AppPackResult>> => {
const buildResult = await appBuild(options);
if (!buildResult.success) {
return buildResult;
}
options.onProgress?.('Packing tarball...');
const outputDir = path.join(options.appPath, '.twenty', 'output');
const packOutput = execSync('npm pack --pack-destination .', {
cwd: outputDir,
encoding: 'utf-8',
}).trim();
const tarballName = packOutput.split('\n').pop()!;
const tarballPath = path.join(outputDir, tarballName);
return { success: true, data: { tarballPath } };
};
export const appPack = (
options: AppBuildOptions,
): Promise<CommandResult<AppPackResult>> =>
runSafe(() => innerAppPack(options), APP_ERROR_CODES.SYNC_FAILED);
@@ -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<CommandResult<AppPublishResult>> => {
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<CommandResult<AppPublishResult>> => {
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<CommandResult<AppPublishResult>> => {
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<CommandResult<AppPublishResult>> =>
runSafe(() => innerAppPublish(options), APP_ERROR_CODES.PUBLISH_FAILED);
@@ -25,7 +25,7 @@ const innerAppUninstall = async (
error: {
code: APP_ERROR_CODES.MANIFEST_NOT_FOUND,
message:
'Manifest not found. Run `app:build`, `app:generate-client`, or `app:dev` first.',
'Manifest not found. Run `app:build` or `app:dev` to generate it first.',
},
};
}
@@ -62,7 +62,7 @@ const innerFunctionExecute = async (
error: {
code: APP_ERROR_CODES.MANIFEST_NOT_FOUND,
message:
'Manifest not found. Run `app:build`, `app:generate-client`, or `app:dev` first.',
'Manifest not found. Run `app:build` or `app:dev` to generate it first.',
},
};
}
@@ -7,13 +7,8 @@ export type { AuthLogoutOptions } from './auth-logout';
// App
export { appBuild } from './app-build';
export type { AppBuildOptions, AppBuildResult } from './app-build';
export { appGenerateClient } from './app-generate-client';
export type {
AppGenerateClientOptions,
AppGenerateClientResult,
} from './app-generate-client';
export { appPublish } from './app-publish';
export type { AppPublishOptions, AppPublishResult } from './app-publish';
export { appPack } from './app-pack';
export type { AppPackResult } from './app-pack';
export { appUninstall } from './app-uninstall';
export type { AppUninstallOptions } from './app-uninstall';
@@ -17,8 +17,6 @@ export const AUTH_ERROR_CODES = {
export const APP_ERROR_CODES = {
MANIFEST_NOT_FOUND: 'MANIFEST_NOT_FOUND',
MANIFEST_BUILD_FAILED: 'MANIFEST_BUILD_FAILED',
BUILD_FAILED: 'BUILD_FAILED',
PUBLISH_FAILED: 'PUBLISH_FAILED',
UNINSTALL_FAILED: 'UNINSTALL_FAILED',
SYNC_FAILED: 'SYNC_FAILED',
TYPECHECK_FAILED: 'TYPECHECK_FAILED',
@@ -101,6 +101,61 @@ export class ApiService {
}
}
async findOneApplication(
universalIdentifier: string,
): Promise<ApiResponse<{ id: string; universalIdentifier: string } | null>> {
try {
const query = `
query FindOneApplication($universalIdentifier: UUID!) {
findOneApplication(universalIdentifier: $universalIdentifier) {
id
universalIdentifier
}
}
`;
const response = await this.client.post(
'/metadata',
{
query,
variables: { universalIdentifier },
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
const isNotFound = response.data.errors.some(
(error: { extensions?: { code?: string } }) =>
error.extensions?.code === 'NOT_FOUND',
);
if (isNotFound) {
return { success: true, data: null };
}
return {
success: false,
error: response.data.errors[0],
};
}
return {
success: true,
data: response.data.data.findOneApplication,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async generateApplicationToken(applicationId: string): Promise<
ApiResponse<{
applicationAccessToken: { token: string; expiresAt: string };
@@ -328,25 +383,40 @@ export class ApiService {
}
}
async createDevelopmentApplication(input: {
universalIdentifier: string;
name: string;
}): Promise<ApiResponse<{ id: string; universalIdentifier: string }>> {
async createApplication(
manifest: Manifest,
options?: { applicationRegistrationId?: string },
): Promise<ApiResponse<{ id: string; universalIdentifier: string }>> {
try {
const mutation = `
mutation CreateDevelopmentApplication($universalIdentifier: String!, $name: String!) {
createDevelopmentApplication(universalIdentifier: $universalIdentifier, name: $name) {
mutation CreateOneApplication($input: CreateApplicationInput!) {
createOneApplication(input: $input) {
id
universalIdentifier
}
}
`;
const response = await this.client.post(
const input: Record<string, string> = {
universalIdentifier: manifest.application.universalIdentifier,
name: manifest.application.displayName,
version: '0.0.1',
sourcePath: 'cli-sync',
};
if (options?.applicationRegistrationId) {
input.applicationRegistrationId = options.applicationRegistrationId;
}
const variables = {
input,
};
const response: AxiosResponse = await this.client.post(
'/metadata',
{
query: mutation,
variables: input,
variables,
},
{
headers: {
@@ -365,7 +435,8 @@ export class ApiService {
return {
success: true,
data: response.data.data.createDevelopmentApplication,
data: response.data.data.createOneApplication,
message: `Successfully create application: ${manifest.application.displayName}`,
};
} catch (error) {
return {
@@ -0,0 +1,80 @@
import { type ApiService } from '@/cli/utilities/api/api-service';
import { serializeError } from '@/cli/utilities/error/serialize-error';
import { type Manifest } from 'twenty-shared/application';
export type EnsureApplicationSuccess = {
success: true;
applicationId: string;
universalIdentifier: string;
created: boolean;
};
export type EnsureApplicationFailure = {
success: false;
error: string;
};
export type EnsureApplicationResult =
| EnsureApplicationSuccess
| EnsureApplicationFailure;
export const findOrCreateApplication = async ({
apiService,
manifest,
applicationRegistrationId,
}: {
apiService: ApiService;
manifest: Manifest;
applicationRegistrationId?: string;
}): Promise<EnsureApplicationResult> => {
const universalIdentifier = manifest.application.universalIdentifier;
const findResult = await apiService.findOneApplication(universalIdentifier);
if (!findResult.success) {
return {
success: false,
error: `Failed to resolve application: ${serializeError(findResult.error)}`,
};
}
if (findResult.data) {
return {
success: true,
applicationId: findResult.data.id,
universalIdentifier: findResult.data.universalIdentifier,
created: false,
};
}
let registrationId = applicationRegistrationId;
if (!registrationId) {
const registerResult =
await apiService.findApplicationRegistrationByUniversalIdentifier(
universalIdentifier,
);
if (registerResult.success && registerResult.data) {
registrationId = registerResult.data.id;
}
}
const createResult = await apiService.createApplication(manifest, {
applicationRegistrationId: registrationId,
});
if (!createResult.success) {
return {
success: false,
error: `Failed to create application: ${serializeError(createResult.error)}`,
};
}
return {
success: true,
applicationId: createResult.data!.id,
universalIdentifier: createResult.data!.universalIdentifier,
created: true,
};
};
@@ -3,6 +3,7 @@ import {
type CommandResult,
} from '@/cli/public-operations/types';
import { ApiService } from '@/cli/utilities/api/api-service';
import { findOrCreateApplication } from '@/cli/utilities/application/find-or-create-application';
import { type BuiltFileInfo } from '@/cli/utilities/build/common/build-application';
import { manifestUpdateChecksums } from '@/cli/utilities/build/manifest/manifest-update-checksums';
import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer';
@@ -15,62 +16,6 @@ export type AppSyncOptions = {
workspace?: string;
};
const ensureApplicationRegistrationExists = async (
apiService: ApiService,
manifest: Manifest,
): Promise<CommandResult> => {
const universalIdentifier = manifest.application.universalIdentifier;
const findResult =
await apiService.findApplicationRegistrationByUniversalIdentifier(
universalIdentifier,
);
if (findResult.success && findResult.data) {
return { success: true, data: undefined };
}
const createResult = await apiService.createApplicationRegistration({
name: manifest.application.displayName,
description: manifest.application.description,
universalIdentifier,
});
if (!createResult.success) {
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message: `Failed to create application registration: ${serializeError(createResult.error)}`,
},
};
}
return { success: true, data: undefined };
};
const ensureDevelopmentApplicationExists = async (
apiService: ApiService,
manifest: Manifest,
): Promise<CommandResult> => {
const result = await apiService.createDevelopmentApplication({
universalIdentifier: manifest.application.universalIdentifier,
name: manifest.application.displayName,
});
if (!result.success) {
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message: `Failed to create development application: ${serializeError(result.error)}`,
},
};
}
return { success: true, data: undefined };
};
export const synchronizeBuiltApplication = async ({
appPath,
manifest,
@@ -81,40 +26,37 @@ export const synchronizeBuiltApplication = async ({
builtFileInfos: Map<string, BuiltFileInfo>;
}): Promise<CommandResult> => {
const apiService = new ApiService();
const ensureResult = await findOrCreateApplication({
apiService,
manifest,
});
if (!ensureResult.success) {
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message: ensureResult.error,
},
};
}
const universalIdentifier = manifest.application.universalIdentifier;
const registrationResult = await ensureApplicationRegistrationExists(
apiService,
manifest,
);
if (!registrationResult.success) {
return registrationResult;
}
const applicationResult = await ensureDevelopmentApplicationExists(
apiService,
manifest,
);
if (!applicationResult.success) {
return applicationResult;
}
const fileUploader = new FileUploader({
applicationUniversalIdentifier: universalIdentifier,
appPath,
});
const uploadResults = await Promise.all(
[...builtFileInfos.values()].map((fileInfo) =>
fileUploader.uploadFile({
builtPath: fileInfo.builtPath,
fileFolder: fileInfo.fileFolder,
}),
),
const uploadPromises = Array.from(builtFileInfos.values()).map((fileInfo) =>
fileUploader.uploadFile({
builtPath: fileInfo.builtPath,
fileFolder: fileInfo.fileFolder,
}),
);
const uploadResults = await Promise.all(uploadPromises);
const failedUploads = uploadResults.filter((result) => !result.success);
if (failedUploads.length > 0) {
@@ -1,6 +1,7 @@
import { type EntityFilePaths } from '@/cli/utilities/build/manifest/manifest-extract-config';
import { type BuildManifestOrchestratorStepOutput } from '@/cli/utilities/dev/orchestrator/steps/build-manifest-orchestrator-step';
import { type CheckServerOrchestratorStepOutput } from '@/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step';
import { type ResolveApplicationOrchestratorStepOutput } from '@/cli/utilities/dev/orchestrator/steps/resolve-application-orchestrator-step';
import { type StartWatchersOrchestratorStepOutput } from '@/cli/utilities/dev/orchestrator/steps/start-watchers-orchestrator-step';
import { type SyncApplicationOrchestratorStepOutput } from '@/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step';
import { type UploadFilesOrchestratorStepOutput } from '@/cli/utilities/dev/orchestrator/steps/upload-files-orchestrator-step';
@@ -95,10 +96,7 @@ export class OrchestratorState {
steps: {
checkServer: OrchestratorStepState<CheckServerOrchestratorStepOutput>;
ensureValidTokens: OrchestratorStepState<Record<string, never>>;
resolveApplication: OrchestratorStepState<{
applicationId: string | null;
universalIdentifier: string | null;
}>;
resolveApplication: OrchestratorStepState<ResolveApplicationOrchestratorStepOutput>;
buildManifest: OrchestratorStepState<BuildManifestOrchestratorStepOutput>;
uploadFiles: OrchestratorStepState<UploadFilesOrchestratorStepOutput>;
generateApiClient: OrchestratorStepState<Record<string, never>>;
@@ -7,6 +7,7 @@ import { CheckServerOrchestratorStep } from '@/cli/utilities/dev/orchestrator/st
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 { ResolveApplicationOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/resolve-application-orchestrator-step';
import {
StartWatchersOrchestratorStep,
type FileBuiltEvent,
@@ -29,13 +30,13 @@ export class DevModeOrchestrator {
private syncTimer: NodeJS.Timeout | null = null;
private serverCheckInterval: NodeJS.Timeout | null = null;
private apiService: ApiService;
private clientService: ClientService;
private skipTypecheck = true;
private checkServerStep: CheckServerOrchestratorStep;
private ensureValidTokensStep: EnsureValidTokensOrchestratorStep;
private buildManifestStep: BuildManifestOrchestratorStep;
private registerAppStep: RegisterAppOrchestratorStep;
private resolveApplicationStep: ResolveApplicationOrchestratorStep;
private uploadFilesStep: UploadFilesOrchestratorStep;
private generateApiClientStep: GenerateApiClientOrchestratorStep;
private syncApplicationStep: SyncApplicationOrchestratorStep;
@@ -45,8 +46,7 @@ export class DevModeOrchestrator {
this.debounceMs = options.debounceMs ?? 200;
this.state = options.state;
this.apiService = new ApiService({ disableInterceptors: true });
const apiService = this.apiService;
const apiService = new ApiService({ disableInterceptors: true });
const configService = new ConfigService();
this.clientService = new ClientService();
const stepDeps = { state: this.state, notify: () => this.state.notify() };
@@ -66,6 +66,10 @@ export class DevModeOrchestrator {
apiService,
configService,
});
this.resolveApplicationStep = new ResolveApplicationOrchestratorStep({
...stepDeps,
apiService,
});
this.uploadFilesStep = new UploadFilesOrchestratorStep(stepDeps);
this.generateApiClientStep = new GenerateApiClientOrchestratorStep({
...stepDeps,
@@ -219,37 +223,20 @@ export class DevModeOrchestrator {
}
private async initializePipeline(manifest: Manifest): Promise<boolean> {
await this.registerAppStep.execute({ manifest });
const registerResult = await this.registerAppStep.execute({ manifest });
const createResult = await this.apiService.createDevelopmentApplication({
universalIdentifier: manifest.application.universalIdentifier,
name: manifest.application.displayName,
const resolveResult = await this.resolveApplicationStep.execute({
manifest,
applicationRegistrationId:
registerResult.applicationRegistrationId ?? undefined,
});
if (!createResult.success || !createResult.data) {
this.state.applyStepEvents([
{
message: 'Failed to create development application',
status: 'error',
},
]);
this.state.updatePipeline({ status: 'error' });
if (!resolveResult.applicationId) {
return false;
}
this.state.steps.resolveApplication.output = {
applicationId: createResult.data.id,
universalIdentifier: createResult.data.universalIdentifier,
};
this.state.steps.resolveApplication.status = 'done';
this.state.applyStepEvents([
{ message: 'Application created', status: 'success' },
]);
await this.ensureValidTokensStep.exchangeTokens({
applicationId: createResult.data.id,
applicationId: resolveResult.applicationId,
});
this.uploadFilesStep.initialize({
@@ -0,0 +1,74 @@
import { type ApiService } from '@/cli/utilities/api/api-service';
import { findOrCreateApplication } from '@/cli/utilities/application/find-or-create-application';
import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
import { type Manifest } from 'twenty-shared/application';
export type ResolveApplicationOrchestratorStepOutput = {
applicationId: string | null;
universalIdentifier: string | null;
};
export class ResolveApplicationOrchestratorStep {
private apiService: ApiService;
private state: OrchestratorState;
private notify: () => void;
constructor({
apiService,
state,
notify,
}: {
apiService: ApiService;
state: OrchestratorState;
notify: () => void;
}) {
this.apiService = apiService;
this.state = state;
this.notify = notify;
}
async execute(input: {
manifest: Manifest;
applicationRegistrationId?: string;
}): Promise<ResolveApplicationOrchestratorStepOutput> {
const step = this.state.steps.resolveApplication;
step.status = 'in_progress';
this.notify();
const result = await findOrCreateApplication({
apiService: this.apiService,
manifest: input.manifest,
applicationRegistrationId: input.applicationRegistrationId,
});
if (!result.success) {
this.state.applyStepEvents([
{
message: result.error,
status: 'error',
},
]);
step.status = 'error';
this.state.updatePipeline({ status: 'error' });
return step.output;
}
if (result.created) {
this.state.applyStepEvents([
{ message: 'Creating application', status: 'info' },
{ message: 'Application created', status: 'success' },
]);
}
step.output = {
applicationId: result.applicationId,
universalIdentifier: result.universalIdentifier,
};
step.status = 'done';
this.notify();
return step.output;
}
}
@@ -24,8 +24,6 @@ import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/appli
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationInput } from 'src/engine/core-modules/application/application-development/dtos/application.input';
import { CreateDevelopmentApplicationInput } from 'src/engine/core-modules/application/application-development/dtos/create-development-application.input';
import { DevelopmentApplicationDTO } from 'src/engine/core-modules/application/application-development/dtos/development-application.dto';
import { GenerateApplicationTokenInput } from 'src/engine/core-modules/application/application-development/dtos/generate-application-token.input';
import { UploadApplicationFileInput } from 'src/engine/core-modules/application/application-development/dtos/upload-application-file.input';
import { WorkspaceMigrationDTO } from 'src/engine/core-modules/application/application-development/dtos/workspace-migration.dto';
@@ -66,44 +64,6 @@ export class ApplicationDevelopmentResolver {
private readonly fileStorageService: FileStorageService,
) {}
@Mutation(() => DevelopmentApplicationDTO)
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async createDevelopmentApplication(
@Args() { universalIdentifier, name }: CreateDevelopmentApplicationInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<DevelopmentApplicationDTO> {
const applicationRegistrationId = await this.findApplicationRegistrationId(
universalIdentifier,
workspaceId,
);
const existing = await this.applicationService.findByUniversalIdentifier({
universalIdentifier,
workspaceId,
});
if (existing) {
return {
id: existing.id,
universalIdentifier: existing.universalIdentifier,
};
}
const application = await this.applicationService.create({
universalIdentifier,
name,
sourcePath: universalIdentifier,
sourceType: ApplicationRegistrationSourceType.LOCAL,
applicationRegistrationId,
workspaceId,
});
return {
id: application.id,
universalIdentifier: application.universalIdentifier,
};
}
@Mutation(() => ApplicationTokenPairDTO)
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async generateApplicationToken(
@@ -127,6 +87,13 @@ export class ApplicationDevelopmentResolver {
workspaceId,
);
await this.ensureApplicationExists({
universalIdentifier: manifest.application.universalIdentifier,
name: manifest.application.displayName,
workspaceId,
applicationRegistrationId,
});
const workspaceMigration =
await this.applicationSyncService.synchronizeFromManifest({
workspaceId,
@@ -256,4 +223,29 @@ export class ApplicationDevelopmentResolver {
}
}
}
private async ensureApplicationExists(params: {
universalIdentifier: string;
name: string;
workspaceId: string;
applicationRegistrationId: string;
}): Promise<void> {
const existing = await this.applicationService.findByUniversalIdentifier({
universalIdentifier: params.universalIdentifier,
workspaceId: params.workspaceId,
});
if (existing) {
return;
}
await this.applicationService.create({
universalIdentifier: params.universalIdentifier,
name: params.name,
sourcePath: params.universalIdentifier,
sourceType: ApplicationRegistrationSourceType.LOCAL,
applicationRegistrationId: params.applicationRegistrationId,
workspaceId: params.workspaceId,
});
}
}
@@ -0,0 +1,36 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
@InputType()
export class CreateApplicationInput {
@IsString()
@IsNotEmpty()
@Field()
universalIdentifier: string;
@IsString()
@IsNotEmpty()
@Field()
name: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
description?: string;
@IsString()
@IsNotEmpty()
@Field()
version: string;
@IsString()
@IsNotEmpty()
@Field()
sourcePath: string;
@IsUUID()
@IsOptional()
@Field({ nullable: true })
applicationRegistrationId?: string;
}
@@ -1,10 +0,0 @@
import { ArgsType, Field } from '@nestjs/graphql';
@ArgsType()
export class CreateDevelopmentApplicationInput {
@Field(() => String)
universalIdentifier: string;
@Field(() => String)
name: string;
}
@@ -1,10 +0,0 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('DevelopmentApplication')
export class DevelopmentApplicationDTO {
@Field(() => String)
id: string;
@Field(() => String)
universalIdentifier: string;
}
@@ -0,0 +1,134 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import axios from 'axios';
import { Repository } from 'typeorm';
import { z } from 'zod';
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
const npmPackageMetadataSchema = z.object({
version: z.string(),
});
@Injectable()
export class ApplicationUpgradeService {
private readonly logger = new Logger(ApplicationUpgradeService.name);
constructor(
@InjectRepository(ApplicationRegistrationEntity)
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
private readonly applicationInstallService: ApplicationInstallService,
private readonly twentyConfigService: TwentyConfigService,
) {}
async checkForUpdates(
appRegistration: ApplicationRegistrationEntity,
): Promise<string | null> {
if (appRegistration.sourceType !== ApplicationRegistrationSourceType.NPM) {
return null;
}
const registryUrl = this.twentyConfigService.get('APP_REGISTRY_URL');
if (!appRegistration.sourcePackage) {
return null;
}
try {
const encodedPackage = encodeURIComponent(appRegistration.sourcePackage);
const { data } = await axios.get(
`${registryUrl}/${encodedPackage}/latest`,
{
headers: { 'User-Agent': 'Twenty-AppUpgrade' },
timeout: 10_000,
},
);
const parsed = npmPackageMetadataSchema.safeParse(data);
if (!parsed.success) {
this.logger.warn(
`Unexpected response shape from registry for ${appRegistration.sourcePackage}`,
);
return null;
}
await this.appRegistrationRepository.update(appRegistration.id, {
latestAvailableVersion: parsed.data.version,
});
return parsed.data.version;
} catch (error) {
this.logger.warn(
`Failed to check updates for ${appRegistration.sourcePackage}: ${error}`,
);
return null;
}
}
async checkAllForUpdates(): Promise<void> {
const npmRegistrations = await this.appRegistrationRepository.find({
where: { sourceType: ApplicationRegistrationSourceType.NPM },
});
for (const registration of npmRegistrations) {
await this.checkForUpdates(registration);
}
}
async upgradeApplication(params: {
appRegistrationId: string;
targetVersion: string;
workspaceId: string;
}): Promise<boolean> {
const appRegistration = await this.appRegistrationRepository.findOneOrFail({
where: { id: params.appRegistrationId },
});
if (
appRegistration.sourceType === ApplicationRegistrationSourceType.LOCAL ||
appRegistration.sourceType === ApplicationRegistrationSourceType.TARBALL
) {
throw new ApplicationException(
'Cannot upgrade an app installed from a tarball or local source',
ApplicationExceptionCode.UPGRADE_FAILED,
);
}
try {
return await this.applicationInstallService.installApplication({
appRegistrationId: params.appRegistrationId,
version: params.targetVersion,
workspaceId: params.workspaceId,
});
} catch (error) {
const appName =
appRegistration.sourcePackage ?? appRegistration.universalIdentifier;
this.logger.error(`Upgrade failed for ${appName}`, error);
if (error instanceof ApplicationException) {
throw error;
}
throw new ApplicationException(
`Upgrade failed for ${appName}`,
ApplicationExceptionCode.UPGRADE_FAILED,
);
}
}
}
@@ -0,0 +1,37 @@
import { Injectable, Logger } from '@nestjs/common';
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
import { APPLICATION_VERSION_CHECK_CRON_PATTERN } from 'src/engine/core-modules/application/application-upgrade/crons/constants/application-version-check-cron-pattern.constant';
import { ApplicationUpgradeService } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.service';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
@Injectable()
@Processor(MessageQueue.cronQueue)
export class ApplicationVersionCheckCronJob {
private readonly logger = new Logger(ApplicationVersionCheckCronJob.name);
constructor(
private readonly applicationUpgradeService: ApplicationUpgradeService,
) {}
@Process(ApplicationVersionCheckCronJob.name)
@SentryCronMonitor(
ApplicationVersionCheckCronJob.name,
APPLICATION_VERSION_CHECK_CRON_PATTERN,
)
async handle(): Promise<void> {
this.logger.log('Starting application version check...');
try {
await this.applicationUpgradeService.checkAllForUpdates();
this.logger.log('Application version check completed successfully');
} catch (error) {
this.logger.error(
`Application version check failed: ${error instanceof Error ? error.message : String(error)}`,
);
throw error;
}
}
}
@@ -0,0 +1,33 @@
import { Command, CommandRunner } from 'nest-commander';
import { APPLICATION_VERSION_CHECK_CRON_PATTERN } from 'src/engine/core-modules/application/application-upgrade/crons/constants/application-version-check-cron-pattern.constant';
import { ApplicationVersionCheckCronJob } from 'src/engine/core-modules/application/application-upgrade/crons/application-version-check.cron.job';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
@Command({
name: 'cron:app-version-check',
description:
'Starts a cron job to check for app version updates on npm registries',
})
export class ApplicationVersionCheckCronCommand extends CommandRunner {
constructor(
@InjectMessageQueue(MessageQueue.cronQueue)
private readonly messageQueueService: MessageQueueService,
) {
super();
}
async run(): Promise<void> {
await this.messageQueueService.addCron<undefined>({
jobName: ApplicationVersionCheckCronJob.name,
data: undefined,
options: {
repeat: {
pattern: APPLICATION_VERSION_CHECK_CRON_PATTERN,
},
},
});
}
}
@@ -0,0 +1,2 @@
// Every 6 hours
export const APPLICATION_VERSION_CHECK_CRON_PATTERN = '0 */6 * * *';
@@ -6,6 +6,7 @@ import { ApplicationRegistrationResolver } from 'src/engine/core-modules/applica
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { ApplicationRegistrationVariableModule } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.module';
import { ApplicationTarballService } from 'src/engine/core-modules/application/application-registration/application-tarball.service';
import { ApplicationPackageModule } from 'src/engine/core-modules/application/application-package/application-package.module';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
@@ -23,6 +24,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
WorkspaceEntity,
]),
ApplicationRegistrationVariableModule,
ApplicationPackageModule,
ApplicationModule,
FeatureFlagModule,
PermissionsModule,
@@ -13,6 +13,7 @@ import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/a
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
import { CreateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration-variable/dtos/create-application-registration-variable.input';
import { UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration-variable/dtos/update-application-registration-variable.input';
import { ApplicationPackageFetcherService } from 'src/engine/core-modules/application/application-package/application-package-fetcher.service';
import { ApplicationRegistrationExceptionFilter } from 'src/engine/core-modules/application/application-registration/application-registration-exception-filter';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import {
@@ -62,6 +63,7 @@ export class ApplicationRegistrationResolver {
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
private readonly applicationTarballService: ApplicationTarballService,
private readonly applicationPackageFetcherService: ApplicationPackageFetcherService,
private readonly fileUrlService: FileUrlService,
) {}
@@ -260,6 +262,44 @@ export class ApplicationRegistrationResolver {
);
}
@UseGuards(
WorkspaceAuthGuard,
FeatureFlagGuard,
SettingsPermissionGuard(PermissionFlagType.MARKETPLACE_APPS),
)
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
@Mutation(() => ApplicationRegistrationEntity)
async registerNpmPackage(
@Args('packageName') packageName: string,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<ApplicationRegistrationEntity> {
const resolvedPackage =
await this.applicationPackageFetcherService.resolveNpmPackage(
packageName,
);
try {
const manifest = resolvedPackage.manifest.application;
return await this.applicationRegistrationService.createFromNpmPackage({
packageName,
universalIdentifier: manifest.universalIdentifier,
name: manifest.displayName,
description: manifest.description,
author: manifest.author,
logoUrl: manifest.logoUrl,
websiteUrl: manifest.websiteUrl,
termsUrl: manifest.termsUrl,
version: resolvedPackage.packageJson.version as string | undefined,
ownerWorkspaceId: workspaceId,
});
} finally {
await this.applicationPackageFetcherService.cleanupExtractedDir(
resolvedPackage.cleanupDir,
);
}
}
@UseGuards(
WorkspaceAuthGuard,
FeatureFlagGuard,
@@ -327,6 +327,62 @@ export class ApplicationRegistrationService {
await this.applicationRegistrationRepository.save(registration);
}
async createFromNpmPackage(params: {
packageName: string;
universalIdentifier: string;
name: string;
description?: string;
author?: string;
logoUrl?: string;
websiteUrl?: string;
termsUrl?: string;
version?: string;
ownerWorkspaceId: string;
}): Promise<ApplicationRegistrationEntity> {
const existingByUid = await this.findOneByUniversalIdentifier(
params.universalIdentifier,
);
if (isDefined(existingByUid)) {
throw new ApplicationRegistrationException(
`An app with universalIdentifier "${params.universalIdentifier}" is already registered`,
ApplicationRegistrationExceptionCode.UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED,
);
}
const existingByPackage =
await this.applicationRegistrationRepository.findOne({
where: { sourcePackage: params.packageName },
});
if (isDefined(existingByPackage)) {
throw new ApplicationRegistrationException(
`Package "${params.packageName}" is already registered`,
ApplicationRegistrationExceptionCode.INVALID_INPUT,
);
}
const registration = this.applicationRegistrationRepository.create({
universalIdentifier: params.universalIdentifier,
name: params.name,
description: params.description ?? null,
author: params.author ?? null,
logoUrl: params.logoUrl ?? null,
websiteUrl: params.websiteUrl ?? null,
termsUrl: params.termsUrl ?? null,
latestAvailableVersion: params.version ?? null,
sourceType: ApplicationRegistrationSourceType.NPM,
sourcePackage: params.packageName,
isListed: true,
oAuthClientId: v4(),
oAuthRedirectUris: [],
oAuthScopes: [],
ownerWorkspaceId: params.ownerWorkspaceId,
});
return this.applicationRegistrationRepository.save(registration);
}
async findManyBySourceType(
sourceType: ApplicationRegistrationSourceType,
): Promise<ApplicationRegistrationEntity[]> {
@@ -212,6 +212,12 @@ export class ApplicationService {
});
}
async createOneApplication(
data: Partial<ApplicationEntity> & { workspaceId: string },
): Promise<ApplicationEntity> {
return this.create(data);
}
async findTwentyStandardApplicationOrThrow(workspaceId: string): Promise<{
application: ApplicationEntity;
workspace: WorkspaceEntity;
@@ -2,6 +2,7 @@ import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
@@ -11,6 +12,10 @@ import { SendInvitationsDTO } from 'src/engine/core-modules/workspace-invitation
import { WorkspaceInvitation } from 'src/engine/core-modules/workspace-invitation/dtos/workspace-invitation.dto';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import {
WorkspaceException,
WorkspaceExceptionCode,
} from 'src/engine/core-modules/workspace/workspace.exception';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
@@ -57,6 +62,13 @@ export class WorkspaceInvitationResolver {
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUser() user: AuthContextUser,
) {
if (!isWorkspaceActiveOrSuspended(workspace)) {
throw new WorkspaceException(
'Workspace is not ready yet. Please complete workspace activation first.',
WorkspaceExceptionCode.WORKSPACE_NOT_READY,
);
}
const authContext = buildSystemAuthContext(workspace.id);
const workspaceMember =
@@ -97,6 +109,13 @@ export class WorkspaceInvitationResolver {
@AuthUser() user: AuthContextUser,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<SendInvitationsDTO> {
if (!isWorkspaceActiveOrSuspended(workspace)) {
throw new WorkspaceException(
'Workspace is not ready yet. Please complete workspace activation first.',
WorkspaceExceptionCode.WORKSPACE_NOT_READY,
);
}
const authContext = buildSystemAuthContext(workspace.id);
const workspaceMember =
@@ -13,6 +13,7 @@ export enum WorkspaceExceptionCode {
WORKSPACE_CUSTOM_DOMAIN_DISABLED = 'WORKSPACE_CUSTOM_DOMAIN_DISABLED',
ENVIRONMENT_VAR_NOT_ENABLED = 'ENVIRONMENT_VAR_NOT_ENABLED',
CUSTOM_DOMAIN_NOT_FOUND = 'CUSTOM_DOMAIN_NOT_FOUND',
WORKSPACE_NOT_READY = 'WORKSPACE_NOT_READY',
}
const getWorkspaceExceptionUserFriendlyMessage = (
@@ -35,6 +36,8 @@ const getWorkspaceExceptionUserFriendlyMessage = (
return msg`This feature is not enabled.`;
case WorkspaceExceptionCode.CUSTOM_DOMAIN_NOT_FOUND:
return msg`Custom domain not found.`;
case WorkspaceExceptionCode.WORKSPACE_NOT_READY:
return msg`Workspace is not ready yet. Please complete workspace activation first.`;
default:
assertUnreachable(code);
}
@@ -0,0 +1,34 @@
import gql from 'graphql-tag';
export const createOneApplicationQueryFactory = ({
universalIdentifier,
name,
description,
version,
sourcePath,
}: {
universalIdentifier: string;
name: string;
description?: string;
version: string;
sourcePath: string;
}) => ({
query: gql`
mutation CreateOneApplication($input: CreateApplicationInput!) {
createOneApplication(input: $input) {
id
universalIdentifier
name
}
}
`,
variables: {
input: {
universalIdentifier,
name,
description,
version,
sourcePath,
},
},
});
@@ -0,0 +1,57 @@
import { createOneApplicationQueryFactory } from 'test/integration/metadata/suites/application/utils/create-one-application-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
type CreatedApplication = {
id: string;
universalIdentifier: string;
name: string;
};
export const createOneApplication = async ({
universalIdentifier,
name,
description,
version,
sourcePath,
expectToFail = false,
token,
}: {
universalIdentifier: string;
name: string;
description?: string;
version: string;
sourcePath: string;
expectToFail?: boolean;
token?: string;
}): CommonResponseBody<{
createOneApplication: CreatedApplication;
}> => {
const graphqlOperation = createOneApplicationQueryFactory({
universalIdentifier,
name,
description,
version,
sourcePath,
});
const response = await makeMetadataAPIRequest(graphqlOperation, token);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Create one application should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Create one application has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};