429 lines
22 KiB
PowerShell
429 lines
22 KiB
PowerShell
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
$script:RepositoryUrl = 'https://code.it.aisuan.cn/wenhuadian/xas-skills.git'
|
|
$script:StateFile = '.xas-install.json'
|
|
|
|
function Get-XasDefaultSkillsRoot {
|
|
$profileDirectory = $env:USERPROFILE
|
|
if ([string]::IsNullOrWhiteSpace($profileDirectory)) {
|
|
$profileDirectory = [Environment]::GetFolderPath('UserProfile')
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($profileDirectory)) { throw 'Cannot determine the user profile directory.' }
|
|
return Join-Path $profileDirectory '.agents/skills'
|
|
}
|
|
|
|
function Get-XasFullPath {
|
|
param([Parameter(Mandatory)][string]$Path)
|
|
$absolute = [IO.Path]::GetFullPath($Path)
|
|
if ($absolute -ieq [IO.Path]::GetPathRoot($absolute)) { return $absolute }
|
|
return $absolute.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
|
|
}
|
|
|
|
function Test-XasWithin {
|
|
param([string]$Path, [string]$Root)
|
|
$candidate = Get-XasFullPath $Path
|
|
$boundary = (Get-XasFullPath $Root).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
|
|
return $candidate.StartsWith($boundary, [StringComparison]::OrdinalIgnoreCase)
|
|
}
|
|
|
|
function Assert-XasNoLinkPath {
|
|
param([string]$Path)
|
|
$cursor = [IO.Path]::GetFullPath($Path)
|
|
while ($cursor) {
|
|
if (Test-Path -LiteralPath $cursor) {
|
|
$item = Get-Item -LiteralPath $cursor -Force
|
|
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
|
throw "Links/reparse points are not supported: $cursor"
|
|
}
|
|
}
|
|
$parent = [IO.Path]::GetDirectoryName($cursor)
|
|
if ($parent -eq $cursor) { break }
|
|
$cursor = $parent
|
|
}
|
|
}
|
|
|
|
function Assert-XasSkillName {
|
|
param([string]$Name)
|
|
if ($Name.Length -gt 63 -or $Name -cnotmatch '^[a-z0-9]+(-[a-z0-9]+)*$' -or
|
|
$Name -match '^(con|prn|aux|nul|com[0-9]|lpt[0-9])$') {
|
|
throw "Invalid skill name: $Name"
|
|
}
|
|
}
|
|
|
|
function ConvertFrom-XasYamlString {
|
|
param([string]$Value, [string]$Field, [string]$Entrypoint)
|
|
$text = $Value.Trim()
|
|
$message = "Unsupported YAML string for '$Field' in $Entrypoint"
|
|
if ($text.StartsWith("'")) {
|
|
$quoted = [regex]::Match($text, "^'(?<value>(?:[^']|'')*)'(?<tail>.*)$")
|
|
if (-not $quoted.Success -or $quoted.Groups['tail'].Value -notmatch '^(?:[ \t]*|[ \t]+#.*)$') {
|
|
throw "$message. Use a single-line quoted string, optionally followed by a comment."
|
|
}
|
|
$result = $quoted.Groups['value'].Value.Replace("''", "'")
|
|
} elseif ($text.StartsWith('"')) {
|
|
$quoted = [regex]::Match($text, '^"(?<value>(?:[^"\\]|\\.)*)"(?<tail>.*)$')
|
|
if (-not $quoted.Success -or $quoted.Groups['tail'].Value -notmatch '^(?:[ \t]*|[ \t]+#.*)$') {
|
|
throw "$message. Use a single-line quoted string, optionally followed by a comment."
|
|
}
|
|
# JSON escapes are the supported subset of YAML double-quoted escapes.
|
|
$encoded = $quoted.Groups['value'].Value
|
|
if ($encoded -notmatch '^(?:[^"\\]|\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4}))*$') {
|
|
throw "$message. Double-quoted strings support JSON-style escapes only."
|
|
}
|
|
try { $result = ConvertFrom-Json -InputObject ('"' + $encoded + '"') -ErrorAction Stop }
|
|
catch { throw "$message. Invalid double-quoted string or escape sequence." }
|
|
} else {
|
|
$comment = [regex]::Match($text, '(^|[ \t]+)#')
|
|
if ($comment.Success) { $text = $text.Substring(0, $comment.Index).TrimEnd() }
|
|
if ($text -match '^[\[\]{}&*!|>%@`]' -or $text -match '^(?:-|\?|:)(?:[ \t]|$)' -or
|
|
$text -match ':(?:[ \t]|$)' -or $text -match '^(?:---|\.\.\.)$') {
|
|
throw "$message. Quote plain text containing YAML syntax; collections, aliases and tags are not supported."
|
|
}
|
|
if ($text -match '^(?:null|~|true|false|yes|no|on|off)$' -or
|
|
$text -match '^[+-]?(?:[0-9][0-9_]*(?:\.[0-9_]*)?(?:[eE][+-]?[0-9]+)?|\.[0-9_]+(?:[eE][+-]?[0-9]+)?|\.inf|\.nan|0x[0-9a-f_]+|0o[0-7_]+|0b[01_]+)$') {
|
|
throw "'$Field' must be a non-empty string in $Entrypoint. Quote values that resemble null, booleans or numbers."
|
|
}
|
|
$result = $text
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($result)) { throw "'$Field' must be a non-empty string in $Entrypoint." }
|
|
return $result
|
|
}
|
|
|
|
function ConvertFrom-XasRequiredFrontmatter {
|
|
param([string]$Body, [string]$Entrypoint)
|
|
$values = @{}
|
|
$lines = @($Body -split '\r?\n')
|
|
for ($index = 0; $index -lt $lines.Count; $index++) {
|
|
$match = [regex]::Match($lines[$index], '^(?<key>name|description):(?<value>.*)$')
|
|
if (-not $match.Success) { continue }
|
|
$field = $match.Groups['key'].Value
|
|
if ($values.ContainsKey($field)) { throw "Duplicate required key '$field' in $Entrypoint." }
|
|
$raw = $match.Groups['value'].Value
|
|
if ($raw.Length -gt 0 -and $raw -notmatch '^[ \t]') { throw "Expected whitespace after '$field`:' in $Entrypoint." }
|
|
$value = $raw.Trim()
|
|
if ($value -match '^[|>]') {
|
|
$blockHeader = [regex]::Replace($value, '[ \t]+#.*$', '').TrimEnd()
|
|
if ($field -ne 'description' -or $blockHeader -notmatch '^[|>][+-]?$') {
|
|
throw "Unsupported YAML block for '$field' in $Entrypoint. Only description supports |, > and optional + or - chomping."
|
|
}
|
|
$block = New-Object 'System.Collections.Generic.List[string]'
|
|
$end = $index + 1
|
|
while ($end -lt $lines.Count) {
|
|
$line = $lines[$end]
|
|
if (-not [string]::IsNullOrWhiteSpace($line) -and $line -notmatch '^[ \t]') { break }
|
|
if ($line -match '^ *\t') { throw "YAML block indentation must use spaces in $Entrypoint." }
|
|
$block.Add($line)
|
|
$end++
|
|
}
|
|
$nonempty = @($block | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
|
if ($nonempty.Count -eq 0) { throw "'description' must be a non-empty string in $Entrypoint." }
|
|
$indent = [regex]::Match($nonempty[0], '^ +').Length
|
|
$normalized = New-Object 'System.Collections.Generic.List[string]'
|
|
foreach ($line in $block) {
|
|
if ([string]::IsNullOrWhiteSpace($line)) { $normalized.Add(''); continue }
|
|
if ([regex]::Match($line, '^ +').Length -lt $indent) { throw "Inconsistent YAML block indentation in $Entrypoint." }
|
|
$normalized.Add($line.Substring($indent))
|
|
}
|
|
if ($blockHeader.StartsWith('|')) {
|
|
$result = $normalized.ToArray() -join "`n"
|
|
} else {
|
|
$builder = New-Object Text.StringBuilder
|
|
for ($part = 0; $part -lt $normalized.Count; $part++) {
|
|
$current = $normalized[$part]
|
|
$null = $builder.Append($current)
|
|
if ($part + 1 -ge $normalized.Count) { continue }
|
|
$next = $normalized[$part + 1]
|
|
if ($current.Length -gt 0 -and $next.Length -gt 0 -and
|
|
-not $current.StartsWith(' ') -and -not $next.StartsWith(' ')) {
|
|
$null = $builder.Append(' ')
|
|
} elseif ($current.Length -eq 0 -and $next.Length -gt 0 -and $part -gt 0 -and
|
|
-not $normalized[$part - 1].StartsWith(' ') -and -not $next.StartsWith(' ')) {
|
|
# One blank line separates folded paragraphs with one newline.
|
|
} else { $null = $builder.Append("`n") }
|
|
}
|
|
$result = $builder.ToString()
|
|
}
|
|
if ($blockHeader.EndsWith('-')) { $result = $result.TrimEnd([char]10) }
|
|
elseif ($blockHeader.EndsWith('+')) { $result += "`n" }
|
|
else { $result = $result.TrimEnd([char]10) + "`n" }
|
|
$values[$field] = $result
|
|
$index = $end - 1
|
|
} else {
|
|
$values[$field] = ConvertFrom-XasYamlString -Value $raw -Field $field -Entrypoint $Entrypoint
|
|
# Multiline plain/quoted strings need a YAML parser; do not silently truncate them.
|
|
for ($nextIndex = $index + 1; $nextIndex -lt $lines.Count; $nextIndex++) {
|
|
$continuation = $lines[$nextIndex]
|
|
if ([string]::IsNullOrWhiteSpace($continuation) -or $continuation -match '^[ \t]*#') { continue }
|
|
if ($continuation -match '^[ \t]') {
|
|
throw "Unsupported multiline scalar for '$field' in $Entrypoint. Use a description block with | or >."
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
foreach ($field in @('name', 'description')) {
|
|
if (-not $values.ContainsKey($field)) {
|
|
throw "Missing required '$field' in $Entrypoint. Use unquoted top-level name: and description: keys."
|
|
}
|
|
}
|
|
return $values
|
|
}
|
|
|
|
function Get-XasSkillCatalog {
|
|
[CmdletBinding()]
|
|
param([Parameter(Mandatory)][string]$RepositoryRoot)
|
|
$skillsDirectory = Join-Path (Get-XasFullPath $RepositoryRoot) 'skills'
|
|
Assert-XasNoLinkPath $skillsDirectory
|
|
if (-not (Test-Path -LiteralPath $skillsDirectory -PathType Container)) { return }
|
|
foreach ($directory in @(Get-ChildItem -LiteralPath $skillsDirectory -Directory -Force | Sort-Object Name)) {
|
|
Assert-XasNoLinkPath $directory.FullName
|
|
$entrypoint = Join-Path $directory.FullName 'SKILL.md'
|
|
if (-not (Test-Path -LiteralPath $entrypoint -PathType Leaf)) { continue }
|
|
Assert-XasSkillName $directory.Name
|
|
Assert-XasNoLinkPath $entrypoint
|
|
$content = [IO.File]::ReadAllText($entrypoint)
|
|
$header = [regex]::Match($content, '\A---\r?\n(?<body>[\s\S]*?)\r?\n---(?:\r?\n|\z)')
|
|
if (-not $header.Success) { throw "Missing YAML frontmatter: $entrypoint" }
|
|
$metadata = ConvertFrom-XasRequiredFrontmatter -Body $header.Groups['body'].Value -Entrypoint $entrypoint
|
|
if ($metadata.name -cne $directory.Name) {
|
|
throw "The frontmatter name must match the directory: $entrypoint"
|
|
}
|
|
[pscustomobject]@{ Name = $metadata.name; Description = $metadata.description; Path = $directory.FullName }
|
|
}
|
|
}
|
|
|
|
function Get-XasFileDigest {
|
|
param([string]$Path)
|
|
# Use a read-only .NET stream: Windows PowerShell's Get-FileHash can be
|
|
# affected by an inherited WhatIfPreference even though hashing is read-only.
|
|
$algorithm = [Security.Cryptography.SHA256]::Create()
|
|
$stream = $null
|
|
try {
|
|
$stream = [IO.File]::OpenRead($Path)
|
|
return [BitConverter]::ToString($algorithm.ComputeHash($stream)).Replace('-', '').ToLowerInvariant()
|
|
} finally {
|
|
if ($null -ne $stream) { $stream.Dispose() }
|
|
$algorithm.Dispose()
|
|
}
|
|
}
|
|
|
|
function Get-XasTreeSnapshot {
|
|
param([string]$Directory, [switch]$ExcludeState)
|
|
Assert-XasNoLinkPath $Directory
|
|
if (-not (Test-Path -LiteralPath $Directory -PathType Container)) { throw "Directory not found: $Directory" }
|
|
$root = Get-XasFullPath $Directory
|
|
$files = New-Object 'System.Collections.Generic.List[object]'
|
|
$directories = New-Object 'System.Collections.Generic.List[string]'
|
|
$pending = New-Object 'System.Collections.Generic.Queue[string]'
|
|
$pending.Enqueue($root)
|
|
while ($pending.Count -gt 0) {
|
|
foreach ($item in @(Get-ChildItem -LiteralPath $pending.Dequeue() -Force)) {
|
|
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
|
throw "Links/reparse points are not supported: $($item.FullName)"
|
|
}
|
|
$relative = $item.FullName.Substring($root.Length + 1).Replace('\', '/')
|
|
if ($ExcludeState -and $relative -ieq $script:StateFile) { continue }
|
|
if ($item.PSIsContainer) {
|
|
$directories.Add($relative)
|
|
$pending.Enqueue($item.FullName)
|
|
} else {
|
|
$files.Add([pscustomobject]@{ path = $relative; sha256 = Get-XasFileDigest $item.FullName })
|
|
}
|
|
}
|
|
}
|
|
return [pscustomobject]@{
|
|
files = @($files.ToArray() | Sort-Object path)
|
|
directories = @($directories.ToArray() | Sort-Object)
|
|
}
|
|
}
|
|
|
|
function Get-XasSnapshotKey {
|
|
param($Snapshot)
|
|
$entries = @(@($Snapshot.directories) | Sort-Object | ForEach-Object { 'D:' + $_ })
|
|
$entries += @(@($Snapshot.files) | Sort-Object path | ForEach-Object { 'F:' + $_.path + ':' + $_.sha256.ToLowerInvariant() })
|
|
return $entries -join "`n"
|
|
}
|
|
|
|
function Read-XasState {
|
|
param([string]$Directory, [string]$Name, [switch]$AllowForeign)
|
|
Assert-XasNoLinkPath $Directory
|
|
$statePath = Join-Path $Directory $script:StateFile
|
|
if (-not (Test-Path -LiteralPath $statePath -PathType Leaf)) {
|
|
throw "Existing skill is not managed by xas-skills; nothing was overwritten: $Directory"
|
|
}
|
|
Assert-XasNoLinkPath $statePath
|
|
try { $state = [IO.File]::ReadAllText($statePath) | ConvertFrom-Json } catch { throw "Invalid installation record: $statePath" }
|
|
if ($null -eq $state -or @($state.PSObject.Properties.Name) -notcontains 'repository') { throw "Invalid installation record: $statePath" }
|
|
if ($state.repository -cne $script:RepositoryUrl) {
|
|
if ($AllowForeign) { return $null }
|
|
throw "Skill belongs to another source; nothing was overwritten: $Directory"
|
|
}
|
|
foreach ($field in @('schemaVersion', 'skill', 'files', 'directories')) {
|
|
if (@($state.PSObject.Properties.Name) -notcontains $field) { throw "Incomplete installation record: $statePath" }
|
|
}
|
|
if ($state.schemaVersion -ne 1 -or $state.skill -cne $Name -or $null -eq $state.files -or $null -eq $state.directories) {
|
|
throw "Invalid installation record: $statePath"
|
|
}
|
|
$seen = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
|
|
foreach ($file in @($state.files)) {
|
|
if ($null -eq $file -or @($file.PSObject.Properties.Name) -notcontains 'path' -or
|
|
@($file.PSObject.Properties.Name) -notcontains 'sha256' -or $file.sha256 -notmatch '^[a-fA-F0-9]{64}$') {
|
|
throw "Invalid file digest in installation record: $statePath"
|
|
}
|
|
}
|
|
foreach ($relative in @(@($state.files | ForEach-Object { $_.path }) + @($state.directories))) {
|
|
if ([string]::IsNullOrWhiteSpace($relative) -or $relative -match '(^[\\/]|:|\\|(^|/)\.\.?(/|$))' -or
|
|
$relative -ieq $script:StateFile -or -not $seen.Add($relative)) {
|
|
throw "Invalid file list in installation record: $statePath"
|
|
}
|
|
}
|
|
return $state
|
|
}
|
|
|
|
function Get-XasInstalledSkillNames {
|
|
[CmdletBinding()]
|
|
param([Parameter(Mandatory)][string]$SkillsRoot)
|
|
Assert-XasNoLinkPath $SkillsRoot
|
|
if (-not (Test-Path -LiteralPath $SkillsRoot -PathType Container)) { return }
|
|
foreach ($directory in @(Get-ChildItem -LiteralPath $SkillsRoot -Directory -Force | Sort-Object Name)) {
|
|
$statePath = Join-Path $directory.FullName $script:StateFile
|
|
if (-not (Test-Path -LiteralPath $statePath)) { continue }
|
|
Assert-XasSkillName $directory.Name
|
|
$state = Read-XasState -Directory $directory.FullName -Name $directory.Name -AllowForeign
|
|
if ($null -ne $state) { $directory.Name }
|
|
}
|
|
}
|
|
|
|
function Move-XasDirectory {
|
|
param([string]$Source, [string]$Destination)
|
|
[IO.Directory]::Move($Source, $Destination)
|
|
}
|
|
|
|
function Remove-XasWorkDirectory {
|
|
param([string]$Path, [string]$WorkRoot)
|
|
if (-not (Test-XasWithin -Path $Path -Root $WorkRoot)) { throw "Refusing cleanup outside workspace: $Path" }
|
|
if (Test-Path -LiteralPath $Path) {
|
|
$null = Get-XasTreeSnapshot -Directory $Path
|
|
Remove-Item -LiteralPath $Path -Recurse -Force
|
|
}
|
|
}
|
|
|
|
function Copy-XasTree {
|
|
param([string]$Source, [string]$Destination, $Snapshot)
|
|
$null = [IO.Directory]::CreateDirectory($Destination)
|
|
foreach ($relative in @($Snapshot.directories)) {
|
|
$null = [IO.Directory]::CreateDirectory((Join-Path $Destination $relative))
|
|
}
|
|
foreach ($file in @($Snapshot.files)) {
|
|
$sourcePath = Join-Path $Source $file.path
|
|
Assert-XasNoLinkPath $sourcePath
|
|
[IO.File]::Copy($sourcePath, (Join-Path $Destination $file.path), $false)
|
|
}
|
|
}
|
|
|
|
function Invoke-XasInstall {
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory)][string]$RepositoryRoot,
|
|
[Parameter(Mandatory)][string]$SkillsRoot,
|
|
[Parameter(Mandatory)][string[]]$Skill,
|
|
[switch]$Preview
|
|
)
|
|
$sourceRoot = Get-XasFullPath $RepositoryRoot
|
|
$targetRoot = Get-XasFullPath $SkillsRoot
|
|
if ($sourceRoot -ieq $targetRoot -or (Test-XasWithin $targetRoot $sourceRoot) -or (Test-XasWithin $sourceRoot $targetRoot)) {
|
|
throw 'The repository and installation directories must not overlap.'
|
|
}
|
|
Assert-XasNoLinkPath $sourceRoot
|
|
Assert-XasNoLinkPath $targetRoot
|
|
if ((Test-Path -LiteralPath $targetRoot) -and -not (Test-Path -LiteralPath $targetRoot -PathType Container)) {
|
|
throw "Installation root is not a directory: $targetRoot"
|
|
}
|
|
$catalog = @(Get-XasSkillCatalog -RepositoryRoot $sourceRoot)
|
|
$plan = New-Object 'System.Collections.Generic.List[object]'
|
|
# Validate the entire batch before creating a directory or replacing any skill.
|
|
foreach ($name in @($Skill | Select-Object -Unique)) {
|
|
Assert-XasSkillName $name
|
|
$entry = @($catalog | Where-Object { $_.Name -ceq $name })
|
|
if ($entry.Count -ne 1) { throw "Skill not found in this repository: $name" }
|
|
$source = $entry[0].Path
|
|
if (Test-Path -LiteralPath (Join-Path $source $script:StateFile)) { throw "Source contains a reserved installation record: $source" }
|
|
$snapshot = Get-XasTreeSnapshot -Directory $source
|
|
$destination = Join-Path $targetRoot $name
|
|
Assert-XasNoLinkPath $destination
|
|
$exists = Test-Path -LiteralPath $destination
|
|
$status = 'Installed'
|
|
$previous = $null
|
|
if ($exists) {
|
|
if (-not (Test-Path -LiteralPath $destination -PathType Container)) { throw "Destination is not a directory: $destination" }
|
|
$previous = Read-XasState -Directory $destination -Name $name
|
|
$actual = Get-XasTreeSnapshot -Directory $destination -ExcludeState
|
|
if ((Get-XasSnapshotKey $actual) -cne (Get-XasSnapshotKey $previous)) {
|
|
throw "Installed skill has local additions, changes or deletions; preserve them before updating: $destination"
|
|
}
|
|
$status = 'Updated'
|
|
if ((Get-XasSnapshotKey $snapshot) -ceq (Get-XasSnapshotKey $actual)) { $status = 'Skipped' }
|
|
}
|
|
$plan.Add([pscustomobject]@{ Name = $name; Source = $source; Destination = $destination; Snapshot = $snapshot; Previous = $previous; Status = $status; Exists = $exists })
|
|
}
|
|
foreach ($operation in $plan) {
|
|
$status = $operation.Status
|
|
if ($status -eq 'Skipped') { [pscustomobject]@{ Name = $operation.Name; Status = $status }; continue }
|
|
if ($Preview) {
|
|
if ($operation.Exists) { $status = 'WouldUpdate' } else { $status = 'WouldInstall' }
|
|
[pscustomobject]@{ Name = $operation.Name; Status = $status }
|
|
continue
|
|
}
|
|
$workRoot = Join-Path ([IO.Path]::GetDirectoryName($targetRoot)) '.xas-skills-work'
|
|
Assert-XasNoLinkPath $workRoot
|
|
$work = Join-Path $workRoot ([Guid]::NewGuid().ToString('N'))
|
|
$staging = Join-Path $work 'new'
|
|
$backup = Join-Path $work 'old'
|
|
$preserveRecovery = $false
|
|
try {
|
|
Copy-XasTree -Source $operation.Source -Destination $staging -Snapshot $operation.Snapshot
|
|
if ((Get-XasSnapshotKey (Get-XasTreeSnapshot $staging)) -cne (Get-XasSnapshotKey $operation.Snapshot)) {
|
|
throw "Source changed during copying: $($operation.Source)"
|
|
}
|
|
$record = [ordered]@{
|
|
schemaVersion = 1
|
|
repository = $script:RepositoryUrl
|
|
skill = $operation.Name
|
|
files = @($operation.Snapshot.files)
|
|
directories = @($operation.Snapshot.directories)
|
|
}
|
|
[IO.File]::WriteAllText((Join-Path $staging $script:StateFile), ($record | ConvertTo-Json -Depth 8), (New-Object Text.UTF8Encoding $false))
|
|
Assert-XasNoLinkPath $targetRoot
|
|
$null = [IO.Directory]::CreateDirectory($targetRoot)
|
|
if ($operation.Exists) {
|
|
$current = Get-XasTreeSnapshot -Directory $operation.Destination -ExcludeState
|
|
$currentRecord = Read-XasState -Directory $operation.Destination -Name $operation.Name
|
|
if ((Get-XasSnapshotKey $current) -cne (Get-XasSnapshotKey $operation.Previous) -or
|
|
(Get-XasSnapshotKey $currentRecord) -cne (Get-XasSnapshotKey $operation.Previous)) {
|
|
throw "Installed skill changed during update: $($operation.Destination)"
|
|
}
|
|
Move-XasDirectory -Source $operation.Destination -Destination $backup
|
|
} elseif (Test-Path -LiteralPath $operation.Destination) {
|
|
throw "Destination appeared during installation: $($operation.Destination)"
|
|
}
|
|
try {
|
|
Move-XasDirectory -Source $staging -Destination $operation.Destination
|
|
} catch {
|
|
$failure = $_
|
|
if (Test-Path -LiteralPath $backup) {
|
|
try { Move-XasDirectory -Source $backup -Destination $operation.Destination } catch {
|
|
$preserveRecovery = $true
|
|
throw "Replacement and rollback failed. Recover the old skill from: $backup"
|
|
}
|
|
}
|
|
throw $failure
|
|
}
|
|
[pscustomobject]@{ Name = $operation.Name; Status = $status }
|
|
} finally {
|
|
if (-not $preserveRecovery) { Remove-XasWorkDirectory -Path $work -WorkRoot $workRoot }
|
|
}
|
|
}
|
|
}
|
|
|
|
Export-ModuleMember -Function Get-XasDefaultSkillsRoot, Get-XasSkillCatalog, Get-XasInstalledSkillNames, Invoke-XasInstall
|