From f342d85317c0f20e91fd9e312f1d7de2baa9a5d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Wed, 22 Apr 2026 23:49:17 +0200 Subject: [PATCH] feat(migration): seed Exa pre-install from EXA_API_KEY env var MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-shot bridge for instances that set `EXA_API_KEY` as an env var under the old native `web-search` driver (removed in the Exa migration PR). Runs at deploy time: 1. Skips if `EXA_API_KEY` is unset. 2. Fetches the `twenty-exa` manifest from the app registry CDN and upserts the `ApplicationRegistration` (via the existing catalog-sync code path — no new registration plumbing). 3. Seeds `EXA_API_KEY` onto the registration's server variable, encrypted via `SecretEncryptionService`. Never overwrites a value already edited through the admin UI. 4. Flips `isPreInstalled=true` so new workspaces auto-install. Existing workspaces are backfilled with `install-pre-installed-apps`. Idempotent — each step no-ops when its target already exists. Safe to rerun. After a successful deploy, the env var can be dropped from infra since the key now lives on the registration row. The slow instance command hook gives us full DI, so this reuses `MarketplaceService`, `ApplicationRegistrationService`, and `SecretEncryptionService` rather than hand-rolling SQL + encryption. `InstanceCommandProviderModule` now imports the modules those services live in. Prerequisite: `twenty-sdk@2.1.0` and `twenty-exa@0.1.0` must be published on npm before this migration runs — otherwise the CDN fetch returns no manifest and the command logs a warning and exits clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...894434000-seed-exa-pre-install-from-env.ts | 131 ++++++++++++++++++ .../instance-command-provider.module.ts | 15 ++ .../instance-commands.constant.ts | 2 + 3 files changed, 148 insertions(+) create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-0/2-0-instance-command-slow-1776894434000-seed-exa-pre-install-from-env.ts diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-0/2-0-instance-command-slow-1776894434000-seed-exa-pre-install-from-env.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-0/2-0-instance-command-slow-1776894434000-seed-exa-pre-install-from-env.ts new file mode 100644 index 00000000000..503b1acf516 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-0/2-0-instance-command-slow-1776894434000-seed-exa-pre-install-from-env.ts @@ -0,0 +1,131 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { type DataSource, type QueryRunner, Repository } from 'typeorm'; + +import { MarketplaceService } from 'src/engine/core-modules/application/application-marketplace/marketplace.service'; +import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity'; +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 { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service'; +import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; +import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; +import { type SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface'; + +const EXA_PACKAGE_NAME = 'twenty-exa'; + +// One-shot bridge for instances that set `EXA_API_KEY` as an env var under +// the old web-search driver. Registers `twenty-exa` as an application, +// seeds the key onto its `ApplicationRegistrationVariable`, and flips +// `isPreInstalled=true` so the next workspace install backfills it. The +// env var can be removed from infra after deploy. Idempotent — each step +// no-ops when its target already exists. +@RegisteredInstanceCommand('2.0.0', 1776894434000, { type: 'slow' }) +@Injectable() +export class SeedExaPreInstallFromEnvSlowInstanceCommand + implements SlowInstanceCommand +{ + private readonly logger = new Logger( + SeedExaPreInstallFromEnvSlowInstanceCommand.name, + ); + + constructor( + private readonly marketplaceService: MarketplaceService, + private readonly applicationRegistrationService: ApplicationRegistrationService, + private readonly secretEncryptionService: SecretEncryptionService, + @InjectRepository(ApplicationRegistrationEntity) + private readonly applicationRegistrationRepository: Repository, + @InjectRepository(ApplicationRegistrationVariableEntity) + private readonly applicationRegistrationVariableRepository: Repository, + ) {} + + public async up(_queryRunner: QueryRunner): Promise {} + + public async down(_queryRunner: QueryRunner): Promise {} + + async runDataMigration(_dataSource: DataSource): Promise { + const apiKey = process.env.EXA_API_KEY; + + if (!apiKey || apiKey.length === 0) { + this.logger.log('EXA_API_KEY not set — skipping Exa pre-install seed.'); + + return; + } + + const packages = await this.marketplaceService.fetchAppsFromRegistry(); + const exaPackage = packages.find((pkg) => pkg.name === EXA_PACKAGE_NAME); + + if (!exaPackage) { + this.logger.warn( + `"${EXA_PACKAGE_NAME}" not found in the app registry — skipping seed. ` + + `Publish the package first, then run \`install-pre-installed-apps\` to backfill.`, + ); + + return; + } + + const manifest = await this.marketplaceService.fetchManifestFromRegistryCdn( + exaPackage.name, + exaPackage.version, + ); + + if (!manifest) { + this.logger.warn( + `Manifest not found for "${EXA_PACKAGE_NAME}@${exaPackage.version}" — skipping seed.`, + ); + + return; + } + + await this.applicationRegistrationService.upsertFromCatalog({ + universalIdentifier: manifest.application.universalIdentifier, + name: manifest.application.displayName ?? exaPackage.name, + sourceType: ApplicationRegistrationSourceType.NPM, + sourcePackage: exaPackage.name, + latestAvailableVersion: exaPackage.version, + isListed: true, + isFeatured: false, + manifest, + ownerWorkspaceId: null, + }); + + const registration = await this.applicationRegistrationRepository.findOne({ + where: { universalIdentifier: manifest.application.universalIdentifier }, + }); + + if (!registration) { + this.logger.error( + `upsertFromCatalog did not produce a registration for "${EXA_PACKAGE_NAME}".`, + ); + + return; + } + + // Fill EXA_API_KEY only when unset — never overwrite a value already + // edited via the admin UI. + const variable = + await this.applicationRegistrationVariableRepository.findOne({ + where: { + applicationRegistrationId: registration.id, + key: 'EXA_API_KEY', + }, + }); + + if (variable && variable.encryptedValue === '') { + await this.applicationRegistrationVariableRepository.update(variable.id, { + encryptedValue: this.secretEncryptionService.encrypt(apiKey), + }); + } + + if (!registration.isPreInstalled) { + await this.applicationRegistrationRepository.update(registration.id, { + isPreInstalled: true, + }); + } + + this.logger.log( + `Seeded "${EXA_PACKAGE_NAME}" registration + EXA_API_KEY + isPreInstalled=true. ` + + `Run \`install-pre-installed-apps\` to backfill existing workspaces.`, + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-command-provider.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-command-provider.module.ts index b35cc44420f..18dc81d65ae 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-command-provider.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-command-provider.module.ts @@ -1,8 +1,23 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { INSTANCE_COMMANDS } from 'src/database/commands/upgrade-version-command/instance-commands.constant'; +import { MarketplaceModule } from 'src/engine/core-modules/application/application-marketplace/marketplace.module'; +import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity'; +import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity'; +import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module'; +import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module'; @Module({ + imports: [ + TypeOrmModule.forFeature([ + ApplicationRegistrationEntity, + ApplicationRegistrationVariableEntity, + ]), + ApplicationRegistrationModule, + MarketplaceModule, + SecretEncryptionModule, + ], providers: [...INSTANCE_COMMANDS], }) export class InstanceCommandProviderModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts index 1ff661851d4..346db180fc0 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts @@ -16,6 +16,7 @@ import { AddGlobalObjectContextToCommandMenuItemAvailabilityTypeFastInstanceComm import { AddPageLayoutIdToCommandMenuItemFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1776168404836-add-page-layout-id-to-command-menu-item'; import { AddConditionalAvailabilityExpressionToPageLayoutWidgetFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1775654781000-add-conditional-availability-expression-to-page-layout-widget'; import { AddIsPreInstalledToApplicationRegistrationFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-0/2-0-instance-command-fast-1776886452831-add-is-pre-installed-to-application-registration'; +import { SeedExaPreInstallFromEnvSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-0/2-0-instance-command-slow-1776894434000-seed-exa-pre-install-from-env'; export const INSTANCE_COMMANDS = [ AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand, @@ -34,4 +35,5 @@ export const INSTANCE_COMMANDS = [ AddPageLayoutIdToCommandMenuItemFastInstanceCommand, AddConditionalAvailabilityExpressionToPageLayoutWidgetFastInstanceCommand, AddIsPreInstalledToApplicationRegistrationFastInstanceCommand, + SeedExaPreInstallFromEnvSlowInstanceCommand, ];