feat(migration): seed Exa pre-install from EXA_API_KEY env var

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) <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-04-22 23:49:17 +02:00
co-authored by Claude Opus 4.7
parent c643f3869f
commit f342d85317
3 changed files with 148 additions and 0 deletions
@@ -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<ApplicationRegistrationEntity>,
@InjectRepository(ApplicationRegistrationVariableEntity)
private readonly applicationRegistrationVariableRepository: Repository<ApplicationRegistrationVariableEntity>,
) {}
public async up(_queryRunner: QueryRunner): Promise<void> {}
public async down(_queryRunner: QueryRunner): Promise<void> {}
async runDataMigration(_dataSource: DataSource): Promise<void> {
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.`,
);
}
}
@@ -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 {}
@@ -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,
];