chore: initialize xas skills repository

This commit is contained in:
2026-09-20 15:59:30 +08:00
commit 1c92431021
15 changed files with 1983 additions and 0 deletions
+78
View File
@@ -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
}
+428
View File
@@ -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
+183
View File
@@ -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