twenty/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationVersionContainer.tsx
Félix Malfait 0e89c96170
feat: add npm and tarball app distribution with upgrade mechanism (#18358)
## Summary

- **npm + tarball app distribution**: Apps can be installed from the npm
registry (public or private) or uploaded as `.tar.gz` tarballs, with
`AppRegistrationSourceType` tracking the origin
- **Upgrade mechanism**: `AppUpgradeService` checks for newer versions,
supports rollback for npm-sourced apps, and a cron job runs every 6
hours to update `latestAvailableVersion` on registrations
- **Security hardening**: Tarball extraction uses path traversal
protection, and `enableScripts: false` in `.yarnrc.yml` disables all
lifecycle scripts during `yarn install` to prevent RCE
- **Frontend**: "Install from npm" and "Upload tarball" modals, upgrade
button on app detail page, blue "Update" badge on installed apps table
when a newer version is available
- **Marketplace catalog sync**: Hourly cron job syncs a hardcoded
catalog index into `ApplicationRegistration` entities
- **Integration tests**: Coverage for install, upgrade, tarball upload,
and catalog sync flows

## Backend changes

| Area | Files |
|------|-------|
| Entity & migration | `ApplicationRegistrationEntity` (sourceType,
sourcePackage, latestAvailableVersion), `ApplicationEntity`
(applicationRegistrationId), migration |
| Services | `AppPackageResolverService`, `ApplicationInstallService`,
`AppUpgradeService`, `MarketplaceCatalogSyncService` |
| Cron jobs | `MarketplaceCatalogSyncCronJob` (hourly),
`AppVersionCheckCronJob` (every 6h) |
| REST endpoint | `AppRegistrationUploadController` — tarball upload
with secure extraction |
| Resolver | `MarketplaceResolver` — simplified `installMarketplaceApp`
(removed redundant `sourcePackage` arg) |
| Security | `.yarnrc.yml` — `enableScripts: false` to block postinstall
RCE |

## Frontend changes

| Area | Files |
|------|-------|
| Modals | `SettingsInstallNpmAppModal`, `SettingsUploadTarballModal`,
`SettingsAppModalLayout` |
| Hooks | `useUploadAppTarball`, `useInstallMarketplaceApp` (cleaned up)
|
| Upgrade UI | `SettingsApplicationVersionContainer`,
`SettingsApplicationDetailAboutTab` |
| Badge | `SettingsApplicationTableRow` — blue "Update" tag,
`SettingsApplicationsInstalledTab` — fetches registrations for version
comparison |
| Styling | Migrated to Linaria (matching main) |

## Test plan

- [ ] Install an app from npm via the "Install from npm" modal
- [ ] Upload a `.tar.gz` tarball via the "Upload tarball" modal
- [ ] Verify upgrade badge appears when `latestAvailableVersion >
version`
- [ ] Verify upgrade flow from app detail page
- [ ] Run integration tests: `app-distribution.integration-spec.ts`,
`marketplace-catalog-sync.integration-spec.ts`
- [ ] Verify `enableScripts: false` blocks postinstall scripts during
yarn install


Made with [Cursor](https://cursor.com)
2026-03-05 10:34:08 +01:00

106 lines
2.9 KiB
TypeScript

import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { SettingsAdminVersionDisplay } from '@/settings/admin-panel/components/SettingsAdminVersionDisplay';
import { useUpgradeApplication } from '@/marketplace/hooks/useUpgradeApplication';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconCircleDot, IconStatusChange, IconUpload } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import {
AppRegistrationSourceType,
type Application,
} from '~/generated-metadata/graphql';
import { isNewerSemver } from '~/pages/settings/applications/utils/isNewerSemver';
export const SettingsApplicationVersionContainer = ({
application,
latestAvailableVersion,
appRegistrationId,
}: {
application?: Omit<Application, 'objects' | 'universalIdentifier'> & {
objects: { id: string }[];
};
latestAvailableVersion?: string | null;
appRegistrationId?: string | null;
}) => {
const loading = !isDefined(application);
const currentVersion = application?.version;
const sourceType = application?.applicationRegistration?.sourceType;
const isNpmApp = sourceType === AppRegistrationSourceType.NPM;
const latestVersion = isNpmApp
? (latestAvailableVersion ?? currentVersion)
: currentVersion;
const hasUpdate =
isNpmApp &&
isDefined(latestAvailableVersion) &&
isDefined(currentVersion) &&
isNewerSemver(latestAvailableVersion, currentVersion);
const { upgrade, isUpgrading } = useUpgradeApplication();
const handleUpgrade = async () => {
if (!isDefined(appRegistrationId) || !isDefined(latestAvailableVersion)) {
return;
}
await upgrade({
appRegistrationId,
targetVersion: latestAvailableVersion,
});
};
const versionItems = [
{
Icon: IconCircleDot,
label: t`Current version`,
value: (
<SettingsAdminVersionDisplay
version={currentVersion}
loading={loading}
noVersionMessage={t`Unknown`}
/>
),
},
...(isNpmApp
? [
{
Icon: IconStatusChange,
label: t`Latest version`,
value: (
<SettingsAdminVersionDisplay
version={latestVersion}
loading={loading}
noVersionMessage={t`No latest version found`}
/>
),
},
]
: []),
];
return (
<>
<SettingsAdminTableCard
rounded
items={versionItems}
gridAutoColumns="3fr 8fr"
/>
{hasUpdate && isDefined(appRegistrationId) && (
<Button
Icon={IconUpload}
title={
isUpgrading
? t`Upgrading...`
: t`Upgrade to ${latestAvailableVersion}`
}
variant="secondary"
accent="blue"
onClick={handleUpgrade}
disabled={isUpgrading}
/>
)}
</>
);
};