chore: initialize xas skills repository
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
# Keep arguments as data: never build a PowerShell -Command expression from them.
|
||||
$cliTokens = @($args | ForEach-Object { [string]$_ })
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repositoryRoot = Split-Path -Parent $PSScriptRoot
|
||||
|
||||
function Show-XasUsage {
|
||||
Write-Host @'
|
||||
Usage:
|
||||
.\skills list
|
||||
.\skills install <name> [more-names...]
|
||||
.\skills install --all
|
||||
.\skills update
|
||||
.\skills help
|
||||
|
||||
Add --preview to install or update to inspect the operation without writing.
|
||||
Example: .\skills install xas-skill-review --preview
|
||||
'@
|
||||
}
|
||||
|
||||
try {
|
||||
if ($cliTokens.Count -eq 0) { Show-XasUsage; exit 0 }
|
||||
$command = $cliTokens[0].ToLowerInvariant()
|
||||
$remaining = @($cliTokens | Select-Object -Skip 1)
|
||||
if ($command -in @('help', '--help', '-h', '/?')) {
|
||||
if ($remaining.Count -gt 0) { throw 'The help command does not accept additional arguments.' }
|
||||
Show-XasUsage
|
||||
exit 0
|
||||
}
|
||||
switch ($command) {
|
||||
'list' {
|
||||
if ($remaining.Count -gt 0) { throw 'Usage: .\skills list' }
|
||||
& (Join-Path $repositoryRoot 'install.ps1') -List
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
'install' {
|
||||
$selected = New-Object 'System.Collections.Generic.List[string]'
|
||||
$preview = $false
|
||||
$all = $false
|
||||
foreach ($token in $remaining) {
|
||||
switch ($token.ToLowerInvariant()) {
|
||||
'--preview' {
|
||||
if ($preview) { throw 'Specify --preview only once.' }
|
||||
$preview = $true
|
||||
}
|
||||
'--all' {
|
||||
if ($all) { throw 'Specify --all only once.' }
|
||||
$all = $true
|
||||
}
|
||||
default {
|
||||
if ($token -cnotmatch '^[a-z0-9]+(-[a-z0-9]+)*$') {
|
||||
throw "Invalid skill name or option: $token. Use .\skills list to view available names."
|
||||
}
|
||||
$selected.Add($token)
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($all -and $selected.Count -gt 0) { throw 'Choose either skill names or --all, not both.' }
|
||||
if (-not $all -and $selected.Count -eq 0) { throw 'Specify at least one skill name or --all. Use .\skills list to view available names.' }
|
||||
$parameters = @{ WhatIf = $preview }
|
||||
if ($all) { $parameters['All'] = $true } else { $parameters['Skill'] = $selected.ToArray() }
|
||||
& (Join-Path $repositoryRoot 'install.ps1') @parameters
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
'update' {
|
||||
if ($remaining.Count -gt 1 -or ($remaining.Count -eq 1 -and $remaining[0] -ine '--preview')) {
|
||||
throw 'Usage: .\skills update [--preview]'
|
||||
}
|
||||
& (Join-Path $repositoryRoot 'update.ps1') -WhatIf:($remaining.Count -eq 1)
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
default { throw "Unknown command: $command" }
|
||||
}
|
||||
} catch {
|
||||
Write-Error -Message $_.Exception.Message -ErrorAction Continue
|
||||
Show-XasUsage
|
||||
exit 2
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
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
|
||||
@@ -0,0 +1,183 @@
|
||||
Set-StrictMode -Version 2.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$script:XasRepositoryUrl = 'https://code.it.aisuan.cn/wenhuadian/xas-skills.git'
|
||||
|
||||
function Invoke-XasGit {
|
||||
param([string]$RepositoryRoot, [string[]]$Arguments)
|
||||
# Capture errors without printing Git's possibly credential-bearing diagnostics.
|
||||
$oldPreference = $ErrorActionPreference
|
||||
try {
|
||||
$ErrorActionPreference = 'Continue'
|
||||
# Prevent read-only status checks from refreshing the index on disk.
|
||||
$output = @(& git --no-optional-locks -C $RepositoryRoot @Arguments 2>&1)
|
||||
$code = $LASTEXITCODE
|
||||
return [pscustomobject]@{ ExitCode = $code; Output = (($output | ForEach-Object { [string]$_ }) -join "`n") }
|
||||
}
|
||||
catch {
|
||||
return [pscustomobject]@{ ExitCode = 1; Output = '' }
|
||||
}
|
||||
finally { $ErrorActionPreference = $oldPreference }
|
||||
}
|
||||
|
||||
function Invoke-XasCheckedGit {
|
||||
param([string]$RepositoryRoot, [string[]]$Arguments, [scriptblock]$GitRunner, [string]$Failure)
|
||||
$result = & $GitRunner $RepositoryRoot $Arguments
|
||||
if ($null -eq $result -or $result.ExitCode -ne 0) { throw $Failure }
|
||||
return ([string]$result.Output).Trim()
|
||||
}
|
||||
|
||||
function Invoke-XasFetch {
|
||||
param([string]$RepositoryRoot, [scriptblock]$GitRunner)
|
||||
$result = & $GitRunner $RepositoryRoot @('fetch', '--no-tags', 'origin', 'refs/heads/main:refs/remotes/origin/main')
|
||||
if ($null -ne $result -and $result.ExitCode -eq 0) { return }
|
||||
$detail = if ($null -eq $result) { '' } else { [string]$result.Output }
|
||||
# Classify locally; never repeat raw diagnostics, URLs, or credentials.
|
||||
if ($detail -match "couldn't find remote ref|could not find remote branch|remote ref.+(?:not found|does not exist)") {
|
||||
throw '远端 main 尚未发布或已删除;请联系仓库维护者。'
|
||||
}
|
||||
if ($detail -match 'authentication failed|could not read (?:username|password)|terminal prompts disabled|access denied|permission denied|(?:HTTP|error|status|returned).{0,20}\b(?:401|403)\b') {
|
||||
throw 'Git 认证或权限检查失败;请确认已登录公司 Git 且有仓库读取权限。'
|
||||
}
|
||||
if ($detail -match 'could not resolve|failed to connect|connection (?:timed out|reset|refused)|unable to access|SSL|TLS|certificate|network is unreachable|RPC failed') {
|
||||
throw '连接公司 Git 失败;请检查网络、代理以及 TLS 证书配置。'
|
||||
}
|
||||
throw '获取 origin/main 失败;请检查 Git 连接状态和远端 main,然后重试。'
|
||||
}
|
||||
|
||||
function Get-XasUpdateState {
|
||||
param([string]$RepositoryRoot, [scriptblock]$GitRunner)
|
||||
$root = [IO.Path]::GetFullPath($RepositoryRoot).TrimEnd([char[]]'\/')
|
||||
$gitRoot = Invoke-XasCheckedGit $root @('rev-parse', '--show-toplevel') $GitRunner '当前目录不是可更新的 Git 仓库。'
|
||||
if (-not [string]::Equals($root, [IO.Path]::GetFullPath($gitRoot).TrimEnd([char[]]'\/'), [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw '更新脚本必须位于仓库根目录。'
|
||||
}
|
||||
$url = Invoke-XasCheckedGit $root @('remote', 'get-url', 'origin') $GitRunner '找不到 origin;请重新克隆公司仓库。'
|
||||
if ($url.TrimEnd('/') -cne $script:XasRepositoryUrl -and $url.TrimEnd('/') -cne $script:XasRepositoryUrl.Substring(0, $script:XasRepositoryUrl.Length - 4)) {
|
||||
throw 'origin 不是指定的公司 xas-skills 仓库;请检查远端配置。'
|
||||
}
|
||||
$branch = Invoke-XasCheckedGit $root @('branch', '--show-current') $GitRunner '无法读取当前分支。'
|
||||
if ($branch -cne 'main') { throw '更新只支持 main 分支;请先切换分支。' }
|
||||
$head = Invoke-XasCheckedGit $root @('rev-parse', '--verify', 'HEAD') $GitRunner '仓库尚无提交;首次发布后才能更新。'
|
||||
if ($head -notmatch '^[0-9a-fA-F]{40}([0-9a-fA-F]{24})?$') { throw '无法确认当前提交。' }
|
||||
$upstream = Invoke-XasCheckedGit $root @('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}') $GitRunner 'main 尚未跟踪 origin/main;请先配置跟踪分支。'
|
||||
if ($upstream -cne 'origin/main') { throw 'main 必须跟踪 origin/main 才能更新。' }
|
||||
$status = Invoke-XasCheckedGit $root @('status', '--porcelain', '--untracked-files=all') $GitRunner '无法检查工作区状态。'
|
||||
if (-not [string]::IsNullOrWhiteSpace($status)) { throw '工作区有修改或未跟踪文件;请先保留并处理这些内容。' }
|
||||
$counts = Invoke-XasCheckedGit $root @('rev-list', '--left-right', '--count', 'HEAD...origin/main') $GitRunner 'origin/main 不存在或无法比较;首次发布后请重新克隆。'
|
||||
if ($counts -notmatch '^(\d+)\s+(\d+)$') { throw '无法确认本地与远端的提交关系。' }
|
||||
$ahead = [long]$Matches[1]
|
||||
$behind = [long]$Matches[2]
|
||||
if ($ahead -gt 0 -and $behind -gt 0) { throw '本地 main 与 origin/main 已分叉;请人工处理后再更新。' }
|
||||
if ($ahead -gt 0) { throw '本地 main 有尚未推送的提交;请人工处理后再更新。' }
|
||||
return [pscustomobject]@{ Head = $head; Behind = $behind }
|
||||
}
|
||||
|
||||
function Invoke-XasSyncInstalled {
|
||||
param([string]$RepositoryRoot, [string]$SkillsRoot, [switch]$Preview)
|
||||
Import-Module (Join-Path $RepositoryRoot 'scripts/XasSkills.psm1') -Force
|
||||
$installed = @(Get-XasInstalledSkillNames -SkillsRoot $SkillsRoot)
|
||||
$catalog = @(Get-XasSkillCatalog -RepositoryRoot $RepositoryRoot)
|
||||
$available = @($catalog | ForEach-Object { $_.Name })
|
||||
$selected = @()
|
||||
foreach ($name in $installed) {
|
||||
if ($available -contains $name) { $selected += $name }
|
||||
else { Write-Warning ("上游已移除技能 {0};保留已安装目录,请人工检查。" -f $name) }
|
||||
}
|
||||
if ($selected.Count -eq 0) {
|
||||
Write-Host '没有需要同步的已安装技能。'
|
||||
return
|
||||
}
|
||||
Invoke-XasInstall -RepositoryRoot $RepositoryRoot -SkillsRoot $SkillsRoot -Skill $selected -Preview:$Preview
|
||||
}
|
||||
|
||||
function Start-XasUpdateContinuation {
|
||||
param([string]$RepositoryRoot, [string]$SkillsRoot, [string]$ExpectedHead)
|
||||
$token = [Guid]::NewGuid().ToString('N')
|
||||
$executable = [Diagnostics.Process]::GetCurrentProcess().MainModule.FileName
|
||||
$entrypoint = Join-Path $RepositoryRoot 'update.ps1'
|
||||
$start = New-Object Diagnostics.ProcessStartInfo
|
||||
$start.FileName = $executable
|
||||
$start.Arguments = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "' + $entrypoint + '" -ContinueUpdate ' + $token
|
||||
$start.UseShellExecute = $false
|
||||
$start.CreateNoWindow = $true
|
||||
$start.RedirectStandardOutput = $true
|
||||
$start.RedirectStandardError = $true
|
||||
$start.StandardOutputEncoding = New-Object Text.UTF8Encoding($false)
|
||||
$start.StandardErrorEncoding = New-Object Text.UTF8Encoding($false)
|
||||
$start.EnvironmentVariables['XAS_SKILLS_UPDATE_TOKEN'] = $token
|
||||
$start.EnvironmentVariables['XAS_SKILLS_UPDATE_ROOT'] = $RepositoryRoot
|
||||
$start.EnvironmentVariables['XAS_SKILLS_UPDATE_TARGET'] = $SkillsRoot
|
||||
$start.EnvironmentVariables['XAS_SKILLS_UPDATE_HEAD'] = $ExpectedHead
|
||||
$process = New-Object Diagnostics.Process
|
||||
$process.StartInfo = $start
|
||||
try {
|
||||
if (-not $process.Start()) { throw '无法启动更新后的 PowerShell 脚本。' }
|
||||
$stdout = $process.StandardOutput.ReadToEndAsync()
|
||||
$stderr = $process.StandardError.ReadToEndAsync()
|
||||
$process.WaitForExit()
|
||||
$outText = $stdout.GetAwaiter().GetResult()
|
||||
$errText = $stderr.GetAwaiter().GetResult()
|
||||
if (-not [string]::IsNullOrWhiteSpace($outText)) { Write-Host $outText.TrimEnd() }
|
||||
if (-not [string]::IsNullOrWhiteSpace($errText)) { Write-Host $errText.TrimEnd() }
|
||||
if ($process.ExitCode -ne 0) { throw '仓库拉取已完成,但技能同步失败;请处理以上错误后重新运行 update.ps1。' }
|
||||
}
|
||||
finally { $process.Dispose() }
|
||||
}
|
||||
|
||||
function Invoke-XasUpdate {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RepositoryRoot,
|
||||
[Parameter(Mandatory = $true)][string]$SkillsRoot,
|
||||
[switch]$Preview,
|
||||
[scriptblock]$GitRunner = { param($root, $arguments) Invoke-XasGit $root $arguments },
|
||||
[scriptblock]$RestartRunner = { param($root, $target, $head) Start-XasUpdateContinuation $root $target $head },
|
||||
[scriptblock]$SyncRunner = { param($root, $target, $preview) Invoke-XasSyncInstalled $root $target -Preview:$preview }
|
||||
)
|
||||
$state = Get-XasUpdateState $RepositoryRoot $GitRunner
|
||||
if ($Preview) {
|
||||
Write-Host '预览:将获取 origin/main,验证只允许快进,再以新版脚本同步已安装技能。'
|
||||
Write-Host '本次不访问远端、不写入;以下技能预览基于当前本地仓库。'
|
||||
& $SyncRunner $RepositoryRoot $SkillsRoot $true
|
||||
return
|
||||
}
|
||||
# Discover existing installation conflicts before changing the source checkout.
|
||||
$null = & $SyncRunner $RepositoryRoot $SkillsRoot $true
|
||||
# Explicit refspec updates the exact tracking ref subsequently compared/merged.
|
||||
Invoke-XasFetch $RepositoryRoot $GitRunner
|
||||
$state = Get-XasUpdateState $RepositoryRoot $GitRunner
|
||||
$null = Invoke-XasCheckedGit $RepositoryRoot @('merge', '--ff-only', '--no-edit', 'origin/main') $GitRunner '快进更新失败;请检查仓库状态后重试。'
|
||||
$state = Get-XasUpdateState $RepositoryRoot $GitRunner
|
||||
if ($state.Behind -ne 0) { throw '更新后 HEAD 与 origin/main 不一致;未同步技能。' }
|
||||
& $RestartRunner $RepositoryRoot $SkillsRoot $state.Head
|
||||
}
|
||||
|
||||
function Invoke-XasUpdateContinuation {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RepositoryRoot,
|
||||
[Parameter(Mandatory = $true)][string]$Token,
|
||||
[scriptblock]$GitRunner = { param($root, $arguments) Invoke-XasGit $root $arguments },
|
||||
[scriptblock]$SyncRunner = { param($root, $target, $preview) Invoke-XasSyncInstalled $root $target -Preview:$preview }
|
||||
)
|
||||
$expectedToken = [Environment]::GetEnvironmentVariable('XAS_SKILLS_UPDATE_TOKEN', 'Process')
|
||||
$expectedRoot = [Environment]::GetEnvironmentVariable('XAS_SKILLS_UPDATE_ROOT', 'Process')
|
||||
$target = [Environment]::GetEnvironmentVariable('XAS_SKILLS_UPDATE_TARGET', 'Process')
|
||||
$expectedHead = [Environment]::GetEnvironmentVariable('XAS_SKILLS_UPDATE_HEAD', 'Process')
|
||||
# Consume the receipt before validation. It cannot cause repeated continuation.
|
||||
foreach ($key in @('TOKEN', 'ROOT', 'TARGET', 'HEAD')) {
|
||||
[Environment]::SetEnvironmentVariable(('XAS_SKILLS_UPDATE_' + $key), $null, 'Process')
|
||||
}
|
||||
if ($Token -notmatch '^[0-9a-f]{32}$' -or $Token -cne $expectedToken -or [string]::IsNullOrWhiteSpace($expectedRoot) -or [string]::IsNullOrWhiteSpace($target) -or $expectedHead -notmatch '^[0-9a-fA-F]{40}([0-9a-fA-F]{24})?$') {
|
||||
throw '内部更新续接无效;请直接运行 update.ps1。'
|
||||
}
|
||||
if (-not [string]::Equals([IO.Path]::GetFullPath($RepositoryRoot).TrimEnd([char[]]'\/'), [IO.Path]::GetFullPath($expectedRoot).TrimEnd([char[]]'\/'), [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw '更新续接的仓库目录不匹配。'
|
||||
}
|
||||
$state = Get-XasUpdateState $RepositoryRoot $GitRunner
|
||||
if ($state.Head -cne $expectedHead -or $state.Behind -ne 0) { throw '更新续接时仓库状态已改变;请重新运行 update.ps1。' }
|
||||
& $SyncRunner $RepositoryRoot $target $false
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Invoke-XasUpdate, Invoke-XasUpdateContinuation
|
||||
Reference in New Issue
Block a user