Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ oclif.manifest.json
omnistudio_migration

assessment_reports/
migration_reports/
migration_report/
.sfdx/
package.xml
package.xml
OmnistudioDeployment.xml
clean_assessment/
89 changes: 89 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,95 @@ sf omnistudio:migration:migrate -u YOUR_ORG_USERNAME@DOMAIN.COM --only=autonumbe

5. An HTML page will be open in your default browser with the results of your migration/assessment job.

## Clean Command

Deactivates and deletes Omnistudio records (Omniscripts, Integration Procedures, Flexcards, and Data Mappers) that blocks enabling the Omnistudio Metadata API. Removes records with special characters in unique name fields or missing unique names.

> ⚠️ **Warning:** This action is permanent. Always run in a testing sandbox first and verify results before running in production.

### Prerequisites

- ✅ Org uses **standard data model** (not custom data model)
- ✅ **Metadata API not enabled** in your org
- ✅ All Omnistudio components **backed up** or deployed to testing sandbox
- ✅ Run in **testing sandbox first** to verify results

### Quick Start

```bash
# Step 1: Preview what would be cleaned (recommended)
sf omnistudio:migration:clean -u orguser@domain.com --assess

# Step 2: Review assessment reports in clean_assessment/ folder

# Step 3: Run actual cleanup (requires confirmation)
sf omnistudio:migration:clean -u orguser@domain.com
```

### Command Options

| Option | Description |
| ----------------------------- | ------------------------------------------------------- |
| `-u, --target-org=<username>` | (required) Username or alias for target org |
| `--assess` | Preview records that would be removed (no changes made) |

### Usage Examples

#### Preview Mode (Assessment)

```bash
# Preview without making changes
sf omnistudio:migration:clean -u orguser@domain.com --assess
```

**Assessment Output:**

- Reports saved to `clean_assessment/` folder
- JSON files for each component type (Omniscripts, Integration Procedures, Flexcards, Data Mappers)
- Each file contains:
- `specialCharacterRecords`: Records with special characters in unique names
- `orphanRecords`: Records without deployment references (missing unique names)
- `totalToDelete`: Total records that would be removed

#### Cleanup Mode

```bash
# Run cleanup (requires confirmation)
sf omnistudio:migration:clean -u orguser@domain.com
```

**What Happens:**

1. Validates prerequisites (standard data model, Metadata API not enabled)
2. Shows warning and prompts for confirmation
3. **Phase 1**: Deactivates and deletes records with special characters
4. **Phase 2**: Deactivates and deletes records without unique names
5. Displays completion message

### Cleanup Process

The clean command runs in two phases:

1. **Phase 1: Special Character Cleanup**

- Removes records with special characters in unique name fields
- These records are incompatible with Metadata API

2. **Phase 2: Missing Unique Name Cleanup**
- Removes records without deployment references (missing unique names)
- These records blocks enabling Metadata API

### Full Command Reference

```
USAGE
$ sf omnistudio:migration:clean -u <username> [--assess]

OPTIONS
-u, --target-org=<username> (required) username or alias for the target org
--assess preview which records would be removed without making changes
```

### Assess Usage & parameters

