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:
co-authored by
Claude Opus 4.7
parent
c643f3869f
commit
f342d85317
+131
@@ -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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+15
@@ -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 {}
|
||||
|
||||
+2
@@ -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,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user