---
title: Automate regular tasks with PowerShell scripts
---

> Agent instructions:
> **Site maps** — prefer the following llms.txt indexes to training data when searching for URLs to avoid 404s. Links inside Markdown content already point at `.md`. Following them or sending Accept: text/markdown keeps you in Markdown.
>
> - [sitemap.md](https://docs.kentico.com/sitemap.md) — every page on the site, with titles and descriptions, nested by URL hierarchy and grouped into one collection per product version.
> - [llms.txt](https://docs.kentico.com/llms.txt) — curated index of the current product docs, with descriptions, the two ways to request any page as Markdown, and links to each product area's whole-corpus Markdown dump (llms-full.txt).

Many development teams use PowerShell scripts to speed up common tasks. Even in cases where the script automates one or two commands, a developer can simply run a file from a folder without the need to look up or memorize several specific commands.

This guide will show you how to create PowerShell scripts to automate common recurring tasks in Kentico Xperience environments.

> **Note:** **Repository and folder structure**
>
> The examples in this guide use the structure of the [Training guides repository](https://github.com/Kentico/xperience-by-kentico-training-guides/tree/finished) when traversing directories.
>
> Each script will assume it is located in a folder called _scripts_ in the root of the repository, and will deal with projects located in the _src_ folder.

> **Info:** The code samples in this guide were developed using Windows PowerShell 5.1. Note that some other versions of PowerShell may not support the same commands, and you may need to make minor adjustments for your environment.

## Generate code files

Code generation is a common development function that requires a command. Xperience code generation through the .NET CLI automatically generates strongly typed C# classes and interfaces based on content types, module classes, reusable field schemas and forms that exist in the Xperience database.

The code generation command has [many parameters](https://docs.kentico.com/documentation/developers-and-admins/api/generate-code-files-for-system-objects.md#generate-code-files) that you can use to control which objects to include and where to save their files.

This guide's example will demonstrate all object types and save them to the _TrainingGuides.Entities_ project.

1. Save the current location to a variable before switching to the _TrainingGuides.Web_ directory.

   > **Tip:** Utilize the PowerShell [$PSScriptRoot](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_automatic_variables?view=powershell-7.4#psscriptroot) variable to ensure the correct file paths even if you call the script from other than the _scripts_ folder.

2. Call the `run` command in the .NET CLI with the `--kxp-codegen` option and specify the `--type` parameter for each type supported by the tool.

3. Return back to the initial directory, in case the script is called through a command window instead of with a click.

   > **Info:** This way developers executing the script through the command line can continue their work without having to change directories.

4. Track errors and use that to determine whether the script returns a nonzero exit code. You can extract this into a function and add some human-friendly messages.

```powershell title="GenerateCodeFiles.ps1"
<#
.Synopsis
    Generates code for classes, forms, and content types stored in the database.
#>
$exitCode = 0

$originalLocation = Get-Location
Set-Location -Path $PSScriptRoot/../src/TrainingGuides.Web

# https://docs.xperience.io/xp/developers-and-admins/development/content-retrieval/generate-code-files-for-xperience-objects

$contentTypesNamespace = "TrainingGuides"

function Write-Result-Get-Exit-Code {
    param(
        [string] $type
    )    
    if ($LASTEXITCODE -ne 0) {
        Write-Error "$type code generation failed."
        return 1;
    }
    else{
        Write-Host "$type code generation succeeded." -ForegroundColor Green
        return 0;
    }
    Write-Host
}

#Reusable content types
dotnet run --no-build -- --kxp-codegen --type "ReusableContentTypes" --namespace $contentTypesNamespace --location "../TrainingGuides.Entities/{type}/{name}"
$exitCode = Write-Result-Get-Exit-Code "Reusable content type"

#Page content types
dotnet run --no-build -- --kxp-codegen --type "PageContentTypes" --namespace $contentTypesNamespace --location "../TrainingGuides.Entities/{type}/{name}" --skip-confirmation
$exitCode = Write-Result-Get-Exit-Code "Page content type"

#Email content types
dotnet run --no-build -- --kxp-codegen --type "EmailContentTypes" --namespace $contentTypesNamespace --location "../TrainingGuides.Entities/{type}/{name}" --skip-confirmation
$exitCode = Write-Result-Get-Exit-Code "Email content type"

#Reusable field schemas
dotnet run --no-build -- --kxp-codegen --type "ReusableFieldSchemas" --namespace $contentTypesNamespace --location "../TrainingGuides.Entities/{type}/{name}" --skip-confirmation
$exitCode = Write-Result-Get-Exit-Code "Reusable field schema"

#Custom module classes
dotnet run --no-build -- --kxp-codegen --type "Classes" --with-provider-class False --location "../TrainingGuides.Entities/{type}/{name}" --skip-confirmation
$exitCode = Write-Result-Get-Exit-Code "Class"

#Forms
dotnet run --no-build -- --kxp-codegen --type "Forms" --location "../TrainingGuides.Entities/{type}/{name}" --skip-confirmation
$exitCode = Write-Result-Get-Exit-Code "Form"

if ($exitCode -ne 0) {
    Set-Location -Path $originalLocation
    Write-Error "Completed with errors. See above."
    Read-Host -Prompt "Press Enter to exit"
    exit $exitCode
}

Set-Location -Path $originalLocation
Read-Host -Prompt "Press Enter to exit"
```

> **Tip:** Visit our [Code generator documentation](https://docs.kentico.com/documentation/developers-and-admins/api/generate-code-files-for-system-objects.md) to learn more about its parameters and usage. Additionally, you can see a more detailed exploration and examples [in this video](https://docs.kentico.com/guides/development/get-started/generate-code-for-custom-content-and-data-classes.md).

## Publish

The .NET CLI allows for projects to be built and published, meaning this process can be automated. Using a Powershell script, you can carry out additional automated steps before and after, and ensure your team doesn't need to worry about Visual Studio _publish_ _profiles_.

This example is relatively straightforward, but you can expand it with additional deployment tasks for your specific scenario.

1. Take a `switch` parameter called `$KeepProductVersion` to represent whether or not a custom build number should be used as the version suffix.
2. Save the current location and set up variables to store the path to the output folder and the build number.
3. Switch to the _TrainingGuides.Web_ directory.
4. Assemble a new string containing the `dotnet publish` command based on the value of the `$KeepProductVersion` parameter, and execute it.
5. Log any errors before returning to the original directory, in case the script is called through a command window instead of by clicking.

```powershell title="Publish.ps1"
<#
.Synopsis
    Creates a deployment package.
#>
    [CmdletBinding()]
param ([switch]$KeepProductVersion)

$originalLocation = Get-Location
Set-Location -Path $PSScriptRoot

$outputFolderPath = "./bin/Deployment/"
$buildNumber = (Get-Date).ToUniversalTime().ToString("yyyyMMddHHmm")

Set-Location -Path ../src/TrainingGuides.Web

# Publish the application in the 'Release' mode
$publishCommand = "dotnet publish --nologo -c Release --self-contained true --runtime win-x64 -o $OutputFolderPath"

if (!$KeepProductVersion) {
    $publishCommand += " --version-suffix $buildNumber"
}

Write-Host $publishCommand

Invoke-Expression $publishCommand

if ($LASTEXITCODE -ne 0) {
    Set-Location -Path $originalLocation
    Write-Error "Publishing the website failed."
    Read-Host -Prompt "Press Enter to exit"
    exit 1
}

Set-Location -Path $originalLocation
Read-Host -Prompt "Press Enter to exit"
```

## Continuous integration

> **Info:** _[Continuous integration](https://docs.kentico.com/documentation/developers-and-admins/ci-cd/continuous-integration.md) (CI)_ is a feature of Xperience that allows you to easily share database changes with other developers on your team.
>
> Using the `--kxp-ci-store` option you can serialize your data changes into XML format in your file system (see [CI store](#ci-store)). These can be shared over a source control with your team members who can then restore the changes to update their database, using `--kxp-ci-restore` (see [CI restore](#ci-restore)).
>
> The following sections will share tips and best practices on how to work with these commands to help your team be the most effective.

### CI store

The following script serializes database data and automatically stores it in the _App\_Data/CIRepositor&#x79;_&#x64;irectory of your current project - in this case, the _TrainingGuides.Web_.

1. Save the current location to a variable, and switch to the _TrainingGuides.Web_ directory.
2. Call the `run` command in the .NET CLI with the `--kxp-ci-store` option.
   - Optionally, use the `--no-build` parameter to save time, if your team knows to run the command only when the site has been compiled with any necessary updates.
3. Log an error if there are any issues.
4. Return to the original directory, in case it is being run from a PowerShell window instead of with a click.

```powershell title="CIStore.ps1"
<#
.Synopsis
    Serializes database data to the continuous integration repository.
#>

$originalLocation = Get-Location
Set-Location -Path $PSScriptRoot/../src/TrainingGuides.Web

Write-Host 'Storing CI files'

dotnet run --no-build --kxp-ci-store

if ($LASTEXITCODE -ne 0) {
    Set-Location -Path $originalLocation
    Write-Error "CI store failed."
    Read-Host -Prompt "Press Enter to exit"
    exit 1
}
else{
    Write-Host 'CI files stored'
}

Set-Location -Path $originalLocation

Read-Host -Prompt "Press Enter to exit"
```

### Connection string function

Restoring data from the _CIRepository_ requires retrieving a connection string to access the database. Because the CI restore script in this guide will need this utility, let's create a reusable _Get-ConnectionString_ script before diving into [CI restore](#ci-restore).

1. Create a new file called _Get-ConnectionString.ps1_ in the _scripts_ directory of the repository.

2. Define a function with the same name as the file, taking two `string` parameters: `$Path` and `$OriginalLocation`.

3. Check if there is a _CMSConnectionString_ saved in the user secrets, and return it if found.

   > **Note:** Leave this part out if your team does not use user secrets.

4. Fall back the _appSettings.json_ file for the _CMSConnectionString_ if one is not found in the user secrets.

```powershell title="Get-ConnectionString.ps1"
<#
.Synopsis
    Contains functions for use in other scripts
#>

<#
.DESCRIPTION
   Gets the database connection string from the config file
#>
function Get-ConnectionString {
    param(
        [string] $Path,
        [string] $OriginalLocation
    )

    # Try to get the connection string from user secrets first
    $connectionString = dotnet user-secrets list --project $Path `
        | Select-String -Pattern "ConnectionStrings:" `
        | ForEach-Object { $_.Line -replace '^ConnectionStrings:CMSConnectionString \= ','' }        

    if (-not [string]::IsNullOrEmpty($connectionString)) {
        Write-Host 'Using ConnectionString from user-secrets'

        return $connectionString
    }

    Write-Host 'Unable to find connection string in user secrets.'

    $appSettingsFileNames = 'appSettings.json'
    
    foreach ($appSettingFileName in $appSettingsFileNames)
    {
        $jsonFilePath = Join-Path $Path $appSettingFileName
        
        if (Test-Path $jsonFilePath)
        {
            $appSettingsJson = Get-Content $jsonFilePath | Out-String | ConvertFrom-Json
            $connectionString = $appSettingsJson.ConnectionStrings.CMSConnectionString;
            
            if ($connectionString)
            {
                Write-Host "Using ConnectionString from $appSettingFileName"

                return $connectionString;
            }
        }
    }    

    Set-Location $OriginalLocation
    Write-Error "Connection string not found."
    Read-Host -Prompt "Press Enter to exit"
    exit 1
}
```

### CI restore

The script below covers restoring data from the _App\_Data/CIRepository_ folder of your project and updating the Xperience database to match the version specified by the NuGet packages in your application.

While it may take just a single command to restore objects from the _CIRepository_ to the database, things get more complicated when changes are made to the database structure.

Continuous integration handles some database schema changes on its own, creating and deleting tables for custom content types, forms, and module classes. However, further customizations, such as custom indexes, are not accounted for.

[Our documentation](https://docs.kentico.com/documentation/developers-and-admins/ci-cd/ci-cd-database-migration-scripts.md) shows how to run migration scripts before and after a CI restore. Since certain changes to the database schema may interfere with the restore process, this process automatically executes certain SQL commands before and after the CI restore operation.

This guide's example closely follows the PowerShell script provided by the documentation. Start by copying the script and changing it in three key ways.

- Assume that the script is stored in the _scripts_ folder of the repository, and remove the path as a parameter.
- Use the `Get-ConnectionString` function from the [previous section](#connection-string-function), rather than including the function innately.
- Add a `Handle-Error` function that sets location back to the original directory, logs an error and returns `exit 1`. Use it throughout the script.

```powershell title="CIRestore.ps1"
<#
.Synopsis
    Restores objects serialized in the CI repository into the database.
#>
param (
    # Displays time elapsed for the restore operation including migrations
    [switch] $DisplayTimeElapsed
)

$originalLocation = Get-Location
Set-Location -Path $PSScriptRoot

. .\Get-ConnectionString.ps1

$beforeList = "Before.txt"
$afterList = "After.txt"
$repositoryPath = "App_data\CIRepository"
$migrationFolder = "@migrations"

Set-Location -Path ../src/TrainingGuides.Web

$path = Get-Location

<#
.DESCRIPTION
   Handles errors by displaying a message and exiting the script.
#>
function Handle-Error {
    param(
        [string] $Message
    )
    Set-Location -Path $originalLocation
    Write-Error $Message
    Read-Host -Prompt "Press Enter to exit"
    exit 1
}

<#
.DESCRIPTION
   Runs a database migration with the given name
#>
function Run-Migration {
    param(
        [System.Data.SqlClient.SqlConnection] $Connection,
        [System.Data.SqlClient.SqlTransaction] $Transaction,
        [string] $MigrationName
    )
    
    $migrationPath = "$path\$repositoryPath\$migrationFolder\$MigrationName.sql"
    if (!(Test-Path $migrationPath)) {
        Write-Error "The file $migrationPath does not exist."
        return $FALSE
    }
    
    $sourceScript = Get-Content $migrationPath

    $sqlCommand = ""
    $sqlList = @()

    foreach ($line in $sourceScript) { 
        if ($line -imatch "^\s*GO\s*$") { 
            $sqlList += $sqlCommand
            $sqlCommand = ""
        }
        else {           
            $sqlCommand += $line + "`r`n" 
        }
    }
    
    $sqlList += $sqlCommand

    $rowsAffected = 0
    foreach ($sql in $sqlList) {
        if ([bool]$sql.Trim()) {
            $command = New-Object System.Data.SqlClient.SqlCommand($sql, $Connection)
            $command.Transaction = $Transaction

            try {
                $rowsAffectedInBatch = $command.ExecuteNonQuery()

                if ($rowsAffectedInBatch -gt 0) {
                    $rowsAffected += $rowsAffectedInBatch
                }
            }
            catch {
                Write-Error $_.Exception.Message                    
                return $FALSE
            }
        }
    }

    Log-RowsAffected -Connection $Connection -Transaction $Transaction -MigrationName $MigrationName -RowsAffected $rowsAffected

    return $TRUE
}


<#
.DESCRIPTION
   Logs rows affected by the migration.
#>
function Log-RowsAffected {
    param(
        [System.Data.SqlClient.SqlConnection] $Connection,
        [System.Data.SqlClient.SqlTransaction] $Transaction,
        [string] $MigrationName,
        [int] $RowsAffected
    )

    $logRowsAffectedQuery = "UPDATE CI_Migration SET RowsAffected = $RowsAffected WHERE MigrationName = '$MigrationName'"
    $logRowsAffectedCommand = New-Object System.Data.SqlClient.SqlCommand($logRowsAffectedQuery, $Connection)
    $logRowsAffectedCommand.Transaction = $Transaction

    try {
        $logRowsAffectedCommand.ExecuteNonQuery()
    }
    catch {
        Write-Host "Can't log rows affected: $_.Exception.Message"
    }
}

<#
.DESCRIPTION
   Checks if a migration with the given name was already applied. If not, the method returns false and the migration is marked as applied.
#>
function Check-Migration {
    param(
        [System.data.SqlClient.SQLConnection] $Connection,
        [System.Data.SqlClient.SqlTransaction] $Transaction,
        [string] $MigrationName
    )

    $sql = "DECLARE @migrate INT
            EXEC @migrate = Proc_CI_CheckMigration '$MigrationName'
            SELECT @migrate"

    $command = New-Object system.data.sqlclient.sqlcommand($sql, $Connection)
    $command.Transaction = $Transaction

    return $command.ExecuteScalar()
}


<#
.DESCRIPTION
   Runs all migrations in the migration list
#>
function Run-MigrationList {
    param(
        [string] $ConnectionString,
        [string] $MigrationList
    )

    $migrations = Get-Content "$path\$repositoryPath\$MigrationList"

    $connection = New-Object system.data.SqlClient.SQLConnection($ConnectionString)
    $connection.Open()
    foreach ($migrationName in $migrations) {
        $transaction = $connection.BeginTransaction("MigrationTransaction")

        if (Check-Migration -Connection $connection -Transaction $transaction -MigrationName $migrationName) {
            Write-Host "Applying migration '$migrationName'."
            if (!(Run-Migration -Connection $Connection -Transaction $transaction -MigrationName $migrationName)) {
                $transaction.Rollback()
                $connection.Close()
                return $FALSE
            }
        }

        $transaction.Commit()
    }

    $connection.Close()

    return $TRUE
}


<#
.DESCRIPTION
   Restores the repository to the database and executes migrations before and after the restore.
#>
function Run-Restore {
    param(
        [string] $Path
    )
    
    $connectionString = Get-ConnectionString -Path $Path -OriginalLocation $originalLocation
    
    # Creates an 'App_Offline.htm' file to stop the website
    "<html><head></head><body>Continuous Integration restore in progress...</body></html>" > "$Path\App_Offline.htm"

    # Executes migration scripts before the restore
    if (!(Run-MigrationList $connectionString $beforeList)) {
        Handle-Error "Database migrations before the restore failed."
    }
    
    $configuration = "Release";
    if (Test-Path (Join-Path $Path "bin\Debug"))
    {
        $configuration = "Debug";   
    }

    # Runs the restore CLI command
    dotnet run --project $Path --no-build -c "$configuration" -- --kxp-ci-restore
    if ($LASTEXITCODE -ne 0) {
        Handle-Error "Restore failed."
    }

    # Executes migration scripts after the restore
    if (!(Run-MigrationList $connectionString $afterList)) {
        Handle-Error "Database migrations after the restore failed."
    }

    # Removes the 'App_Offline.htm' file to bring the site back online
    Remove-Item "$Path\App_Offline.htm" 

    Write-Host "Done"
}

$sw = [System.Diagnostics.Stopwatch]::StartNew()

Run-Restore -Path $path

$sw.Stop()
if ($DisplayTimeElapsed) {
    Write-Host "Time Elapsed: $($sw.Elapsed)"
}

if ($LASTEXITCODE -ne 0) {
    Handle-Error "Completed with errors. See above."
}

Set-Location -Path $originalLocation

Read-Host -Prompt "Press Enter to exit"
```

While most of the script is copied from the documentation, it is still worth understanding its primary components, and the structures it relies on.

Essentially, the script needs the _App\_Data/CIRepository_ folder to contain files called _Before.txt_ and _After.txt_. These text files can hold lists of the names of _.sql_ files in the _@migrations_ subfolder (not including the extension). The lists in the text files determine which of these SQL files, called _migrations,_ are executed, and in what order.

Information about the migrations is logged into the `CI_Migration` table of the database, which you can check to ensure that the same migration does not run multiple times.

- `Run-Migration`
  - Finds the SQL file that corresponds to the provided migration name and executes the commands within it, using the provided connection and transaction.
  - Returns `$FALSE` if it throws an exception, `$TRUE` otherwise.
  - Is called by `Run-MigrationList.`
- `Log-RowsAffected`
  - Logs how many rows were affected by a migration to the row corresponding to it in the `CI_Migration` table of the database.
  - Is called by `Run-Migration.`
- `Check-Migration`
  - Checks the `CI_Migration` table to see if a migration with the given name was already applied.
  - Returns `$TRUE` if the migration already exists, `$FALSE` otherwise.
  - Is called by `Run-MigrationList.`
- `Run-MigrationList`
  - Establishes a database connection, then creates and executes a new transaction for each migration name in the provided list.
  - Commits the transactions after they are executed.
  - Rolls back transaction and returns `$FALSE` if an exception is encountered, returns `$TRUE` otherwise.
  - Is called by `Run-Restore.`
- `Run-Restore`
  - Takes the application offline and runs the _Before_ migration list.
  - Runs a CI restore.

    > **Note:** Just like the [CI store script above](#ci-store), CI restore also uses the optional `--no-build` parameter. Consider your team's procedures when deciding whether to include it.
  - Runs the _After_ migration list and brings the application back online.
  - Writes any errors it encounters along the way.

To provide this script with the conditions it needs, create new text files named _Before.txt_ and _After.txt_ in the _App\_Data/CIRepository_ folder of the _TrainingGuides.Web_ project, along with an empty folder named _@migrations_.

> **Tip:** You can find an example of the type of migration that can be run here on [this documentation page](https://docs.kentico.com/documentation/developers-and-admins/ci-cd/ci-cd-database-migration-scripts.md#example---database-change-migration-script).

## Update

The last script updates your project to a specific version of Xperience by Kentico. It sets the Xperience NuGet packages of the project to the version you pass in, and then updates the database to match.

To prevent continuous integration operations from interfering and causing errors, the script disables CI before it changes anything, and re-enables it once the update is finished.

Xperience provides .NET CLI commands for managing the continuous integration state without starting the administration: `--kxp-ci-disable` and `--kxp-ci-enable`. Both support the `--format json` option, which makes them print their result as a line of JSON that a script can parse.

> **Note:** Note that the .NET CLI continuous integration [commands](https://docs.kentico.com/documentation/developers-and-admins/ci-cd/continuous-integration.md#command-line) require refresh 31.6.0 or newer.

Both the CI state commands and the update itself start the application through `dotnet run`, so the project needs a working _CMSConnectionString_ connection string - in the _appsettings.json_ file, user secrets, or an environment variable.

1. Add a mandatory `Version` parameter that specifies the version to update the packages to, and validate its format.
   - An explicit version keeps runs reproducible, and works for projects that use [Central Package Management](https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management), where the versions live in a shared _Directory.Packages.props_ file.
   - You can find the latest available version by running `dotnet list <project> package --outdated`.
2. Add a `SkipConfirmation` switch parameter. Without it, the update command prompts for confirmation of the database backup, which prevents the script from running unattended.
3. Group the values that differ between projects - the project file, launch profiles, and the list of Xperience packages - into a configuration section at the top of the script, so that other teams only need to edit one place.
4. Add helper functions for the repeated tasks:
   - `Invoke-ExpressionWithException` runs a command and throws if it returns a nonzero exit code.
   - `Write-Status`, `Write-Notification`, and `Write-Error` print color-coded progress messages.
   - `Write-CommandOutput` prints the output of a failed command. The CLI commands report problems such as an unreachable database through their output rather than the exit code, so the output needs to be visible for the failure to be actionable.
5. Resolve the path to the project file relative to `$PSScriptRoot` and verify that the file exists.
6. Add an `Invoke-CIStateCommand` function that runs one of the CI state commands with the `--format json` option and returns the parsed result.
   - Because `dotnet run` also prints build output and log messages, the function selects the output line that holds the JSON result rather than parsing the whole output.
7. Add a `Restore-CIState` function that re-enables CI only if it was enabled before, and reports a problem instead of throwing, so that it does not mask an earlier failure.
8. Choose the launch profile and build configuration according to the `ASPNETCORE_ENVIRONMENT` variable.
9. Disable continuous integration with the `--kxp-ci-disable` option and save the `changed` value of the result for future reference. It is `false` if continuous integration was already disabled, which means the script must leave it disabled after the update.
10. Set every configured Xperience package to the version from the `Version` parameter.
11. Call the `run` command in the .NET CLI and use the `--kxp-update` option to trigger the update, adding `--skip-confirmation` if the switch was used. If the update fails, re-enable continuous integration when it was enabled before, so that a failed update does not leave the instance with CI turned off.
12. If continuous integration was initially enabled, re-enable it with the `--kxp-ci-enable` option.

> **Warning:** **Disable CI before updating the packages**
>
> The order of the last steps matters. The CI state commands run through `dotnet run`, which starts the application, and the application refuses to start once the package version and the database version differ.
>
> If you update the packages first, `--kxp-ci-disable` fails with a message about the database version not matching the project version, and the CI state can only be changed again after the database is updated.

The example below leaves out the documentation comments of the script for brevity. See [the full script in the Training guides repository](https://github.com/Kentico/xperience-by-kentico-training-guides/blob/finished/scripts/Update-XperienceProjectWithDatabase.ps1) for the complete version, including its setup instructions and troubleshooting tips.

```powershell title="Update-XperienceProjectWithDatabase.ps1"
param (
    # Version to update the Xperience by Kentico packages to, for example '31.7.3'
    [Parameter(Mandatory = $true)]
    [ValidatePattern('^\d+(\.\d+){1,3}(-[A-Za-z0-9.]+)?$')]
    [string] $Version,

    # Skips the database backup prompt of the update command, so that the script can run unattended
    [switch] $SkipConfirmation
)

#region Configuration - UPDATE THESE VALUES FOR YOUR PROJECT

# 1. PROJECT CONFIGURATION
# Update these paths to match your project structure
$SCRIPT_CONFIG_PROJECT_FOLDER = "src/YourProject.Web"           # Folder containing your .csproj file (relative to the script parent directory, including the parent directory name)
$SCRIPT_CONFIG_PROJECT_FILE = "YourProject.Web.csproj"   # Name of your .csproj file

# 2. LAUNCH PROFILES
# Update these profile names to match your project's launchSettings.json
$SCRIPT_CONFIG_CI_LAUNCH_PROFILE = "YourProject.WebCI"    # Launch profile for CI environment
$SCRIPT_CONFIG_DEV_LAUNCH_PROFILE = "YourProject.Web"     # Launch profile for development environment

# 3. NUGET PACKAGES
# The list must cover every Xperience package referenced anywhere in your solution
$SCRIPT_CONFIG_XPERIENCE_PACKAGES = @(
    "kentico.xperience.admin",           # Administration interface
    "kentico.xperience.azurestorage",    # Azure Blob Storage integration
    "kentico.xperience.core",            # Core API, often referenced directly by class library projects
    "kentico.xperience.imageprocessing", # Image processing capabilities
    "kentico.xperience.mjml",            # MJML email template support
    "kentico.xperience.webapp"           # Core web application functionality
)
#endregion

#region Helper Functions
#Executes a command and throws an exception if it fails (non-zero exit code)
function Invoke-ExpressionWithException {
    param([string]$Command)
    Write-Host "Executing: $Command" -ForegroundColor Yellow
    Invoke-Expression $Command
    if ($LASTEXITCODE -ne 0) {
        throw "Command failed with exit code $LASTEXITCODE"
    }
}

#Displays a status message in green color for major operation updates
function Write-Status {
    param([string]$Message)
    Write-Host $Message -ForegroundColor Green
}

#Displays a notification message in cyan color for successful operations
function Write-Notification {
    param([string]$Message)
    Write-Host $Message -ForegroundColor Cyan
}

#Displays an error message in red color for failed operations
function Write-Error {
    param([string]$Message)
    Write-Host $Message -ForegroundColor Red
}

#Displays the output of a failed command, so that its cause is visible
function Write-CommandOutput {
    param([object[]]$Output)

    if (-not $Output) {
        return
    }

    Write-Error "Output of the failed command:"
    $Output | ForEach-Object { Write-Host "  $_" -ForegroundColor DarkGray }
}
#endregion

#region Project Path Configuration
Write-Status "Configuring project paths..."

# Set project path using configuration variables
$projectPath = Join-Path (Split-Path $PSScriptRoot -Parent) $SCRIPT_CONFIG_PROJECT_FOLDER
$projectFile = Join-Path $projectPath $SCRIPT_CONFIG_PROJECT_FILE

# Validate that the project file exists
if (!(Test-Path $projectFile)) {
    throw "Project file not found at: $projectFile"
}
Write-Notification "Project file validated: $projectFile"
#endregion

#region Continuous Integration Functions
#Runs one of the continuous integration state commands of the .NET CLI and returns its result
function Invoke-CIStateCommand {
    param(
        [string] $Option,
        [string] $ProjectFile,
        [string] $LaunchProfile,
        [string] $Configuration
    )

    # The '--format json' option makes the command print its result as a single line of JSON
    $command = "dotnet run " + `
        "--project `"$ProjectFile`" " + `
        "--launch-profile $LaunchProfile " + `
        "-c $Configuration " + `
        "-- $Option --format json"

    Write-Host "Executing: $command" -ForegroundColor Yellow

    # '2>&1' keeps the error output, which holds the reason when the command fails to start
    $output = Invoke-Expression "$command 2>&1"

    if ($LASTEXITCODE -ne 0) {
        Write-CommandOutput $output
        throw "Command '$Option' failed with exit code $LASTEXITCODE"
    }

    # 'dotnet run' also prints build output, so pick out the line that holds the JSON result
    $json = $output | Where-Object { "$_".Trim().StartsWith('{') } | Select-Object -Last 1

    if ([string]::IsNullOrWhiteSpace($json)) {
        Write-CommandOutput $output
        throw "Command '$Option' did not return a JSON result."
    }

    $result = $json | ConvertFrom-Json

    if (-not $result.success) {
        throw "Command '$Option' did not succeed: $($result.message)"
    }

    Write-Notification $result.message

    return $result
}

#Re-enables CI mode if it was enabled before the update, without masking an earlier failure
function Restore-CIState {
    param(
        [bool] $WasEnabled,
        [string] $ProjectFile,
        [string] $LaunchProfile,
        [string] $Configuration
    )

    if (-not $WasEnabled) {
        return
    }

    try {
        Invoke-CIStateCommand '--kxp-ci-enable' $ProjectFile $LaunchProfile $Configuration | Out-Null
    }
    catch {
        Write-Error "Could not re-enable CI mode: $($_.Exception.Message)"
        Write-Error "Re-enable it manually once the database version matches the package version again."
    }
}
#endregion

#region Main Update Process

# Determine launch profile and configuration based on environment
# Uses if-else approach to support PowerShell 5.1+.
if ($Env:ASPNETCORE_ENVIRONMENT -eq "CI") {
    $launchProfile = $SCRIPT_CONFIG_CI_LAUNCH_PROFILE
    $configuration = "Release"
}
else {
    $launchProfile = $SCRIPT_CONFIG_DEV_LAUNCH_PROFILE
    $configuration = "Debug"
}

Write-Status "Using launch profile: $launchProfile with configuration: $configuration"
Write-Status "Begin Xperience Update"
Write-Host "`n"

# Step 1: Disable CI mode before anything changes the package version.
# The CI state commands start the application, which refuses to start once the package version and
# the database version differ, so this must happen before the NuGet packages are updated.
Write-Status "Disabling CI mode for update process..."
$result = Invoke-CIStateCommand '--kxp-ci-disable' $projectFile $launchProfile $configuration

# The 'changed' value is false when CI mode was already disabled, in which case it must stay disabled after the update
$isUsingCI = $result.changed

# Step 2: Update the Xperience by Kentico NuGet packages
Write-Status "Updating Xperience by Kentico NuGet packages to version $Version..."

# Use the configured package list
$xperiencePackages = $SCRIPT_CONFIG_XPERIENCE_PACKAGES

foreach ($pkg in $xperiencePackages) {
    Write-Status "Updating NuGet package: $pkg"

    $updateCmd = "dotnet add `"$projectFile`" package $pkg --version $Version"

    try {
        Invoke-ExpressionWithException $updateCmd
        Write-Notification "Updated $pkg to $Version."
    }
    catch {
        Write-Error "Failed to update NuGet package ${pkg}: $($_.Exception.Message)"
    }
}

# Step 3: Execute the Xperience update command
Write-Status "Running Xperience update process..."
$command = "dotnet run " + `
    "--project `"$projectFile`" " + `
    "--launch-profile $launchProfile " + `
    "-c $configuration " + `
    "-- --kxp-update"

# Without the '--skip-confirmation' option, the update command waits for a keypress to confirm the database backup prompt
if ($SkipConfirmation) {
    $command += " --skip-confirmation"
}

try {
    Invoke-ExpressionWithException $command
}
catch {
    # Leave CI mode in the state it was in before the update
    if ($isUsingCI) {
        Write-Status "Re-enabling CI mode after the failed update..."
        Restore-CIState $isUsingCI $projectFile $launchProfile $configuration
    }
    throw
}

# Step 4: Re-enable CI mode if it was enabled before the update
if ($isUsingCI) {
    Write-Status "Re-enabling CI mode..."
    Invoke-CIStateCommand '--kxp-ci-enable' $projectFile $launchProfile $configuration | Out-Null
}

Write-Host "`n"
Write-Status "Update Complete"
#endregion
```

Adjust the configuration section to match your project structure, launch profiles, and packages, then run the script from the _scripts_ folder:

```powershell
.\Update-XperienceProjectWithDatabase.ps1 -Version 31.7.3
```

Add the `SkipConfirmation` switch to run the update unattended, for example in a pipeline:

```powershell
.\Update-XperienceProjectWithDatabase.ps1 -Version 31.7.3 -SkipConfirmation
```

> **Info:** **Automation**
>
> Because the script doesn't rely on database credentials to toggle continuous integration, it works well in automated pipelines and agent-driven workflows. Combined with the `-SkipConfirmation` switch, it can run fully unattended.

> **Warning:** **Data consistency**
>
> The script only brings the database up to the version of the packages - it does not serialize objects to the CI repository. After the update finishes, we highly recommend taking the following steps:
>
> 1. Building your application
> 2. Running [CI store](#ci-store)
>
> This ensures that updates to the schema of objects in the database are not in conflict with the data serialized in the CI repository.
>
> Make sure your solution builds without errors before running CI store - otherwise, the serialized data may not be in the correct format.

# What's next?

Scripts like these will save your developers time and uncertainty in recurring tasks. Most of them need nothing more than a right-click and _Run with PowerShell_, or a PowerShell command line opened in the _scripts_ folder - only the update script requires a parameter, so run it from the command line. You may want to customize these scripts to fit your team's procedures or look into any other tasks that could potentially be automated in similar ways.