```
Expand Down
6 changes: 6 additions & 0 deletions command-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
"flags": ["allversions", "loglevel", "only", "relatedobjects", "target-org", "verbose"],
"alias": []
},
{
"command": "omnistudio:migration:clean",
"plugin": "@salesforce/plugin-omnistudio-migration-tool",
"flags": ["assess", "target-org", "verbose"],
"alias": []
},
{
"command": "omnistudio:migration:info",
"plugin": "@salesforce/plugin-omnistudio-migration-tool",
Expand Down
40 changes: 40 additions & 0 deletions messages/clean.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"commandDescription": "Deactivates and deletes Omnistudio records (Omniscripts, Integration Procedures, Flexcards, and Data Mappers) that prevent enabling the Omnistudio Metadata API. This includes records with special characters in unique name fields or missing unique names.\n\nWarning: This action is permanent. Run in a testing sandbox and verify results before running in your production environment.",
"examples": ["sf omnistudio:migration:clean -u orguser@domain.com"],
"enableVerboseOutput": "Show detailed information in the command output.",
"sandboxWarning": "IMPORTANT: This command permanently deletes records. Make sure that all Omnistudio components are backed up or deployed to a testing sandbox. Verify all results before enabling the Omnistudio Metadata API in your production environment.",
"confirmDeletion": "By proceeding, you confirm that you want to deactivate and permanently delete all Omnistudio records with special characters in their unique name fields or those missing a unique name. These records are incompatible with the Metadata API. Do you want to proceed?",
"operationCancelled": "Operation cancelled. No changes were made.",
"deletionComplete": "Cleanup complete. All Omnistudio records with special characters and those without a unique name have been removed.",
"standardDataModelRequired": "This command only supports orgs using the standard data model.",
"metadataApiAlreadyEnabled": "The Omnistudio metadata API is already enabled. No cleanup is required.",
"errorRunningClean": "Cleanup failed: %s",
"specialCharCleanupPhaseStart": "Phase 1: Removing records with special characters in unique name fields.",
"specialCharCleanupSectionStart": "Scanning %s records...",
"noSpecialCharRecords": "No %s records found with special characters.",
"foundSpecialCharRecordsToRemove": "Found %s %s record(s) with special characters to remove.",
"deactivatingRecords": "Deactivating %s %s record(s)...",
"deactivatedRecords": "Successfully deactivated %s %s record(s).",
"deletingRecords": "Deleting %s %s record(s)...",
"deletedRecords": "Successfully deleted %s %s record(s).",
"errorRemovingSpecialCharRecords": "Error removing %s records with special characters: %s",
"nullUniqueNameCleanupPhaseStart": "Phase 2: Removing records without a deployment reference (missing unique names) that prevent enabling the Omnistudio metadata API.",
"nullUniqueNameCleanupSectionStart": "Scanning %s records for missing unique names...",
"noNullUniqueNameRecords": "No %s records found with missing unique names.",
"foundNullUniqueNameRecords": "Found %s %s record(s) with missing unique names.",
"deactivationFailed": "Failed to deactivate %s (%s): %s",
"deletionFailed": "Failed to delete %s (%s): %s",
"errorAssessingSpecialCharRecords": "Error scanning %s records for special characters: %s",
"errorAssessingNullUniqueNameRecords": "Error scanning %s records with missing unique names: %s",
"errorCleaningNullUniqueNameRecords": "Error removing %s records with missing unique names: %s",
"assessFlagDescription": "Preview which records would be removed by the clean command, without making any changes. Results are saved to the 'clean_assessment' folder.",
"assessPhaseStart": "Starting assessment. Your org will not be modified.",
"assessSpecialCharPhaseStart": "Phase 1 (Preview): Scanning for records with special characters in unique name fields.",
"assessNullUniqueNamePhaseStart": "Phase 2 (Preview): Scanning for records without a deployment reference.",
"assessScanningEntity": "Scanning %s...",
"assessEntityFound": "Found %s %s record(s) eligible for removal.",
"assessEntityNone": "No %s records require cleanup.",
"assessmentFileWritten": "Assessment report saved to: %s",
"assessmentComplete": "Assessment complete. Review the reports in %s before running the clean command.",
"assessmentNoRecords": "Your org is clean. No records require removal."
}
162 changes: 162 additions & 0 deletions src/commands/omnistudio/migration/clean.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { Connection, Messages, Org, Logger as CoreLogger } from '@salesforce/core';
import { SfCommand, Ux, Flags as flags } from '@salesforce/sf-plugins-core';
import { Logger } from '../../../utils/logger';
import { OmnistudioOrgDetails, OrgUtils } from '../../../utils/orgUtils';
import {
initializeDataModelService,
isStandardDataModel,
isStandardDataModelWithMetadataAPIEnabled,
} from '../../../utils/dataModelService';
import { SpecialCharacterRecordCleanupService } from '../../../utils/config/SpecialCharacterRecordCleanupService';
import { ExistingRecordCleanupService } from '../../../utils/config/ExistingRecordCleanupService';
import { askConfirmation } from '../../../utils/promptUtil';

const ASSESS_OUTPUT_FOLDER = 'clean_assessment';

Messages.importMessagesDirectory(__dirname);
const messages = Messages.loadMessages('@salesforce/plugin-omnistudio-migration-tool', 'clean');

export type CleanResult = {
success: boolean;
};

interface CleanFlags {
'target-org'?: Org;
verbose?: boolean;
assess?: boolean;
}

export default class Clean extends SfCommand<CleanResult> {
public static description = messages.getMessage('commandDescription');

public static examples = messages.getMessage('examples').split(os.EOL);

public static args: any = [];

public static readonly flags: any = {
'target-org': flags.optionalOrg({
summary: 'Target org username or alias',
char: 'u',
required: true,
aliases: ['targetusername'],
deprecateAliases: true,
makeDefault: false,
}),
verbose: flags.boolean({
description: messages.getMessage('enableVerboseOutput'),
}),
assess: flags.boolean({
description: messages.getMessage('assessFlagDescription'),
default: false,
}),
};

public async run(): Promise<CleanResult> {
const { flags: parsedFlags } = await this.parse(Clean);
const ux = new Ux();
const logger = await CoreLogger.child(this.constructor.name);
Logger.initialiseLogger(ux, logger, 'clean', parsedFlags.verbose);
try {
return await this.runClean(parsedFlags as CleanFlags, ux);
} catch (e) {
const error = e as Error;
Logger.error(messages.getMessage('errorRunningClean', [error.message]));
process.exit(1);
}
}

private async runClean(parsedFlags: CleanFlags, ux: Ux): Promise<CleanResult> {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const org = parsedFlags['target-org']!;
const conn: Connection = org.getConnection();

const orgs: OmnistudioOrgDetails = await OrgUtils.getOrgDetails(conn);
initializeDataModelService(orgs);

if (!isStandardDataModel()) {
Logger.warn(messages.getMessage('standardDataModelRequired'));
return { success: false };
}

if (isStandardDataModelWithMetadataAPIEnabled()) {
Logger.log(messages.getMessage('metadataApiAlreadyEnabled'));
return { success: false };
}

if (parsedFlags.assess) {
return this.runAssess(conn, ux);
}

Logger.warn(messages.getMessage('sandboxWarning'));
const confirmed = await askConfirmation(messages.getMessage('confirmDeletion'));
if (!confirmed) {
Logger.log(messages.getMessage('operationCancelled'));
return { success: false };
}

const specialCharService = new SpecialCharacterRecordCleanupService(conn, messages, ux);
await specialCharService.deactivateAndDelete();

const existingRecordCleanupService = new ExistingRecordCleanupService(conn, messages, ux);
await existingRecordCleanupService.cleanAll();

Logger.log(messages.getMessage('deletionComplete'));
return { success: true };
}

private async runAssess(conn: Connection, ux: Ux): Promise<CleanResult> {
Logger.log(messages.getMessage('assessPhaseStart'));

const specialCharService = new SpecialCharacterRecordCleanupService(conn, messages, ux);
const existingRecordService = new ExistingRecordCleanupService(conn, messages, ux);

// Run phases sequentially so their log output does not interleave
const specialCharMap = await specialCharService.assess();
const nullUniqueNameMap = await existingRecordService.assess();

// Collect all entity names from both phases
const allEntities = new Set([...specialCharMap.keys(), ...nullUniqueNameMap.keys()]);

let totalRecords = 0;
const outputDir = path.join(process.cwd(), ASSESS_OUTPUT_FOLDER);
fs.mkdirSync(outputDir, { recursive: true });

for (const entityName of allEntities) {
const specialCharRecords = specialCharMap.get(entityName) ?? [];
const nullUniqueNameRecords = nullUniqueNameMap.get(entityName) ?? [];
const entityTotal = specialCharRecords.length + nullUniqueNameRecords.length;
totalRecords += entityTotal;

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const strip = (records: Array<Record<string, unknown>>): Array<Record<string, unknown>> =>
records.map(({ attributes: _attrs, ...rest }) => rest);

const assessment = {
component: entityName,
specialCharacterRecords: strip(specialCharRecords),
orphanRecords: strip(nullUniqueNameRecords as unknown as Array<Record<string, unknown>>),
totalToDelete: entityTotal,
};

// Use a filesystem-safe filename (remove spaces)
const fileName = `${entityName.replace(/ /g, '')}.json`;
const filePath = path.join(outputDir, fileName);
fs.writeFileSync(filePath, JSON.stringify(assessment, null, 2));
Logger.log(messages.getMessage('assessmentFileWritten', [filePath]));
}

if (totalRecords === 0) {
Logger.log(messages.getMessage('assessmentNoRecords'));
} else {
Logger.log(messages.getMessage('assessmentComplete', [outputDir]));
}

return { success: true };
}
}
13 changes: 13 additions & 0 deletions src/commands/omnistudio/migration/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export default class Migrate extends SfCommand<MigrateResult> {
}
}

// eslint-disable-next-line complexity
public async runMigration(parsedFlags: MigrateFlags, ux: Ux, logger: CoreLogger): Promise<MigrateResult> {
const migrateOnly = parsedFlags.only || '';
let allVersions = parsedFlags.allversions || false;
Expand Down Expand Up @@ -277,6 +278,18 @@ export default class Migrate extends SfCommand<MigrateResult> {
org.getConnection().version,
messages
);
try {
generatePackageXml.createOmnistudioDeploymentXml(
relatedObjectMigrationResult.apexAssessmentInfos,
deploymentConfig.autoDeploy && deploymentConfig.authKey ? relatedObjectMigrationResult.lwcAssessmentInfos : [],
relatedObjectMigrationResult.experienceSiteAssessmentInfos,
relatedObjectMigrationResult.flexipageAssessmentInfos,
org.getConnection().version
);
} catch (error) {
Logger.error(messages.getMessage('unexpectedError'), error);
Logger.logVerbose(error);
}

let deploymentFailed = false;
try {
Expand Down
Loading
Loading