diff --git a/.github/actions/publish-twenty-app/action.yaml b/.github/actions/publish-twenty-app/action.yaml new file mode 100644 index 00000000000..c0cbe1f57e2 --- /dev/null +++ b/.github/actions/publish-twenty-app/action.yaml @@ -0,0 +1,37 @@ +name: Publish Twenty App +description: Build and publish a Twenty app to npm with provenance + +inputs: + npm-token: + description: npm token with publish permissions + required: true + npm-tag: + description: npm dist-tag + required: false + default: latest + app-path: + description: Path to the app directory + required: false + default: '.' + +runs: + using: composite + steps: + - uses: actions/setup-node@v4 + with: + node-version: '24' + registry-url: https://registry.npmjs.org + + - name: Install and build + shell: bash + working-directory: ${{ inputs.app-path }} + run: | + yarn install --immutable + npx twenty app:build + + - name: Publish with provenance + shell: bash + working-directory: ${{ inputs.app-path }}/.twenty/output + env: + NODE_AUTH_TOKEN: ${{ inputs.npm-token }} + run: npm publish --provenance --access public --tag ${{ inputs.npm-tag }} diff --git a/packages/create-twenty-app/src/create-app.command.ts b/packages/create-twenty-app/src/create-app.command.ts index 1e3ae4f7d85..fe38bd7a282 100644 --- a/packages/create-twenty-app/src/create-app.command.ts +++ b/packages/create-twenty-app/src/create-app.command.ts @@ -66,7 +66,7 @@ export class CreateAppCommand { name: 'name', message: 'Application name:', when: () => !directory, - default: 'my-awesome-app', + default: 'twenty-app-my-awesome-app', validate: (input) => { if (input.length === 0) return 'Application name is required'; return true; diff --git a/packages/create-twenty-app/src/utils/app-template.ts b/packages/create-twenty-app/src/utils/app-template.ts index ff42ae6ab0a..ef4a013672a 100644 --- a/packages/create-twenty-app/src/utils/app-template.ts +++ b/packages/create-twenty-app/src/utils/app-template.ts @@ -36,6 +36,8 @@ export const copyBaseApplicationProject = async ({ await createYarnLock(appDirectory); + await createPublishWorkflow(appDirectory); + const sourceFolderPath = join(appDirectory, SRC_FOLDER); await fs.ensureDir(sourceFolderPath); @@ -141,6 +143,33 @@ const createYarnLock = async (appDirectory: string) => { await fs.writeFile(join(appDirectory, 'yarn.lock'), yarnLockContent); }; + +const createPublishWorkflow = async (appDirectory: string) => { + const workflowDir = join(appDirectory, '.github', 'workflows'); + + await fs.ensureDir(workflowDir); + + const workflowContent = `name: Publish +on: + release: + types: [published] + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: twentyhq/twenty/.github/actions/publish-twenty-app@main + with: + npm-token: \${{ secrets.NPM_TOKEN }} +`; + + await fs.writeFile(join(workflowDir, 'publish.yml'), workflowContent); +}; const createGitignore = async (appDirectory: string) => { const gitignoreContent = `# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. @@ -583,10 +612,18 @@ const createPackageJson = async ({ devDependencies['vite-tsconfig-paths'] = '^4.2.1'; } + const normalizedName = appName.startsWith('twenty-app-') + ? appName + : `twenty-app-${appName}`; + const packageJson = { - name: appName, + name: normalizedName, version: '0.1.0', license: 'MIT', + keywords: ['twenty-app'], + publishConfig: { + access: 'public' as const, + }, engines: { node: '^24.5.0', npm: 'please-use-yarn', diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 0d0d50ba35b..2f2f8c791c8 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -357,6 +357,7 @@ export type ApplicationRegistration = { id: Scalars['UUID']; isFeatured: Scalars['Boolean']; isListed: Scalars['Boolean']; + isProvenanceVerified: Scalars['Boolean']; latestAvailableVersion?: Maybe; logoUrl?: Maybe; name: Scalars['String']; @@ -364,6 +365,7 @@ export type ApplicationRegistration = { oAuthRedirectUris: Array; oAuthScopes: Array; ownerWorkspaceId?: Maybe; + provenanceRepositoryUrl?: Maybe; sourcePackage?: Maybe; sourceType: ApplicationRegistrationSourceType; termsUrl?: Maybe; @@ -2535,6 +2537,7 @@ export type Mutation = { verifyEmailAndGetLoginToken: VerifyEmailAndGetLoginToken; verifyEmailAndGetWorkspaceAgnosticToken: AvailableWorkspacesAndAccessTokens; verifyEmailingDomain: EmailingDomain; + registerNpmPackage: ApplicationRegistration; verifyTwoFactorAuthenticationMethodForAuthenticatedUser: VerifyTwoFactorAuthenticationMethod; }; @@ -3478,6 +3481,11 @@ export type MutationVerifyEmailingDomainArgs = { }; +export type MutationRegisterNpmPackageArgs = { + packageName: Scalars['String']; +}; + + export type MutationVerifyTwoFactorAuthenticationMethodForAuthenticatedUserArgs = { otp: Scalars['String']; }; diff --git a/packages/twenty-front/src/pages/settings/applications/components/SettingsUploadTarballModal.tsx b/packages/twenty-front/src/pages/settings/applications/components/SettingsUploadTarballModal.tsx deleted file mode 100644 index 4d00c028b9e..00000000000 --- a/packages/twenty-front/src/pages/settings/applications/components/SettingsUploadTarballModal.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { styled } from '@linaria/react'; -import { useRef } from 'react'; - -import { FIND_MANY_APPLICATION_REGISTRATIONS } from '@/settings/application-registrations/graphql/queries/findManyApplicationRegistrations'; -import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; -import { useModal } from '@/ui/layout/modal/hooks/useModal'; -import { useApolloClient, useMutation } from '@apollo/client'; -import { useLingui } from '@lingui/react/macro'; -import { t } from '@lingui/core/macro'; -import { isDefined } from 'twenty-shared/utils'; -import { H1Title, H1TitleFontColor } from 'twenty-ui/display'; -import { SectionAlignment, SectionFontColor } from 'twenty-ui/layout'; -import { INSTALL_APPLICATION } from '~/modules/marketplace/graphql/mutations/installApplication'; -import { useUploadAppTarball } from '~/modules/marketplace/hooks/useUploadAppTarball'; -import { - StyledAppModal, - StyledAppModalButton, - StyledAppModalSection, - StyledAppModalTitle, -} from '~/pages/settings/applications/components/SettingsAppModalLayout'; - -export const UPLOAD_TARBALL_MODAL_ID = 'upload-tarball-modal'; - -const StyledFileInput = styled.input` - display: none; -`; - -export const SettingsUploadTarballModal = () => { - const { t: tFn } = useLingui(); - const { closeModal } = useModal(); - const { upload, isUploading } = useUploadAppTarball(); - const [installApplication] = useMutation(INSTALL_APPLICATION); - const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar(); - const apolloClient = useApolloClient(); - const fileInputRef = useRef(null); - - const handleSelectFile = () => { - fileInputRef.current?.click(); - }; - - const handleFileChange = async ( - event: React.ChangeEvent, - ) => { - const file = event.target.files?.[0]; - - if (!isDefined(file)) { - return; - } - - try { - const uploadResult = await upload(file); - - if (!uploadResult.success) { - return; - } - - const registrationId = uploadResult.registrationId; - - if (isDefined(registrationId)) { - try { - await installApplication({ - variables: { appRegistrationId: registrationId }, - }); - enqueueSuccessSnackBar({ - message: t`Application installed successfully.`, - }); - } catch { - enqueueErrorSnackBar({ - message: t`Tarball uploaded but installation failed.`, - }); - } - } - - await apolloClient.refetchQueries({ - include: [FIND_MANY_APPLICATION_REGISTRATIONS], - }); - closeModal(UPLOAD_TARBALL_MODAL_ID); - } finally { - if (isDefined(fileInputRef.current)) { - fileInputRef.current.value = ''; - } - } - }; - - const handleCancel = () => { - closeModal(UPLOAD_TARBALL_MODAL_ID); - }; - - return ( - - - - - - {tFn`Select a .tar.gz application package to upload and install.`} - - - - - - - - ); -}; diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationsDeveloperTab.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationsDeveloperTab.tsx index a149bc8e648..daeb17c15b0 100644 --- a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationsDeveloperTab.tsx +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationsDeveloperTab.tsx @@ -2,7 +2,6 @@ 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 { useModal } from '@/ui/layout/modal/hooks/useModal'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { styled } from '@linaria/react'; import { useLingui } from '@lingui/react/macro'; @@ -17,26 +16,27 @@ import { IconChevronRight, IconCopy, IconFileInfo, - IconUpload, } from 'twenty-ui/display'; import { Button } from 'twenty-ui/input'; import { Section } from 'twenty-ui/layout'; import { useContext } from 'react'; import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; import { useCopyToClipboard } from '~/hooks/useCopyToClipboard'; -import { - SettingsUploadTarballModal, - UPLOAD_TARBALL_MODAL_ID, -} from '~/pages/settings/applications/components/SettingsUploadTarballModal'; const StyledButtonContainer = styled.div` margin: ${themeCssVariables.spacing[2]} 0; `; -const StyledButtonGroupContainer = styled.div` +const StyledPublishMethodLabel = styled.div` + font-size: ${themeCssVariables.font.size.sm}; + color: ${themeCssVariables.font.color.light}; + margin-bottom: ${themeCssVariables.spacing[2]}; + margin-top: ${themeCssVariables.spacing[4]}; +`; + +const StyledEmptyStateContainer = styled.div` display: flex; - justify-content: flex-end; - margin-bottom: ${themeCssVariables.spacing[4]}; + flex-direction: column; `; type ApplicationRegistration = { @@ -50,7 +50,6 @@ export const SettingsApplicationsDeveloperTab = () => { const { t } = useLingui(); const navigate = useNavigate(); const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState); - const { openModal } = useModal(); const { copyToClipboard } = useCopyToClipboard(); @@ -59,23 +58,64 @@ export const SettingsApplicationsDeveloperTab = () => { const registrations: ApplicationRegistration[] = data?.findManyApplicationRegistrations ?? []; - const commands = [ + const createCommands = [ // oxlint-disable-next-line lingui/no-unlocalized-strings 'npx create-twenty-app@latest my-twenty-app', // oxlint-disable-next-line lingui/no-unlocalized-strings 'cd my-twenty-app', ]; - const copyButton = ( + const createCopyButton = (