Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b7f5de3a7 | ||
|
|
629d524837 | ||
|
|
243734d29e | ||
|
|
569951ba6b |
@@ -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 };
|
||||
};
|
||||
+914
-67
File diff suppressed because it is too large
Load Diff
+112
@@ -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>
|
||||
);
|
||||
};
|
||||
-165
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
-614
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
-298
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+60
-8
@@ -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 />
|
||||
</>
|
||||
);
|
||||
|
||||
-15
@@ -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;
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
+5
-5
@@ -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(
|
||||
|
||||
+2
-5
@@ -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',
|
||||
|
||||
+1
-1
@@ -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,
|
||||
};
|
||||
};
|
||||
+23
-81
@@ -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) {
|
||||
|
||||
+2
-4
@@ -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({
|
||||
|
||||
+74
@@ -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;
|
||||
}
|
||||
}
|
||||
+32
-40
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+36
@@ -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;
|
||||
}
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
@ArgsType()
|
||||
export class CreateDevelopmentApplicationInput {
|
||||
@Field(() => String)
|
||||
universalIdentifier: string;
|
||||
|
||||
@Field(() => String)
|
||||
name: string;
|
||||
}
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('DevelopmentApplication')
|
||||
export class DevelopmentApplicationDTO {
|
||||
@Field(() => String)
|
||||
id: string;
|
||||
|
||||
@Field(() => String)
|
||||
universalIdentifier: string;
|
||||
}
|
||||
+134
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
// Every 6 hours
|
||||
export const APPLICATION_VERSION_CHECK_CRON_PATTERN = '0 */6 * * *';
|
||||
+2
@@ -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,
|
||||
|
||||
+40
@@ -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,
|
||||
|
||||
+56
@@ -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;
|
||||
|
||||
+19
@@ -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);
|
||||
}
|
||||
|
||||
+34
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
+57
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user