Release v1.0.114: add sync.ps1 so new skills can mirror plaintext locally
All checks were successful
技能自动化发布 / release (push) Successful in 7s
All checks were successful
技能自动化发布 / release (push) Successful in 7s
This commit is contained in:
668
sync.ps1
Normal file
668
sync.ps1
Normal file
@@ -0,0 +1,668 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Sync this skill's plaintext source into the local OpenClaw skills directory.
|
||||
|
||||
.DESCRIPTION
|
||||
Local development only. Does not commit, tag, push, or trigger Gitea CI.
|
||||
release.ps1 remains the formal publish path.
|
||||
Ships in skill-template beside release.ps1. Scaffold copies this file into each new skill.
|
||||
|
||||
Mirrors this repo onto ~/.openclaw/skills/<slug> and removes marketplace
|
||||
pyarmor_runtime_* leftovers. Stop any running job for this skill first;
|
||||
otherwise the copy can fail because files are in use.
|
||||
|
||||
Dependencies follow the host skill:direct-install path:
|
||||
uv pip install --python <JIANGCHANG_DATA_ROOT>/python-runtime/.venv/Scripts/python.exe -r requirements.txt
|
||||
Packages are not installed into system Python. Unchanged requirements that
|
||||
are already present in the shared venv are skipped.
|
||||
|
||||
platform_kit_min_version is provided by jiangchang-platform-kit in the shared
|
||||
runtime. This script only checks it. It does not upgrade the kit. If the
|
||||
installed kit is too old, install or update the skill once from the market,
|
||||
or upgrade the host.
|
||||
|
||||
After the copy, this script runs scripts\main.py init-db the same way the
|
||||
host does after a market install. The user id is read from
|
||||
%APPDATA%\jiangchang\settings.json (jiangchangUserId), which the host writes
|
||||
on login and logout. It is never hardcoded. An empty id or _anon stops the
|
||||
script so the anonymous data folder is not initialized by mistake.
|
||||
-SkipInitDb skips that step. -SkipDeps still runs init-db.
|
||||
|
||||
No Chinese literals in this file: the skill repo requires UTF-8 without BOM,
|
||||
and Windows PowerShell 5.1 would mis-parse a no-BOM UTF-8 script.
|
||||
|
||||
.EXAMPLE
|
||||
.\sync.ps1
|
||||
.\sync.ps1 -DryRun
|
||||
.\sync.ps1 -ForceDeps
|
||||
.\sync.ps1 -SkipDeps
|
||||
.\sync.ps1 -SkipInitDb
|
||||
.\sync.ps1 -DataRoot D:\jiangchang-data
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Destination,
|
||||
[string]$DataRoot,
|
||||
[switch]$SkipDeps,
|
||||
[switch]$ForceDeps,
|
||||
[switch]$SkipInitDb,
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$SourceRoot = $PSScriptRoot
|
||||
$PypiMirrors = @(
|
||||
@{ Name = "aliyun"; Url = "https://mirrors.aliyun.com/pypi/simple/" },
|
||||
@{ Name = "tuna"; Url = "https://pypi.tuna.tsinghua.edu.cn/simple/" },
|
||||
@{ Name = "tencent"; Url = "https://mirrors.cloud.tencent.com/pypi/simple/" },
|
||||
@{ Name = "huawei"; Url = "https://repo.huaweicloud.com/repository/pypi/simple/" },
|
||||
@{ Name = "pypi"; Url = "https://pypi.org/simple/" }
|
||||
)
|
||||
|
||||
function Read-Utf8Text {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
return [System.IO.File]::ReadAllText($Path)
|
||||
}
|
||||
|
||||
function Get-SkillFrontmatter {
|
||||
param([Parameter(Mandatory = $true)][string]$SkillMdPath)
|
||||
$raw = Read-Utf8Text -Path $SkillMdPath
|
||||
if ($raw -notmatch '(?s)\A---\s*\r?\n(.*?)\r?\n---') {
|
||||
throw "SKILL.md is missing frontmatter: $SkillMdPath"
|
||||
}
|
||||
return $Matches[1]
|
||||
}
|
||||
|
||||
function Get-FrontmatterScalar {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Body,
|
||||
[Parameter(Mandatory = $true)][string]$Key
|
||||
)
|
||||
$pattern = '(?m)^\s*' + [regex]::Escape($Key) + ':\s*["'']?([^\s"''#]+)["'']?\s*$'
|
||||
$match = [regex]::Match($Body, $pattern)
|
||||
if (-not $match.Success) {
|
||||
return $null
|
||||
}
|
||||
return $match.Groups[1].Value.Trim()
|
||||
}
|
||||
|
||||
function Get-RequirementsLines {
|
||||
param([Parameter(Mandatory = $true)][string]$Content)
|
||||
$lines = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($raw in ($Content -split '\r?\n')) {
|
||||
$trimmed = $raw.Trim()
|
||||
if (-not $trimmed -or $trimmed.StartsWith('#')) {
|
||||
continue
|
||||
}
|
||||
$withoutInline = ($trimmed -split '\s+#', 2)[0].Trim()
|
||||
if ($withoutInline) {
|
||||
$lines.Add($withoutInline)
|
||||
}
|
||||
}
|
||||
return @($lines)
|
||||
}
|
||||
|
||||
function Get-RequirementsHash {
|
||||
param([Parameter(Mandatory = $true)][string]$Content)
|
||||
$normalized = (@(Get-RequirementsLines -Content $Content)) -join "`n"
|
||||
$sha = [System.Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($normalized)
|
||||
$hash = $sha.ComputeHash($bytes)
|
||||
} finally {
|
||||
$sha.Dispose()
|
||||
}
|
||||
return ([System.BitConverter]::ToString($hash) -replace '-', '').ToLowerInvariant()
|
||||
}
|
||||
|
||||
function Get-DistributionNames {
|
||||
param([Parameter(Mandatory = $true)][string[]]$Lines)
|
||||
$names = New-Object System.Collections.Generic.List[string]
|
||||
$seen = New-Object 'System.Collections.Generic.HashSet[string]'
|
||||
foreach ($line in $Lines) {
|
||||
$trimmed = $line.Trim()
|
||||
if (-not $trimmed -or $trimmed.StartsWith('#') -or $trimmed.StartsWith('-')) {
|
||||
continue
|
||||
}
|
||||
if ($trimmed -match '^(https?:|git\+|svn\+|hg\+|file:)') {
|
||||
continue
|
||||
}
|
||||
$match = [regex]::Match($trimmed, '^([A-Za-z0-9][A-Za-z0-9._-]*)')
|
||||
if (-not $match.Success) {
|
||||
continue
|
||||
}
|
||||
$name = $match.Groups[1].Value
|
||||
$key = $name.ToLowerInvariant().Replace('_', '-')
|
||||
if ($seen.Add($key)) {
|
||||
$names.Add($name)
|
||||
}
|
||||
}
|
||||
return @($names)
|
||||
}
|
||||
|
||||
function Resolve-SkillDataRoot {
|
||||
param([string]$Explicit)
|
||||
if ($Explicit -and $Explicit.Trim()) {
|
||||
return $Explicit.Trim().TrimEnd('\')
|
||||
}
|
||||
|
||||
$settingsPath = Join-Path $env:APPDATA "jiangchang\settings.json"
|
||||
if (Test-Path -LiteralPath $settingsPath) {
|
||||
try {
|
||||
$settings = Read-Utf8Text -Path $settingsPath | ConvertFrom-Json
|
||||
$fromSettings = [string]$settings.jiangchangSkillDataRoot
|
||||
if ($fromSettings -and $fromSettings.Trim()) {
|
||||
return $fromSettings.Trim().TrimEnd('\')
|
||||
}
|
||||
} catch {
|
||||
Write-Warning "Could not read $settingsPath. Falling back to the default data root. $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($candidate in @("D:\jiangchang-data", (Join-Path $env:USERPROFILE ".jiangchang-data"))) {
|
||||
$python = Join-Path $candidate "python-runtime\.venv\Scripts\python.exe"
|
||||
if (Test-Path -LiteralPath $python) {
|
||||
return $candidate
|
||||
}
|
||||
}
|
||||
|
||||
return "D:\jiangchang-data"
|
||||
}
|
||||
|
||||
function Resolve-JiangchangUserId {
|
||||
$settingsPath = Join-Path $env:APPDATA "jiangchang\settings.json"
|
||||
if (-not (Test-Path -LiteralPath $settingsPath)) {
|
||||
throw "Jiangchang settings were not found: $settingsPath`nOpen Jiangchang and log in, then run this script again."
|
||||
}
|
||||
try {
|
||||
$settings = Read-Utf8Text -Path $settingsPath | ConvertFrom-Json
|
||||
} catch {
|
||||
throw "Could not read the logged-in user from $settingsPath. $($_.Exception.Message)"
|
||||
}
|
||||
$userId = ""
|
||||
if ($null -ne $settings -and $settings.PSObject.Properties.Name -contains "jiangchangUserId" -and $null -ne $settings.jiangchangUserId) {
|
||||
$userId = ([string]$settings.jiangchangUserId).Trim()
|
||||
}
|
||||
if (-not $userId -or $userId -eq "_anon") {
|
||||
throw "Jiangchang has no logged-in user. Open Jiangchang and log in, then run this script again. This script does not guess a user id."
|
||||
}
|
||||
$normalized = $userId.Replace("\", "/")
|
||||
if ($normalized.Contains("..") -or $normalized.Contains("/") -or $userId.StartsWith("\") -or $userId -match '^[A-Za-z]:') {
|
||||
throw "jiangchangUserId is not a single folder name: $userId"
|
||||
}
|
||||
return $userId
|
||||
}
|
||||
|
||||
function Test-InitDbJsonOk {
|
||||
param([Parameter(Mandatory = $true)][string]$Text)
|
||||
foreach ($line in ($Text -split "`r?`n")) {
|
||||
$trimmed = $line.Trim()
|
||||
if (-not $trimmed.StartsWith("{")) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
$parsed = $trimmed | ConvertFrom-Json
|
||||
if ($parsed.ok -eq $true) {
|
||||
return $true
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Test-InitDbUnknownCommand {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Text,
|
||||
[Parameter(Mandatory = $true)][int]$ExitCode
|
||||
)
|
||||
if ($ExitCode -eq 2) {
|
||||
return $true
|
||||
}
|
||||
$lower = $Text.ToLowerInvariant()
|
||||
if ($lower.Contains("invalid choice") -or $lower.Contains("unrecognized arguments") -or $lower.Contains("unknown command")) {
|
||||
return $true
|
||||
}
|
||||
if ($lower.Contains("invalid") -and $lower.Contains("init-db")) {
|
||||
return $true
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Invoke-SkillInitDb {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$PythonExe,
|
||||
[Parameter(Mandatory = $true)][string]$SkillDir,
|
||||
[Parameter(Mandatory = $true)][string]$DataRoot,
|
||||
[Parameter(Mandatory = $true)][string]$UserId,
|
||||
[switch]$DryRun
|
||||
)
|
||||
$mainPy = Join-Path $SkillDir "scripts\main.py"
|
||||
if (-not (Test-Path -LiteralPath $mainPy)) {
|
||||
Write-Host "No scripts\main.py. Skipping init-db."
|
||||
return
|
||||
}
|
||||
if ($DryRun) {
|
||||
Write-Host "DryRun: would run init-db" -ForegroundColor Yellow
|
||||
Write-Host " python $PythonExe"
|
||||
Write-Host " script $mainPy init-db"
|
||||
Write-Host " data $DataRoot"
|
||||
Write-Host " user $UserId"
|
||||
return
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $PythonExe)) {
|
||||
throw "Shared Python was not found: $PythonExe`nOpen Jiangchang and wait for the shared runtime, or pass -DataRoot."
|
||||
}
|
||||
|
||||
Write-Host ">> Initialize the logged-in user's database (init-db)" -ForegroundColor DarkGray
|
||||
Write-Host " user=$UserId"
|
||||
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $PythonExe
|
||||
$psi.WorkingDirectory = $SkillDir
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.Arguments = '"' + $mainPy + '" init-db'
|
||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
|
||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8
|
||||
$psi.EnvironmentVariables["JIANGCHANG_DATA_ROOT"] = $DataRoot
|
||||
$psi.EnvironmentVariables["JIANGCHANG_USER_ID"] = $UserId
|
||||
$psi.EnvironmentVariables["JIANGCHANG_RUN_MODE"] = "cli"
|
||||
$psi.EnvironmentVariables["PYTHONIOENCODING"] = "utf-8"
|
||||
|
||||
$proc = New-Object System.Diagnostics.Process
|
||||
$proc.StartInfo = $psi
|
||||
[void]$proc.Start()
|
||||
$stdoutTask = $proc.StandardOutput.ReadToEndAsync()
|
||||
$stderrTask = $proc.StandardError.ReadToEndAsync()
|
||||
if (-not $proc.WaitForExit(60000)) {
|
||||
try { $proc.Kill() } catch { }
|
||||
throw "init-db timed out after 60 seconds."
|
||||
}
|
||||
$proc.WaitForExit()
|
||||
$stdout = $stdoutTask.Result
|
||||
$stderr = $stderrTask.Result
|
||||
$combined = ($stdout + "`n" + $stderr).Trim()
|
||||
if ($stdout.Trim()) {
|
||||
Write-Host $stdout.Trim()
|
||||
}
|
||||
if ($stderr.Trim()) {
|
||||
Write-Host $stderr.Trim()
|
||||
}
|
||||
|
||||
if (Test-InitDbJsonOk -Text $combined) {
|
||||
Write-Host "init-db finished for user $UserId." -ForegroundColor Green
|
||||
return
|
||||
}
|
||||
if ($proc.ExitCode -eq 0 -and $combined -match '(?m)^OK:\s*INIT_DB\b') {
|
||||
Write-Host "init-db finished for user $UserId." -ForegroundColor Green
|
||||
return
|
||||
}
|
||||
if (Test-InitDbUnknownCommand -Text $combined -ExitCode $proc.ExitCode) {
|
||||
Write-Warning "This skill has no init-db command. Database initialization was skipped."
|
||||
return
|
||||
}
|
||||
|
||||
$detail = $stderr.Trim()
|
||||
if (-not $detail) {
|
||||
$detail = $stdout.Trim()
|
||||
}
|
||||
if ($detail.Length -gt 500) {
|
||||
$detail = $detail.Substring($detail.Length - 500)
|
||||
}
|
||||
if (-not $detail) {
|
||||
$detail = "init-db exited with code $($proc.ExitCode)"
|
||||
}
|
||||
throw "init-db failed for user ${UserId}: $detail"
|
||||
}
|
||||
|
||||
function Resolve-UvExecutable {
|
||||
$cmd = Get-Command uv -ErrorAction SilentlyContinue
|
||||
if ($cmd -and $cmd.Source) {
|
||||
return $cmd.Source
|
||||
}
|
||||
throw "uv was not found on PATH. The host installs skill requirements with uv pip, not pip."
|
||||
}
|
||||
|
||||
function Test-DistributionsPresent {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$PythonExe,
|
||||
[Parameter(Mandatory = $true)][string[]]$Names
|
||||
)
|
||||
if ($Names.Count -eq 0) {
|
||||
return $false
|
||||
}
|
||||
$probe = @'
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
import sys
|
||||
missing = []
|
||||
for n in sys.argv[1:]:
|
||||
try:
|
||||
version(n)
|
||||
except PackageNotFoundError:
|
||||
missing.append(n)
|
||||
sys.stderr.write("missing:" + ",".join(missing) if missing else "")
|
||||
raise SystemExit(1 if missing else 0)
|
||||
'@
|
||||
$prev = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
& $PythonExe -c $probe @Names 2>&1 | Out-Null
|
||||
return ($LASTEXITCODE -eq 0)
|
||||
} finally {
|
||||
$ErrorActionPreference = $prev
|
||||
}
|
||||
}
|
||||
|
||||
function Get-InstalledDistributionVersion {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$PythonExe,
|
||||
[Parameter(Mandatory = $true)][string]$Name
|
||||
)
|
||||
$prev = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
$output = & $PythonExe -c "from importlib.metadata import version, PackageNotFoundError`nimport sys`nname=sys.argv[1]`ntry:`n print(version(name))`nexcept PackageNotFoundError:`n print('')" $Name 2>$null
|
||||
$code = $LASTEXITCODE
|
||||
} finally {
|
||||
$ErrorActionPreference = $prev
|
||||
}
|
||||
if ($code -ne 0) {
|
||||
return $null
|
||||
}
|
||||
return ([string]$output).Trim()
|
||||
}
|
||||
|
||||
function Compare-VersionPrefix {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Installed,
|
||||
[Parameter(Mandatory = $true)][string]$Required
|
||||
)
|
||||
function Convert-ToVersion([string]$Text) {
|
||||
$core = ($Text -split '[^0-9.]', 2)[0].Trim('.')
|
||||
$parts = @($core -split '\.' | Where-Object { $_ -ne '' })
|
||||
while ($parts.Count -lt 3) {
|
||||
$parts += '0'
|
||||
}
|
||||
$take = @($parts | Select-Object -First 3)
|
||||
return [version]($take -join '.')
|
||||
}
|
||||
try {
|
||||
return (Convert-ToVersion $Installed) -ge (Convert-ToVersion $Required)
|
||||
} catch {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-RobocopyMirror {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Source,
|
||||
[Parameter(Mandatory = $true)][string]$Dest,
|
||||
[switch]$ListOnly
|
||||
)
|
||||
$excludeDirs = @(
|
||||
".git", ".github", "__pycache__", ".pytest_cache", "htmlcov",
|
||||
"dist", "build", ".venv", "_fix_workspace", ".skill-requirements"
|
||||
)
|
||||
$excludeFiles = @(".env", "*.env.local", "*.pyc", "*.pyo")
|
||||
$robocopyArgs = @($Source, $Dest, "/MIR", "/R:2", "/W:1", "/XD") + $excludeDirs + @("/XF") + $excludeFiles
|
||||
if ($ListOnly) {
|
||||
$robocopyArgs += "/L"
|
||||
}
|
||||
& robocopy.exe @robocopyArgs
|
||||
$code = $LASTEXITCODE
|
||||
if ($code -ge 8) {
|
||||
throw "Copy failed (robocopy exit $code). Stop the running skill job in Task Center, then retry."
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-PyarmorRuntimes {
|
||||
param([Parameter(Mandatory = $true)][string]$Dest)
|
||||
$removed = @()
|
||||
$dirs = @(Get-ChildItem -LiteralPath $Dest -Recurse -Directory -Force -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -like "pyarmor_runtime_*" })
|
||||
foreach ($dir in $dirs) {
|
||||
Remove-Item -LiteralPath $dir.FullName -Recurse -Force
|
||||
$removed += $dir.FullName
|
||||
}
|
||||
return $removed
|
||||
}
|
||||
|
||||
function Assert-PlainMainPy {
|
||||
param([Parameter(Mandatory = $true)][string]$MainPy)
|
||||
if (-not (Test-Path -LiteralPath $MainPy)) {
|
||||
throw "scripts\main.py is missing after sync: $MainPy"
|
||||
}
|
||||
$head = Read-Utf8Text -Path $MainPy
|
||||
if ($head -match 'pyarmor_runtime_|__pyarmor__') {
|
||||
throw "scripts\main.py is still PyArmor-encrypted. Re-run .\sync.ps1 so the whole scripts tree is replaced."
|
||||
}
|
||||
}
|
||||
|
||||
function Write-RequirementsState {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RuntimeDir,
|
||||
[Parameter(Mandatory = $true)][string]$Slug,
|
||||
[Parameter(Mandatory = $true)][string]$RequirementsPath,
|
||||
[Parameter(Mandatory = $true)][string]$RequirementsHash,
|
||||
[string]$RuntimeFingerprint
|
||||
)
|
||||
$stateDir = Join-Path $RuntimeDir ".skill-requirements"
|
||||
New-Item -ItemType Directory -Force -Path $stateDir | Out-Null
|
||||
$state = [ordered]@{
|
||||
skill = $Slug
|
||||
requirementsPath = $RequirementsPath
|
||||
requirementsHash = $RequirementsHash
|
||||
runtimeFingerprint = $RuntimeFingerprint
|
||||
installedAt = [DateTime]::UtcNow.ToString("o")
|
||||
status = "ok"
|
||||
}
|
||||
$json = ($state | ConvertTo-Json -Depth 4) + "`n"
|
||||
$statePath = Join-Path $stateDir "$Slug.json"
|
||||
$utf8 = New-Object System.Text.UTF8Encoding $false
|
||||
[System.IO.File]::WriteAllText($statePath, $json, $utf8)
|
||||
}
|
||||
|
||||
$skillMd = Join-Path $SourceRoot "SKILL.md"
|
||||
$sourceMain = Join-Path $SourceRoot "scripts\main.py"
|
||||
$sourceReq = Join-Path $SourceRoot "requirements.txt"
|
||||
if (-not (Test-Path -LiteralPath $skillMd)) {
|
||||
throw "SKILL.md not found: $skillMd"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $sourceMain)) {
|
||||
throw "scripts\main.py not found: $sourceMain"
|
||||
}
|
||||
|
||||
$frontmatter = Get-SkillFrontmatter -SkillMdPath $skillMd
|
||||
$slug = Get-FrontmatterScalar -Body $frontmatter -Key "slug"
|
||||
$version = Get-FrontmatterScalar -Body $frontmatter -Key "version"
|
||||
$platformKitMin = Get-FrontmatterScalar -Body $frontmatter -Key "platform_kit_min_version"
|
||||
if (-not $slug) {
|
||||
throw "metadata.openclaw.slug in SKILL.md is empty, so the install directory cannot be determined."
|
||||
}
|
||||
|
||||
if (-not $Destination -or -not $Destination.Trim()) {
|
||||
$Destination = Join-Path $env:USERPROFILE ".openclaw\skills\$slug"
|
||||
}
|
||||
$Destination = $Destination.Trim().TrimEnd('\')
|
||||
|
||||
$sourceFull = [System.IO.Path]::GetFullPath($SourceRoot)
|
||||
$destFull = [System.IO.Path]::GetFullPath($Destination)
|
||||
if ($sourceFull.TrimEnd('\') -eq $destFull.TrimEnd('\')) {
|
||||
throw "Source and destination are the same directory: $sourceFull"
|
||||
}
|
||||
|
||||
$resolvedDataRoot = Resolve-SkillDataRoot -Explicit $DataRoot
|
||||
$runtimeDir = Join-Path $resolvedDataRoot "python-runtime"
|
||||
$pythonExe = Join-Path $runtimeDir ".venv\Scripts\python.exe"
|
||||
$resolvedUserId = $null
|
||||
if (-not $SkipInitDb) {
|
||||
$resolvedUserId = Resolve-JiangchangUserId
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Local skill sync (does not publish to the skill market)" -ForegroundColor Cyan
|
||||
Write-Host " source $sourceFull"
|
||||
Write-Host " dest $destFull"
|
||||
Write-Host " slug $slug"
|
||||
Write-Host " version $(if ($version) { $version } else { '(none)' })"
|
||||
Write-Host " data $resolvedDataRoot"
|
||||
Write-Host " python $pythonExe"
|
||||
Write-Host " user $(if ($SkipInitDb) { '(init-db skipped)' } else { $resolvedUserId })"
|
||||
if ($DryRun) {
|
||||
Write-Host " mode DryRun (no writes)" -ForegroundColor Yellow
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
if (-not (Test-Path -LiteralPath $destFull)) {
|
||||
Write-Warning "Destination does not exist yet. Files can be created, but if this skill was never installed from the market the host may not have enabled it. Install it once, then use this script to overwrite."
|
||||
}
|
||||
|
||||
$configPath = Join-Path $env:USERPROFILE ".openclaw\openclaw.json"
|
||||
if (Test-Path -LiteralPath $configPath) {
|
||||
try {
|
||||
$config = Read-Utf8Text -Path $configPath | ConvertFrom-Json
|
||||
$entry = $null
|
||||
if ($config.PSObject.Properties.Name -contains "skills" -and $null -ne $config.skills -and $config.skills.PSObject.Properties.Name -contains "entries" -and $null -ne $config.skills.entries) {
|
||||
$entryProp = $config.skills.entries.PSObject.Properties[$slug]
|
||||
if ($entryProp) {
|
||||
$entry = $entryProp.Value
|
||||
}
|
||||
}
|
||||
if ($null -eq $entry) {
|
||||
Write-Warning "openclaw.json has no skills.entries.$slug. Install the skill once from the market, or chat and data management may not see it."
|
||||
} elseif ($entry.PSObject.Properties.Name -contains "enabled" -and $entry.enabled -eq $false) {
|
||||
Write-Warning "skills.entries.$slug is disabled. Syncing files does not re-enable it."
|
||||
}
|
||||
} catch {
|
||||
Write-Warning "Could not check openclaw.json enablement: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ">> Mirror source onto the install directory (exclude .git, drop encrypted runtime)" -ForegroundColor DarkGray
|
||||
Invoke-RobocopyMirror -Source $sourceFull -Dest $destFull -ListOnly:$DryRun
|
||||
if (-not $DryRun) {
|
||||
$removed = @(Remove-PyarmorRuntimes -Dest $destFull)
|
||||
foreach ($path in $removed) {
|
||||
Write-Host " removed $path"
|
||||
}
|
||||
Assert-PlainMainPy -MainPy (Join-Path $destFull "scripts\main.py")
|
||||
Write-Host "scripts\main.py is plaintext." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "DryRun: pyarmor_runtime_* was not deleted, and no destination file was changed." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
$skipInstall = $false
|
||||
if ($SkipDeps) {
|
||||
Write-Host "Skipped dependency install (-SkipDeps)." -ForegroundColor Yellow
|
||||
$skipInstall = $true
|
||||
} elseif (-not (Test-Path -LiteralPath $pythonExe)) {
|
||||
throw "Shared Python was not found: $pythonExe`nOpen Jiangchang and wait for the shared runtime, or pass -DataRoot. If the copy already finished, install dependencies before running the skill."
|
||||
} else {
|
||||
$reqPath = if ($DryRun) { $sourceReq } else { Join-Path $destFull "requirements.txt" }
|
||||
if (-not (Test-Path -LiteralPath $reqPath)) {
|
||||
Write-Host "No requirements.txt. Skipping dependency install."
|
||||
$skipInstall = $true
|
||||
} else {
|
||||
$reqContent = Read-Utf8Text -Path $reqPath
|
||||
$reqLines = @(Get-RequirementsLines -Content $reqContent)
|
||||
if ($reqLines.Count -eq 0) {
|
||||
Write-Host "requirements.txt has no effective lines. Skipping install."
|
||||
$skipInstall = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $skipInstall) {
|
||||
|
||||
$reqHash = Get-RequirementsHash -Content $reqContent
|
||||
$stampPath = Join-Path $runtimeDir ".jiangchang-runtime-stamp.json"
|
||||
$runtimeFingerprint = ""
|
||||
if (Test-Path -LiteralPath $stampPath) {
|
||||
try {
|
||||
$stamp = Read-Utf8Text -Path $stampPath | ConvertFrom-Json
|
||||
$runtimeFingerprint = [string]$stamp.fingerprint
|
||||
} catch {
|
||||
$runtimeFingerprint = ""
|
||||
}
|
||||
}
|
||||
|
||||
$statePath = Join-Path $runtimeDir ".skill-requirements\$slug.json"
|
||||
$previousHash = ""
|
||||
$previousFingerprint = ""
|
||||
$previousStatus = ""
|
||||
if (Test-Path -LiteralPath $statePath) {
|
||||
try {
|
||||
$previous = Read-Utf8Text -Path $statePath | ConvertFrom-Json
|
||||
$previousHash = [string]$previous.requirementsHash
|
||||
$previousFingerprint = [string]$previous.runtimeFingerprint
|
||||
$previousStatus = [string]$previous.status
|
||||
} catch {
|
||||
$previousStatus = ""
|
||||
}
|
||||
}
|
||||
|
||||
$distributionNames = @(Get-DistributionNames -Lines $reqLines)
|
||||
$unchanged = (
|
||||
-not $ForceDeps -and
|
||||
$previousStatus -eq "ok" -and
|
||||
$previousHash -eq $reqHash -and
|
||||
$runtimeFingerprint -and
|
||||
$previousFingerprint -eq $runtimeFingerprint
|
||||
)
|
||||
if ($unchanged -and $distributionNames.Count -gt 0 -and (Test-DistributionsPresent -PythonExe $pythonExe -Names $distributionNames)) {
|
||||
Write-Host "Requirements unchanged and already present in the shared environment. Skipping uv pip install." -ForegroundColor Green
|
||||
} elseif ($DryRun) {
|
||||
Write-Host "DryRun: would run uv pip install -r requirements.txt" -ForegroundColor Yellow
|
||||
$distributionNames | ForEach-Object { Write-Host " - $_" }
|
||||
} else {
|
||||
$uv = Resolve-UvExecutable
|
||||
Write-Host ">> Install skill requirements into the shared environment" -ForegroundColor DarkGray
|
||||
Write-Host " uv=$uv"
|
||||
$installed = $false
|
||||
foreach ($mirror in $PypiMirrors) {
|
||||
Write-Host ">> uv pip install --index-url $($mirror.Url)" -ForegroundColor DarkGray
|
||||
& $uv pip install --index-url $mirror.Url --python $pythonExe -r $reqPath
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$installed = $true
|
||||
break
|
||||
}
|
||||
Write-Warning "Mirror $($mirror.Name) failed (exit $LASTEXITCODE). Trying the next one."
|
||||
}
|
||||
if (-not $installed) {
|
||||
throw "requirements.txt install failed. Code is already at $destFull, but the shared environment is missing dependencies."
|
||||
}
|
||||
Write-RequirementsState -RuntimeDir $runtimeDir -Slug $slug -RequirementsPath $reqPath -RequirementsHash $reqHash -RuntimeFingerprint $runtimeFingerprint
|
||||
Write-Host "Dependencies are installed in the shared environment." -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
if ($platformKitMin -and (Test-Path -LiteralPath $pythonExe)) {
|
||||
$kitVersion = Get-InstalledDistributionVersion -PythonExe $pythonExe -Name "jiangchang-platform-kit"
|
||||
if (-not $kitVersion) {
|
||||
Write-Warning "jiangchang-platform-kit is not installed in the shared environment, but this skill requires >= $platformKitMin."
|
||||
} elseif (-not (Compare-VersionPrefix -Installed $kitVersion -Required $platformKitMin)) {
|
||||
Write-Warning "jiangchang-platform-kit $kitVersion is below this skill's requirement >= $platformKitMin. This script does not upgrade the platform kit."
|
||||
} else {
|
||||
Write-Host "platform kit $kitVersion satisfies >= $platformKitMin." -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
if ($SkipInitDb) {
|
||||
Write-Host "Skipped init-db (-SkipInitDb). The database was not updated." -ForegroundColor Yellow
|
||||
} else {
|
||||
$initSkillDir = $destFull
|
||||
if ($DryRun -and -not (Test-Path -LiteralPath (Join-Path $destFull "scripts\main.py"))) {
|
||||
$initSkillDir = $sourceFull
|
||||
}
|
||||
Invoke-SkillInitDb -PythonExe $pythonExe -SkillDir $initSkillDir -DataRoot $resolvedDataRoot -UserId $resolvedUserId -DryRun:$DryRun
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Done. Stop any running job, leave Data Management, then open it again." -ForegroundColor Cyan
|
||||
Write-Host "Do not click Update in the skill market. That replaces this source with the encrypted package." -ForegroundColor Cyan
|
||||
Reference in New Issue
Block a user