Commit 31303078 authored by AI-甘富林's avatar AI-甘富林

build: 优化安装包 payload 并补强 NSIS 安装冒烟校验

parent 0a6752f5
!include "LogicLib.nsh"
!include "nsDialogs.nsh"
!ifndef BUILD_UNINSTALLER
Var qjcInstallGuardPage
Var qjcInstallGuardLabel
Var qjcInstallGuardMessage
Var qjcPriorInstallFolder
Function qjcSetPriorInstallFolder
StrCpy $qjcPriorInstallFolder ""
ReadRegStr $0 HKCU "Software\${APP_GUID}" "InstallLocation"
${If} $0 != ""
StrCpy $qjcPriorInstallFolder "$0"
Return
${EndIf}
ReadRegStr $0 HKLM "Software\${APP_GUID}" "InstallLocation"
${If} $0 != ""
StrCpy $qjcPriorInstallFolder "$0"
${EndIf}
FunctionEnd
Function qjcBuildInstallGuardMessage
StrCpy $qjcInstallGuardMessage "无法继续安装到:$\r$\n$INSTDIR$\r$\n$\r$\n安装器在写入 Uninstall ${PRODUCT_FILENAME}.exe 前发现该目录已有残留文件,并且当前无法覆盖。通常是上一次安装残留,或者该文件仍被其它进程占用。$\r$\n$\r$\n请先关闭 ${PRODUCT_FILENAME} 和卸载程序,或改用一个空目录后重试。"
Call qjcSetPriorInstallFolder
${If} $qjcPriorInstallFolder != ""
${AndIf} $qjcPriorInstallFolder != $INSTDIR
StrCpy $qjcInstallGuardMessage "$qjcInstallGuardMessage$\r$\n$\r$\n已检测到另一处现有安装:$\r$\n$qjcPriorInstallFolder$\r$\n若要迁移安装路径,请先从旧目录卸载,再重新安装。"
${EndIf}
FunctionEnd
Function qjcEvaluateInstallGuard
StrCpy $qjcInstallGuardMessage ""
IfFileExists "$INSTDIR\Uninstall ${PRODUCT_FILENAME}.exe" 0 +7
Delete "$INSTDIR\Uninstall ${PRODUCT_FILENAME}.exe"
Delete "$INSTDIR\uninstallerIcon.ico"
IfFileExists "$INSTDIR\Uninstall ${PRODUCT_FILENAME}.exe" 0 +2
Call qjcBuildInstallGuardMessage
Return
IfFileExists "$INSTDIR\${PRODUCT_FILENAME}.exe" 0 +6
Delete "$INSTDIR\${PRODUCT_FILENAME}.exe"
IfFileExists "$INSTDIR\${PRODUCT_FILENAME}.exe" 0 +2
Call qjcBuildInstallGuardMessage
Return
FunctionEnd
Function qjcInstallGuardPageCreate
Call qjcEvaluateInstallGuard
${If} $qjcInstallGuardMessage == ""
Abort
${EndIf}
${If} ${Silent}
DetailPrint "$qjcInstallGuardMessage"
SetErrorLevel 123
Quit
${EndIf}
nsDialogs::Create 1018
Pop $qjcInstallGuardPage
${If} $qjcInstallGuardPage == error
Abort
${EndIf}
${NSD_CreateLabel} 0u 0u 300u 90u "$qjcInstallGuardMessage"
Pop $qjcInstallGuardLabel
nsDialogs::Show
FunctionEnd
Function qjcInstallGuardPageLeave
Call qjcEvaluateInstallGuard
${If} $qjcInstallGuardMessage != ""
MessageBox MB_OK|MB_ICONEXCLAMATION "$qjcInstallGuardMessage"
Abort
${EndIf}
FunctionEnd
!macro customPageAfterChangeDir
Page custom qjcInstallGuardPageCreate qjcInstallGuardPageLeave
!macroend
!endif
......@@ -21,3 +21,4 @@ win:
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
include: build/installer.nsh
......@@ -3,15 +3,16 @@
- `apps/ui` emits its production bundle into `apps/desktop/dist/renderer`
- `apps/desktop` packages the final EXE
- `vendor/openclaw-runtime` is reserved for the pinned runtime payload
- `installer-smoke.ps1` now splits NSIS validation into installer materialization first and installed-app smoke second; it records preflight old-install evidence, classifies installer failures (`empty-exit-zero-files`, `missing-uninstaller-only`, `partial-materialization`, `app-smoke-failure`), retries the empty-exit case once by default, and writes a combined JSON summary instead of only the raw renderer smoke payload; `pnpm smoke:installer`
- `installer-smoke.ps1` now splits NSIS validation into installer materialization first and installed-app smoke second; it records preflight old-install evidence, classifies installer failures (`empty-exit-zero-files`, `missing-uninstaller-only`, `partial-materialization`, `app-smoke-failure`), retries the empty-exit case once by default, and writes a combined JSON summary instead of only the raw renderer smoke payload; the summary now also records installed runtime payload size/file-count breakdown; `pnpm smoke:installer`
- `electron-smoke.ps1` launches the desktop app directly under Electron with isolated `userData` and `logs` paths, then validates execution-policy smoke output; it now also supports preparing a workspace-entry fixture, preserving `userData`, and remote bundle-specific assertions
- `materialize-runtime-payload.ps1` generates a local bundled runtime payload under `vendor/openclaw-runtime/` by copying the local `node.exe`, the installed OpenClaw package, a local OpenClaw config snapshot, and a self-contained Python runtime with the locked dependency set installed into it; when the existing payload manifest's `materializationKey` still matches the current inputs, it short-circuits and reuses the payload without rerunning `pip` upgrade or dependency installation
- `materialize-runtime-payload.ps1` generates a local bundled runtime payload under `vendor/openclaw-runtime/` by copying the local `node.exe`, the installed OpenClaw package, a local OpenClaw config snapshot, and a self-contained Python runtime with the locked dependency set installed into it; before the payload is finalized it now strips low-risk non-runtime artifacts such as non-template OpenClaw docs/README files, Python build helpers, `__pycache__`, `.pyc` / `.pyo`, and source maps, while preserving `openclaw/package/docs/reference/templates` required by OpenClaw workspace bootstrap, and writes payload size/file-count telemetry into `runtime-manifest.json`; when the existing payload manifest's `materializationKey` still matches the current inputs, it short-circuits and reuses the payload without rerunning `pip` upgrade or dependency installation
- `materialize-runtime-cache-smoke.ps1` materializes an isolated runtime directory twice and asserts the first run is a cache miss while the second run is a cache hit that skips `pip` upgrade and locked dependency installation; `pnpm smoke:materialize-cache`
- `bundled-runtime-smoke.ps1` materializes the local runtime payload, forces bundled-runtime mode, and validates that Electron can launch and use the managed runtime end to end
- `workspace-entry-smoke.ps1` materializes the bundled runtime payload, prepares an isolated active project fixture, and validates the workspace-entry execution path end to end as a formal regression smoke; `pnpm smoke:workspace-entry`
- `cloud-bundle-smoke.ps1` generates real same-project bundle variants, serves them through the smoke cloud API, and validates the full `cloud zip -> bundle sync -> active project -> workspace-entry` chain for payload `sync`, cached `init`, and same-`projectId` replacement with refreshed README/shared-entry materialization; `pnpm smoke:cloud-bundle`
- `default-chat-smoke.ps1` compiles the targeted `default-chat-context-smoke.ts` service-level smoke with the local desktop TypeScript toolchain and verifies `chat-fallback` routing, project context injection into the prepared prompt, post-execution snapshot refresh/rebind, and reuse of the refreshed snapshot on the next request; `pnpm smoke:default-chat`
- `installer-smoke.ps1` validates the packaged Python runtime by importing the preinstalled table/document/web dependencies from `resources/vendor/openclaw-runtime/python/python.exe`
- `installer-smoke.ps1` also validates that the packaged runtime still contains the OpenClaw workspace template fallback file `resources/vendor/openclaw-runtime/openclaw/package/docs/reference/templates/AGENTS.md` before it launches the installed app smoke
- `installer-path-change-smoke.ps1` installs once to an initial path, reinstalls the same package to a second path, and asserts the relocated run still materializes `Uninstall QianjiangClaw.exe` while reporting prior-install evidence; `pnpm smoke:installer:path-change`
- `installer-target-residue-smoke.ps1` preseeds the target directory with a stale `Uninstall QianjiangClaw.exe`, runs the real NSIS installer silently, and verifies the packaged install can overwrite removable residue instead of failing at the uninstaller write step; `pnpm smoke:installer:target-residue`
- `project-context-refresh-smoke.ps1` compiles the targeted `project-context-refresh-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies ProjectContextService snapshot cache, dirty invalidation, refresh, and `session.contextSnapshotId` rebinding; `pnpm smoke:project-context-refresh`
......
param(
[string]$SetupExe,
[string]$BaseOutputDir,
[int]$InstallTimeoutSeconds = 480,
[int]$TimeoutSeconds = 240,
[switch]$RunInstalledAppSmokeOnRelocatedInstall
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$installerSmokeScript = Join-Path $repoRoot 'build\scripts\installer-smoke.ps1'
$installerDir = Join-Path $repoRoot 'dist\installer'
if (-not $SetupExe) {
$latestSetup = Get-ChildItem -Path $installerDir -Filter '*-Setup-*.exe' |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if (-not $latestSetup) {
throw "No installer exe found under $installerDir"
}
$SetupExe = $latestSetup.FullName
}
$SetupExe = (Resolve-Path $SetupExe).Path
if (-not $BaseOutputDir) {
$BaseOutputDir = Join-Path $repoRoot '.tmp\installer-path-change-smoke'
}
$BaseOutputDir = [System.IO.Path]::GetFullPath($BaseOutputDir)
if (Test-Path $BaseOutputDir) {
Remove-Item -Path $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -ItemType Directory -Force -Path $BaseOutputDir | Out-Null
$initialInstallDir = Join-Path $BaseOutputDir 'existing\QianjiangClaw'
$relocatedInstallDir = Join-Path $BaseOutputDir 'relocated\QianjiangClaw'
$initialResultPath = Join-Path $BaseOutputDir 'initial-install-result.json'
$relocatedResultPath = Join-Path $BaseOutputDir 'relocated-install-result.json'
$summaryPath = Join-Path $BaseOutputDir 'installer-path-change-summary.json'
$commonArgs = @(
'-SetupExe', $SetupExe,
'-InstallTimeoutSeconds', $InstallTimeoutSeconds,
'-TimeoutSeconds', $TimeoutSeconds
)
Write-Host "Installing baseline package to $initialInstallDir"
powershell -ExecutionPolicy Bypass -File $installerSmokeScript @commonArgs `
-InstallDir $initialInstallDir `
-SmokeOutput $initialResultPath `
-SkipInstalledAppSmoke
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
$initialResult = Get-Content -LiteralPath $initialResultPath | ConvertFrom-Json
if (-not $initialResult.ok) {
throw 'Initial installer materialization did not report ok=true.'
}
if (-not $initialResult.uninstallerExists) {
throw 'Initial installer materialization did not write the uninstaller.'
}
Write-Host "Installing same package to relocated path $relocatedInstallDir"
$relocatedArgs = @(
'-SetupExe', $SetupExe,
'-InstallDir', $relocatedInstallDir,
'-SmokeOutput', $relocatedResultPath,
'-InstallTimeoutSeconds', $InstallTimeoutSeconds,
'-TimeoutSeconds', $TimeoutSeconds,
'-AdditionalPriorInstallDirs', $initialInstallDir
)
if (-not $RunInstalledAppSmokeOnRelocatedInstall) {
$relocatedArgs += '-SkipInstalledAppSmoke'
}
powershell -ExecutionPolicy Bypass -File $installerSmokeScript @relocatedArgs
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
$relocatedResult = Get-Content -LiteralPath $relocatedResultPath | ConvertFrom-Json
if (-not $relocatedResult.ok) {
throw 'Relocated installer run did not report ok=true.'
}
if (-not $relocatedResult.uninstallerExists) {
throw 'Relocated installer run did not write the uninstaller.'
}
$priorPathEvidence = @($relocatedResult.installerStage.preflight.additionalPriorInstallObservations)
if ($priorPathEvidence.Count -lt 1) {
throw 'Relocated installer run did not record any prior install path observation before reinstall.'
}
if (-not $priorPathEvidence[0].uninstallerExists) {
throw 'Relocated installer run did not observe a prior install directory with an existing uninstaller before reinstall.'
}
$summary = [ordered]@{
ok = $true
setupExe = $SetupExe
baseOutputDir = $BaseOutputDir
initialInstallDir = $initialInstallDir
relocatedInstallDir = $relocatedInstallDir
initialInstallResult = $initialResultPath
relocatedInstallResult = $relocatedResultPath
priorInstallEvidenceCount = @($relocatedResult.installerStage.preflight.priorInstallEvidence).Count
priorPathEvidenceCount = $priorPathEvidence.Count
relocatedInstallerExitCode = $relocatedResult.installerExitCode
relocatedFileCount = $relocatedResult.fileCount
relocatedUninstallerExists = $relocatedResult.uninstallerExists
relocatedFailureStage = $relocatedResult.failureStage
relocatedFailureClassification = $relocatedResult.failureClassification
}
[System.IO.File]::WriteAllText($summaryPath, ($summary | ConvertTo-Json -Depth 12), (New-Object System.Text.UTF8Encoding $false))
Write-Output ($summary | ConvertTo-Json -Depth 12)
......@@ -9,13 +9,20 @@ param(
[string]$UserDataPath,
[string]$LogsPath,
[int]$InstallTimeoutSeconds = 480,
[int]$TimeoutSeconds = 240
[int]$TimeoutSeconds = 240,
[int]$MaxInstallAttempts = 2,
[int]$InstallRetryDelaySeconds = 2,
[string[]]$AdditionalPriorInstallDirs = @(),
[switch]$SkipInstalledAppSmoke
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$installerDir = Join-Path $repoRoot 'dist\installer'
$productName = 'QianjiangClaw'
$uninstallerFileName = "Uninstall $productName.exe"
$installedExeName = "$productName.exe"
if (-not $SetupExe) {
$latestSetup = Get-ChildItem -Path $installerDir -Filter '*-Setup-*.exe' |
......@@ -33,57 +40,366 @@ $SetupExe = (Resolve-Path $SetupExe).Path
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
if (-not $InstallDir) {
$InstallDir = Join-Path $repoRoot ".tmp\installer-smoke\$stamp\QianjiangClaw"
$InstallDir = Join-Path $repoRoot ".tmp\installer-smoke\$stamp\$productName"
}
$InstallDir = [System.IO.Path]::GetFullPath($InstallDir)
if (-not $SmokeOutput) {
$SmokeOutput = Join-Path (Split-Path $InstallDir -Parent) 'installer-smoke-result.json'
}
$SmokeOutput = [System.IO.Path]::GetFullPath($SmokeOutput)
if (-not $UserDataPath) {
$UserDataPath = Join-Path (Split-Path $InstallDir -Parent) 'user-data'
}
$UserDataPath = [System.IO.Path]::GetFullPath($UserDataPath)
if (-not $LogsPath) {
$LogsPath = Join-Path (Split-Path $InstallDir -Parent) 'logs'
}
$LogsPath = [System.IO.Path]::GetFullPath($LogsPath)
$appSmokeOutput = Join-Path (Split-Path $SmokeOutput -Parent) ("{0}.app.json" -f [System.IO.Path]::GetFileNameWithoutExtension($SmokeOutput))
$appSmokeTracePath = "$appSmokeOutput.trace.log"
$installParent = Split-Path $InstallDir -Parent
New-Item -ItemType Directory -Force -Path $installParent | Out-Null
if (Test-Path $InstallDir) {
Write-Host "Removing existing install directory at $InstallDir"
Remove-Item -Path $InstallDir -Recurse -Force -ErrorAction SilentlyContinue
$installedExe = Join-Path $InstallDir $installedExeName
$uninstallerPath = Join-Path $InstallDir $uninstallerFileName
$resourcesAsar = Join-Path $InstallDir 'resources\app.asar'
$runtimeResourceDir = Join-Path $InstallDir 'resources\vendor\openclaw-runtime'
$packagedPythonExe = Join-Path $runtimeResourceDir 'python\python.exe'
$packagedPythonManifest = Join-Path $runtimeResourceDir 'python\python-manifest.json'
$packagedWorkspaceTemplate = Join-Path $runtimeResourceDir 'openclaw\package\docs\reference\templates\AGENTS.md'
$installAttempts = @()
$installerStageSummary = $null
$appStageSummary = $null
$failureStage = ''
$failureClassification = ''
$failureError = ''
function Write-Utf8File {
param([string]$FilePath, [string]$Content)
$encoding = New-Object System.Text.UTF8Encoding $false
$directory = Split-Path $FilePath -Parent
if ($directory) {
New-Item -ItemType Directory -Force -Path $directory | Out-Null
}
[System.IO.File]::WriteAllText($FilePath, $Content, $encoding)
}
function Write-JsonFile {
param([string]$FilePath, [object]$Payload)
Write-Utf8File -FilePath $FilePath -Content ($Payload | ConvertTo-Json -Depth 20)
}
function Get-InstallSnapshot {
param([string]$Path)
if (-not (Test-Path $Path)) {
return @{
FileCount = 0
TotalBytes = [int64]0
}
}
$files = Get-ChildItem -Path $Path -Recurse -File -ErrorAction SilentlyContinue
$totalBytes = ($files | Measure-Object -Property Length -Sum).Sum
if ($null -eq $totalBytes) {
$totalBytes = 0
}
return @{
FileCount = @($files).Count
TotalBytes = [int64]$totalBytes
}
}
function Get-UninstallPathFromString {
param([string]$UninstallString)
if ([string]::IsNullOrWhiteSpace($UninstallString)) {
return ''
}
if ($UninstallString -match '"([^"]+\.exe)"') {
return $matches[1]
}
if ($UninstallString -match '^([^ ]+\.exe)') {
return $matches[1]
}
return ''
}
function Get-RegistryViewLabel {
param([Microsoft.Win32.RegistryView]$RegistryView)
switch ($RegistryView) {
([Microsoft.Win32.RegistryView]::Registry64) { return 'registry64' }
([Microsoft.Win32.RegistryView]::Registry32) { return 'registry32' }
default { return 'default' }
}
}
function Get-PriorInstallEvidence {
param([string]$ProductName)
$entries = @()
$seen = @{}
$registryTargets = @(
@{
HiveName = 'HKCU'
Hive = [Microsoft.Win32.RegistryHive]::CurrentUser
Views = @([Microsoft.Win32.RegistryView]::Default)
},
@{
HiveName = 'HKLM'
Hive = [Microsoft.Win32.RegistryHive]::LocalMachine
Views = @([Microsoft.Win32.RegistryView]::Registry64, [Microsoft.Win32.RegistryView]::Registry32)
}
)
foreach ($target in $registryTargets) {
foreach ($view in $target.Views) {
try {
$baseKey = [Microsoft.Win32.RegistryKey]::OpenBaseKey($target.Hive, $view)
$uninstallRoot = $baseKey.OpenSubKey('Software\Microsoft\Windows\CurrentVersion\Uninstall')
if ($null -eq $uninstallRoot) {
continue
}
foreach ($subKeyName in $uninstallRoot.GetSubKeyNames()) {
$subKey = $uninstallRoot.OpenSubKey($subKeyName)
if ($null -eq $subKey) {
continue
}
$displayName = [string]$subKey.GetValue('DisplayName', '')
$uninstallString = [string]$subKey.GetValue('UninstallString', '')
$quietUninstallString = [string]$subKey.GetValue('QuietUninstallString', '')
$installLocation = [string]$subKey.GetValue('InstallLocation', '')
$matchesProduct = $displayName -like "$ProductName*" -or $uninstallString -like "*$ProductName*" -or $quietUninstallString -like "*$ProductName*"
if (-not $matchesProduct) {
continue
}
$entryKey = '{0}|{1}|{2}' -f $target.HiveName, $view.ToString(), $subKeyName
if ($seen.ContainsKey($entryKey)) {
continue
}
$seen[$entryKey] = $true
$uninstaller = Get-UninstallPathFromString -UninstallString $uninstallString
if ([string]::IsNullOrWhiteSpace($installLocation) -and -not [string]::IsNullOrWhiteSpace($uninstaller)) {
$installLocation = Split-Path $uninstaller -Parent
}
$entries += [ordered]@{
hive = $target.HiveName
registryView = Get-RegistryViewLabel -RegistryView $view
subKey = $subKeyName
displayName = $displayName
displayVersion = [string]$subKey.GetValue('DisplayVersion', '')
installLocation = $installLocation
uninstallString = $uninstallString
quietUninstallString = $quietUninstallString
uninstallerPath = $uninstaller
}
}
} catch {
continue
}
}
}
return $entries
}
function Get-InstallerRelatedProcesses {
param([string[]]$KnownPaths = @())
$normalizedKnownPaths = @($KnownPaths | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue
if (-not $processes) {
return @()
}
$matches = @()
foreach ($process in $processes) {
$commandLine = [string]$process.CommandLine
$name = [string]$process.Name
$isMatch = $false
if ($name -eq $installedExeName -or $name -eq $uninstallerFileName) {
$isMatch = $true
}
if (-not $isMatch -and $name -eq 'node.exe' -and $commandLine -and (
$commandLine -like '*openclaw.runtime.json*' -or
$commandLine -like '*vendor\openclaw-runtime*'
)) {
$isMatch = $true
}
if (-not $isMatch -and $commandLine) {
if ($commandLine -like "*$productName*" -or $commandLine -like '*openclaw.runtime.json*') {
$isMatch = $true
} else {
foreach ($knownPath in $normalizedKnownPaths) {
if ($commandLine -like "*$knownPath*") {
$isMatch = $true
break
}
}
}
}
if ($isMatch) {
$matches += [ordered]@{
processId = [int]$process.ProcessId
name = $name
executablePath = [string]$process.ExecutablePath
commandLine = $commandLine
}
}
}
return $matches
}
function Stop-SmokeAppProcesses {
$appProcesses = Get-Process -Name 'QianjiangClaw' -ErrorAction SilentlyContinue
if ($appProcesses) {
$appProcesses | Stop-Process -Force -ErrorAction SilentlyContinue
param([string[]]$KnownPaths = @())
$relatedProcesses = Get-InstallerRelatedProcesses -KnownPaths $KnownPaths
if (-not $relatedProcesses) {
return
}
$runtimeChildren = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object {
$_.Name -eq 'node.exe' -and $_.CommandLine -and (
$_.CommandLine -like '*openclaw.runtime.json*' -or
$_.CommandLine -like '*vendor\openclaw-runtime*'
$processIds = @(
$relatedProcesses |
ForEach-Object {
if ($_ -is [System.Collections.IDictionary]) {
$_['processId']
} else {
$_.processId
}
} |
Where-Object { $_ } |
Select-Object -Unique
)
foreach ($processId in $processIds) {
Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue
}
if ($runtimeChildren) {
$runtimeChildren | ForEach-Object {
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
}
function Get-InstallObservation {
param([string]$Path)
$snapshot = Get-InstallSnapshot -Path $Path
$observedInstalledExe = Join-Path $Path $installedExeName
$observedUninstallerPath = Join-Path $Path $uninstallerFileName
$observedResourcesAsar = Join-Path $Path 'resources\app.asar'
$observedRuntimeResourceDir = Join-Path $Path 'resources\vendor\openclaw-runtime'
$observedPackagedPythonExe = Join-Path $observedRuntimeResourceDir 'python\python.exe'
$observedPackagedPythonManifest = Join-Path $observedRuntimeResourceDir 'python\python-manifest.json'
return [ordered]@{
installDir = $Path
installDirExists = Test-Path $Path
installedExeExists = Test-Path $observedInstalledExe
resourcesAsarExists = Test-Path $observedResourcesAsar
runtimeResourceDirExists = Test-Path $observedRuntimeResourceDir
packagedPythonExeExists = Test-Path $observedPackagedPythonExe
packagedPythonManifestExists = Test-Path $observedPackagedPythonManifest
uninstallerExists = Test-Path $observedUninstallerPath
fileCount = [int]$snapshot.FileCount
totalBytes = [int64]$snapshot.TotalBytes
}
}
function Get-RuntimePayloadSummary {
param([string]$RuntimeDir)
$manifestPath = Join-Path $RuntimeDir 'runtime-manifest.json'
if (-not (Test-Path $RuntimeDir)) {
return [ordered]@{
runtimeDir = $RuntimeDir
manifestPath = $manifestPath
exists = $false
fileCount = 0
sizeBytes = [int64]0
topLevelBreakdown = @()
manifestFileCount = $null
manifestSizeBytes = $null
payloadStats = $null
cleanupSummary = $null
}
}
$snapshot = Get-InstallSnapshot -Path $RuntimeDir
$topLevelBreakdown = @(
Get-ChildItem -LiteralPath $RuntimeDir -Force -ErrorAction SilentlyContinue |
Sort-Object Name |
ForEach-Object {
if ($_.PSIsContainer) {
$childSnapshot = Get-InstallSnapshot -Path $_.FullName
[ordered]@{
path = $_.Name
fileCount = [int]$childSnapshot.FileCount
sizeBytes = [int64]$childSnapshot.TotalBytes
}
} else {
[ordered]@{
path = $_.Name
fileCount = 1
sizeBytes = [int64]$_.Length
}
}
}
)
$manifest = $null
if (Test-Path $manifestPath) {
try {
$manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json
} catch {
$manifest = $null
}
}
return [ordered]@{
runtimeDir = $RuntimeDir
manifestPath = $manifestPath
exists = $true
fileCount = [int]$snapshot.FileCount
sizeBytes = [int64]$snapshot.TotalBytes
topLevelBreakdown = $topLevelBreakdown
manifestFileCount = if ($manifest -and $null -ne $manifest.fileCount) { [int]$manifest.fileCount } else { $null }
manifestSizeBytes = if ($manifest -and $null -ne $manifest.sizeBytes) { [int64]$manifest.sizeBytes } else { $null }
payloadStats = if ($manifest -and $null -ne $manifest.payloadStats) { $manifest.payloadStats } else { $null }
cleanupSummary = if ($manifest -and $null -ne $manifest.cleanupSummary) { $manifest.cleanupSummary } else { $null }
}
}
Stop-SmokeAppProcesses
function Get-InstallerFailureClassification {
param([object]$Observation)
function Write-Utf8File {
param([string]$FilePath, [string]$Content)
if ($Observation.fileCount -eq 0 -and -not $Observation.installedExeExists -and -not $Observation.uninstallerExists) {
return 'empty-exit-zero-files'
}
$encoding = New-Object System.Text.UTF8Encoding $false
$directory = Split-Path $FilePath -Parent
if ($directory) {
New-Item -ItemType Directory -Force -Path $directory | Out-Null
if ($Observation.installedExeExists -and $Observation.resourcesAsarExists -and $Observation.runtimeResourceDirExists -and $Observation.packagedPythonExeExists -and $Observation.packagedPythonManifestExists -and -not $Observation.uninstallerExists) {
return 'missing-uninstaller-only'
}
[System.IO.File]::WriteAllText($FilePath, $Content, $encoding)
if ($Observation.installDirExists -or $Observation.fileCount -gt 0) {
return 'partial-materialization'
}
return 'empty-exit-zero-files'
}
function Prepare-SmokeWorkspaceFixture {
......@@ -131,42 +447,36 @@ function Prepare-SmokeWorkspaceFixture {
Write-Utf8File -FilePath (Join-Path $manifestRoot 'active-project.json') -Content ($activeProjectPayload | ConvertTo-Json -Depth 3)
}
function Get-InstallSnapshot {
param([string]$Path)
function Invoke-InstallerMaterializationAttempt {
param(
[int]$AttemptNumber,
[int]$AttemptTimeoutSeconds
)
if (-not (Test-Path $Path)) {
return @{ FileCount = 0; TotalBytes = 0 }
if (Test-Path $InstallDir) {
Write-Host "Removing existing install directory at $InstallDir"
Remove-Item -Path $InstallDir -Recurse -Force -ErrorAction SilentlyContinue
}
$files = Get-ChildItem -Path $Path -Recurse -File -ErrorAction SilentlyContinue
$totalBytes = ($files | Measure-Object -Property Length -Sum).Sum
if ($null -eq $totalBytes) {
$totalBytes = 0
}
Write-Host "Installing $SetupExe to $InstallDir (attempt $AttemptNumber/$MaxInstallAttempts)"
$attemptStartedAt = (Get-Date).ToUniversalTime().ToString('o')
$setupProcess = Start-Process -FilePath $SetupExe -ArgumentList @('/S', "/D=$InstallDir") -PassThru
$installDeadline = (Get-Date).AddSeconds($AttemptTimeoutSeconds)
$installReady = $false
$requiredPathsReady = $false
$stabilityThreshold = 8
$stablePollCount = 0
$lastSnapshot = $null
return @{
FileCount = @($files).Count
TotalBytes = [int64]$totalBytes
}
}
while ((Get-Date) -lt $installDeadline) {
$observation = Get-InstallObservation -Path $InstallDir
$requiredPathsReady = $observation.installedExeExists -and $observation.resourcesAsarExists -and $observation.runtimeResourceDirExists -and $observation.packagedPythonExeExists -and $observation.packagedPythonManifestExists -and $observation.uninstallerExists
Write-Host "Installing $SetupExe to $InstallDir"
$installedExe = Join-Path $InstallDir 'QianjiangClaw.exe'
$resourcesAsar = Join-Path $InstallDir 'resources\app.asar'
$runtimeResourceDir = Join-Path $InstallDir 'resources\vendor\openclaw-runtime'
$packagedPythonExe = Join-Path $runtimeResourceDir 'python\python.exe'
$packagedPythonManifest = Join-Path $runtimeResourceDir 'python\python-manifest.json'
$setupProcess = Start-Process -FilePath $SetupExe -ArgumentList @('/S', "/D=$InstallDir") -PassThru
$installDeadline = (Get-Date).AddSeconds($InstallTimeoutSeconds)
$installReady = $false
$requiredPathsReady = $false
$stabilityThreshold = 8
$stablePollCount = 0
$lastSnapshot = $null
while ((Get-Date) -lt $installDeadline) {
$requiredPathsReady = (Test-Path $installedExe) -and (Test-Path $resourcesAsar) -and (Test-Path $runtimeResourceDir) -and (Test-Path $packagedPythonExe) -and (Test-Path $packagedPythonManifest)
if ($requiredPathsReady) {
$currentSnapshot = Get-InstallSnapshot -Path $InstallDir
$currentSnapshot = @{
FileCount = $observation.fileCount
TotalBytes = $observation.totalBytes
}
if ($lastSnapshot -and $currentSnapshot.FileCount -eq $lastSnapshot.FileCount -and $currentSnapshot.TotalBytes -eq $lastSnapshot.TotalBytes) {
$stablePollCount += 1
} else {
......@@ -183,81 +493,86 @@ while ((Get-Date) -lt $installDeadline) {
$lastSnapshot = $null
}
# Do not break early when the launcher process exits — NSIS may spawn a
# child installer process and the parent wrapper exits quickly. Keep polling
# until all required paths are stable or the deadline is reached.
Start-Sleep -Milliseconds 500
}
if (-not $installReady) {
Stop-Process -Id $setupProcess.Id -Force -ErrorAction SilentlyContinue
if (-not $requiredPathsReady) {
throw "Installer did not materialize the packaged files within $InstallTimeoutSeconds seconds."
}
throw "Installer did not reach a stable packaged file state within $InstallTimeoutSeconds seconds."
}
if (-not $setupProcess.HasExited) {
if (-not $setupProcess.HasExited) {
Wait-Process -Id $setupProcess.Id -Timeout 15 -ErrorAction SilentlyContinue
}
if (-not $setupProcess.HasExited) {
Write-Host "Installer process remained alive after files were installed; terminating lingering process."
}
if (-not $setupProcess.HasExited -and -not $installReady) {
Stop-Process -Id $setupProcess.Id -Force -ErrorAction SilentlyContinue
}
}
if (-not $setupProcess.HasExited -and $installReady) {
Write-Host 'Installer process remained alive after files were installed; terminating lingering process.'
Stop-Process -Id $setupProcess.Id -Force -ErrorAction SilentlyContinue
}
if (-not (Test-Path $installedExe)) {
throw "Installed executable not found at $installedExe"
}
if (-not (Test-Path $resourcesAsar)) {
throw "Packaged app.asar not found at $resourcesAsar"
}
if (-not (Test-Path $runtimeResourceDir)) {
throw "Bundled runtime resources not found at $runtimeResourceDir"
}
if (-not (Test-Path $packagedPythonExe)) {
throw "Bundled Python executable not found at $packagedPythonExe"
}
if (-not (Test-Path $packagedPythonManifest)) {
throw "Bundled Python manifest not found at $packagedPythonManifest"
}
$setupExitCode = $null
if ($setupProcess.HasExited) {
$setupExitCode = [int]$setupProcess.ExitCode
}
$pythonImportProbe = & $packagedPythonExe -c "import openpyxl, pandas, requests, bs4, lxml, pypdf, docx, charset_normalizer, yaml, PIL, dotenv, playwright; print('ok')"
if ($LASTEXITCODE -ne 0 -or $pythonImportProbe -notmatch 'ok') {
throw 'Bundled Python import probe failed for the packaged runtime payload.'
$finalObservation = Get-InstallObservation -Path $InstallDir
$failureClassification = if ($installReady) { '' } else { Get-InstallerFailureClassification -Observation $finalObservation }
return [ordered]@{
attempt = $AttemptNumber
startedAt = $attemptStartedAt
finishedAt = (Get-Date).ToUniversalTime().ToString('o')
setupProcessId = [int]$setupProcess.Id
installerExitCode = $setupExitCode
installReady = $installReady
requiredPathsReady = $requiredPathsReady
stablePollCount = $stablePollCount
stabilityThreshold = $stabilityThreshold
observation = $finalObservation
failureClassification = $failureClassification
retryEligible = (-not $installReady -and $failureClassification -eq 'empty-exit-zero-files')
}
}
if (Test-Path $SmokeOutput) {
Remove-Item $SmokeOutput -Force
}
if (Test-Path $UserDataPath) {
Remove-Item $UserDataPath -Recurse -Force -ErrorAction SilentlyContinue
}
if (Test-Path $LogsPath) {
Remove-Item $LogsPath -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -ItemType Directory -Force -Path $UserDataPath, $LogsPath | Out-Null
Prepare-SmokeWorkspaceFixture -WorkspaceRoot $UserDataPath
$env:QJCLAW_SMOKE_OUTPUT = $SmokeOutput
$env:QJCLAW_SMOKE_CLOUD_API_BASE_URL = "http://127.0.0.1:$SmokePort"
$env:QJCLAW_SMOKE_AUTH_TOKEN = $SmokeToken
$env:QJCLAW_SMOKE_RUNTIME_CLOUD_API_KEY = 'smoke-runtime-api-key'
$env:QJCLAW_RUNTIME_CLOUD_HEARTBEAT_INTERVAL_MS = 1000
$env:QJCLAW_RUNTIME_CLOUD_CONFIG_SYNC_INTERVAL_MS = 1500
$env:QJCLAW_RUNTIME_CLOUD_EVENT_FLUSH_INTERVAL_MS = 800
$env:QJCLAW_RUNTIME_CLOUD_EVENT_BATCH_SIZE = 3
$env:QJCLAW_USER_DATA_PATH = $UserDataPath
$env:QJCLAW_LOGS_PATH = $LogsPath
if ($RuntimeMode) {
function Invoke-InstalledAppSmoke {
param()
if (Test-Path $appSmokeOutput) {
Remove-Item -LiteralPath $appSmokeOutput -Force -ErrorAction SilentlyContinue
}
if (Test-Path $appSmokeTracePath) {
Remove-Item -LiteralPath $appSmokeTracePath -Force -ErrorAction SilentlyContinue
}
if (Test-Path $UserDataPath) {
Remove-Item -Path $UserDataPath -Recurse -Force -ErrorAction SilentlyContinue
}
if (Test-Path $LogsPath) {
Remove-Item -Path $LogsPath -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -ItemType Directory -Force -Path $UserDataPath, $LogsPath | Out-Null
Prepare-SmokeWorkspaceFixture -WorkspaceRoot $UserDataPath
$env:QJCLAW_SMOKE_OUTPUT = $appSmokeOutput
$env:QJCLAW_SMOKE_CLOUD_API_BASE_URL = "http://127.0.0.1:$SmokePort"
$env:QJCLAW_SMOKE_AUTH_TOKEN = $SmokeToken
$env:QJCLAW_SMOKE_RUNTIME_CLOUD_API_KEY = 'smoke-runtime-api-key'
$env:QJCLAW_RUNTIME_CLOUD_HEARTBEAT_INTERVAL_MS = 1000
$env:QJCLAW_RUNTIME_CLOUD_CONFIG_SYNC_INTERVAL_MS = 1500
$env:QJCLAW_RUNTIME_CLOUD_EVENT_FLUSH_INTERVAL_MS = 800
$env:QJCLAW_RUNTIME_CLOUD_EVENT_BATCH_SIZE = 3
$env:QJCLAW_USER_DATA_PATH = $UserDataPath
$env:QJCLAW_LOGS_PATH = $LogsPath
if ($RuntimeMode) {
$env:QJCLAW_RUNTIME_MODE = $RuntimeMode
}
}
$runtimeModeValue = if ($RuntimeMode) { $RuntimeMode } else { '' }
$expectBundledValue = if ($ExpectBundledRuntime) { 'true' } else { 'false' }
$runtimeManagerLog = Join-Path $LogsPath 'runtime-manager.log'
Write-Host "Running installed app smoke: $installedExe"
$appProcess = Start-Process -FilePath $installedExe -PassThru
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
if (Test-Path $SmokeOutput) {
try {
Write-Host "Running installed app smoke: $installedExe"
$appProcess = Start-Process -FilePath $installedExe -PassThru
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
if (Test-Path $appSmokeOutput) {
break
}
......@@ -267,39 +582,25 @@ while ((Get-Date) -lt $deadline) {
}
Start-Sleep -Milliseconds 500
}
}
$alive = Get-Process -Id $appProcess.Id -ErrorAction SilentlyContinue
if ($alive) {
$alive = Get-Process -Id $appProcess.Id -ErrorAction SilentlyContinue
if ($alive) {
Wait-Process -Id $appProcess.Id -Timeout 15 -ErrorAction SilentlyContinue
$alive = Get-Process -Id $appProcess.Id -ErrorAction SilentlyContinue
}
}
if ($alive -and -not (Test-Path $SmokeOutput)) {
if ($alive -and -not (Test-Path $appSmokeOutput)) {
Stop-Process -Id $appProcess.Id -Force -ErrorAction SilentlyContinue
throw "Installed smoke process did not finish within $TimeoutSeconds seconds."
}
$appExitCode = if ($appProcess.HasExited) { $appProcess.ExitCode } else { $null }
Remove-Item Env:QJCLAW_SMOKE_OUTPUT -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_CLOUD_API_BASE_URL -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_AUTH_TOKEN -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_RUNTIME_CLOUD_API_KEY -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_RUNTIME_CLOUD_HEARTBEAT_INTERVAL_MS -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_RUNTIME_CLOUD_CONFIG_SYNC_INTERVAL_MS -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_RUNTIME_CLOUD_EVENT_FLUSH_INTERVAL_MS -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_RUNTIME_CLOUD_EVENT_BATCH_SIZE -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_USER_DATA_PATH -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_LOGS_PATH -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_RUNTIME_MODE -ErrorAction SilentlyContinue
}
if (-not (Test-Path $SmokeOutput)) {
throw "Smoke output file was not created: $SmokeOutput"
}
$appExitCode = if ($appProcess.HasExited) { [int]$appProcess.ExitCode } else { $null }
if (-not (Test-Path $appSmokeOutput)) {
throw "Smoke output file was not created: $appSmokeOutput"
}
$expectBundledValue = if ($ExpectBundledRuntime) { 'true' } else { 'false' }
$validator = @"
$validator = @"
const fs = require('fs');
const [smokeOutput, expectedUserData, expectedLogs, runtimeMode, expectBundled, runtimeResourceDir, packagedPythonExe, packagedPythonManifest, setupExe, installDir, installedExe, installedAppExitCode] = process.argv.slice(1);
......@@ -413,12 +714,13 @@ if (expectBundled === 'true') {
}
const summary = {
ok: true,
smokeOutput,
appSmokeTracePath: tracePath,
setupExe,
installDir,
installedExe,
runtimeMode,
expectBundledRuntime: expectBundled === 'true',
smokeOutput,
appPath: String(sendResult.system.appPath || ''),
resourcesPath: String(sendResult.system.resourcesPath || ''),
userDataPath: String(sendResult.system.userDataPath || ''),
......@@ -441,7 +743,6 @@ const summary = {
streamDeltaEventCount: Number(streamSmoke.deltaEventCount || 0),
streamCompletedEventCount: Number(streamSmoke.completedEventCount || 0),
diagnosticsPath,
tracePath,
runtimeResourceDir,
bundledPythonExecutable: packagedPythonExe,
bundledPythonManifest: packagedPythonManifest,
......@@ -449,25 +750,251 @@ const summary = {
};
console.log(JSON.stringify(summary, null, 2));
"@
$runtimeModeValue = if ($RuntimeMode) { $RuntimeMode } else { '' }
$runtimeManagerLog = Join-Path $LogsPath 'runtime-manager.log'
try {
$summary = & node -e $validator $SmokeOutput $UserDataPath $LogsPath $runtimeModeValue $expectBundledValue $runtimeResourceDir $packagedPythonExe $packagedPythonManifest $SetupExe $InstallDir $installedExe ([string]$appExitCode)
$summaryJson = & node -e $validator $appSmokeOutput $UserDataPath $LogsPath $runtimeModeValue $expectBundledValue $runtimeResourceDir $packagedPythonExe $packagedPythonManifest $SetupExe $InstallDir $installedExe ([string]$appExitCode)
if ($LASTEXITCODE -ne 0) {
throw 'Installed smoke validation failed.'
}
Write-Output $summary
Stop-SmokeAppProcesses
exit 0
} catch {
return ($summaryJson | ConvertFrom-Json)
} catch {
if (Test-Path $runtimeManagerLog) {
Write-Host '==== runtime-manager.log ===='
Get-Content $runtimeManagerLog -Tail 200 | Write-Host
}
Stop-SmokeAppProcesses
throw
} finally {
Remove-Item Env:QJCLAW_SMOKE_OUTPUT -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_CLOUD_API_BASE_URL -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_AUTH_TOKEN -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_RUNTIME_CLOUD_API_KEY -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_RUNTIME_CLOUD_HEARTBEAT_INTERVAL_MS -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_RUNTIME_CLOUD_CONFIG_SYNC_INTERVAL_MS -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_RUNTIME_CLOUD_EVENT_FLUSH_INTERVAL_MS -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_RUNTIME_CLOUD_EVENT_BATCH_SIZE -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_USER_DATA_PATH -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_LOGS_PATH -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_RUNTIME_MODE -ErrorAction SilentlyContinue
}
}
function New-FinalSummary {
param(
[bool]$Ok,
[string]$FailureStage,
[string]$FailureClassification,
[string]$ErrorMessage
)
$latestAttempt = $null
if (@($installAttempts).Count -gt 0) {
$latestAttempt = $installAttempts[-1]
}
$summary = [ordered]@{
ok = $Ok
failureStage = $FailureStage
failureClassification = $FailureClassification
error = $ErrorMessage
setupExe = $SetupExe
installDir = $InstallDir
installedExe = $installedExe
uninstallerPath = $uninstallerPath
smokeOutput = $SmokeOutput
appSmokeOutput = $appSmokeOutput
appSmokeTracePath = $appSmokeTracePath
installerStage = $installerStageSummary
installedAppStage = $appStageSummary
}
if ($latestAttempt) {
$summary.installerExitCode = $latestAttempt.installerExitCode
$summary.installDirExists = $latestAttempt.observation.installDirExists
$summary.installedExeExists = $latestAttempt.observation.installedExeExists
$summary.uninstallerExists = $latestAttempt.observation.uninstallerExists
$summary.fileCount = $latestAttempt.observation.fileCount
$summary.totalBytes = [int64]$latestAttempt.observation.totalBytes
}
if ($installerStageSummary -and $installerStageSummary.Contains('runtimePayload')) {
$runtimePayloadSummary = $installerStageSummary['runtimePayload']
$summary.runtimePayloadFileCount = $runtimePayloadSummary.fileCount
$summary.runtimePayloadSizeBytes = [int64]$runtimePayloadSummary.sizeBytes
$summary.runtimePayloadTopLevelBreakdown = $runtimePayloadSummary.topLevelBreakdown
}
if ($appStageSummary) {
if ($appStageSummary -is [System.Collections.IDictionary]) {
foreach ($key in $appStageSummary.Keys) {
if (-not $summary.Contains($key)) {
$summary[$key] = $appStageSummary[$key]
}
}
} else {
foreach ($property in $appStageSummary.PSObject.Properties) {
if (-not $summary.Contains($property.Name)) {
$summary[$property.Name] = $property.Value
}
}
}
}
return $summary
}
New-Item -ItemType Directory -Force -Path $installParent | Out-Null
if (Test-Path $SmokeOutput) {
Remove-Item -LiteralPath $SmokeOutput -Force -ErrorAction SilentlyContinue
}
if (Test-Path $appSmokeOutput) {
Remove-Item -LiteralPath $appSmokeOutput -Force -ErrorAction SilentlyContinue
}
if (Test-Path $appSmokeTracePath) {
Remove-Item -LiteralPath $appSmokeTracePath -Force -ErrorAction SilentlyContinue
}
$normalizedAdditionalPriorInstallDirs = @($AdditionalPriorInstallDirs | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { [System.IO.Path]::GetFullPath($_) })
$knownObservationPaths = @($InstallDir, $installedExe, $uninstallerPath, $SetupExe) + $normalizedAdditionalPriorInstallDirs
$preflight = [ordered]@{
collectedAt = (Get-Date).ToUniversalTime().ToString('o')
targetObservation = Get-InstallObservation -Path $InstallDir
priorInstallEvidence = @(Get-PriorInstallEvidence -ProductName $productName)
additionalPriorInstallObservations = @($normalizedAdditionalPriorInstallDirs | ForEach-Object { Get-InstallObservation -Path $_ })
relatedProcesses = @(Get-InstallerRelatedProcesses -KnownPaths $knownObservationPaths)
}
Stop-SmokeAppProcesses -KnownPaths $knownObservationPaths
try {
for ($attempt = 1; $attempt -le $MaxInstallAttempts; $attempt += 1) {
$attemptSummary = Invoke-InstallerMaterializationAttempt -AttemptNumber $attempt -AttemptTimeoutSeconds $InstallTimeoutSeconds
$installAttempts += $attemptSummary
if ($attemptSummary.installReady) {
break
}
if ($attemptSummary.retryEligible -and $attempt -lt $MaxInstallAttempts) {
Write-Warning "Installer materialization failed with $($attemptSummary.failureClassification) on attempt $attempt. Retrying..."
Start-Sleep -Seconds $InstallRetryDelaySeconds
continue
}
break
}
$selectedAttempt = $installAttempts[-1]
$installerStageSummary = [ordered]@{
ok = $selectedAttempt.installReady
failureStage = if ($selectedAttempt.installReady) { '' } else { 'installer-materialization' }
failureClassification = $selectedAttempt.failureClassification
setupExe = $SetupExe
installDir = $InstallDir
installedExe = $installedExe
uninstallerPath = $uninstallerPath
preflight = $preflight
attemptCount = @($installAttempts).Count
attempts = $installAttempts
installerExitCode = $selectedAttempt.installerExitCode
installDirExists = $selectedAttempt.observation.installDirExists
installedExeExists = $selectedAttempt.observation.installedExeExists
resourcesAsarExists = $selectedAttempt.observation.resourcesAsarExists
runtimeResourceDirExists = $selectedAttempt.observation.runtimeResourceDirExists
packagedPythonExeExists = $selectedAttempt.observation.packagedPythonExeExists
packagedPythonManifestExists = $selectedAttempt.observation.packagedPythonManifestExists
uninstallerExists = $selectedAttempt.observation.uninstallerExists
fileCount = $selectedAttempt.observation.fileCount
totalBytes = [int64]$selectedAttempt.observation.totalBytes
}
if (-not $selectedAttempt.installReady) {
$failureStage = 'installer-materialization'
$failureClassification = $selectedAttempt.failureClassification
$failureError = if ($failureClassification -eq 'empty-exit-zero-files') {
"Installer exited without materializing files under $InstallDir."
} elseif ($failureClassification -eq 'missing-uninstaller-only') {
"Installer materialized app payload but did not write $uninstallerFileName."
} else {
"Installer did not reach a stable packaged file state within $InstallTimeoutSeconds seconds."
}
throw $failureError
}
$pythonImportProbe = & $packagedPythonExe -c "import openpyxl, pandas, requests, bs4, lxml, pypdf, docx, charset_normalizer, yaml, PIL, dotenv, playwright; print('ok')"
if ($LASTEXITCODE -ne 0 -or $pythonImportProbe -notmatch 'ok') {
$failureStage = 'installer-materialization'
$failureClassification = 'payload-validation-failure'
$failureError = 'Bundled Python import probe failed for the packaged runtime payload.'
$installerStageSummary.failureStage = $failureStage
$installerStageSummary.failureClassification = $failureClassification
$installerStageSummary.ok = $false
$installerStageSummary.pythonImportProbe = [ordered]@{
ok = $false
output = [string]$pythonImportProbe
}
throw $failureError
}
$installerStageSummary.pythonImportProbe = [ordered]@{
ok = $true
output = [string]$pythonImportProbe
}
$installerStageSummary.packagedWorkspaceTemplate = [ordered]@{
path = $packagedWorkspaceTemplate
exists = (Test-Path $packagedWorkspaceTemplate)
}
if (-not $installerStageSummary.packagedWorkspaceTemplate.exists) {
$failureStage = 'installer-materialization'
$failureClassification = 'payload-validation-failure'
$failureError = "Packaged runtime workspace template is missing: $packagedWorkspaceTemplate"
$installerStageSummary.failureStage = $failureStage
$installerStageSummary.failureClassification = $failureClassification
$installerStageSummary.ok = $false
throw $failureError
}
$runtimePayloadSummary = Get-RuntimePayloadSummary -RuntimeDir $runtimeResourceDir
$installerStageSummary['runtimePayload'] = $runtimePayloadSummary
$installerStageSummary['runtimePayloadFileCount'] = $runtimePayloadSummary.fileCount
$installerStageSummary['runtimePayloadSizeBytes'] = [int64]$runtimePayloadSummary.sizeBytes
$installerStageSummary['runtimePayloadTopLevelBreakdown'] = $runtimePayloadSummary.topLevelBreakdown
if ($SkipInstalledAppSmoke) {
$appStageSummary = [ordered]@{
ok = $true
skipped = $true
reason = 'SkipInstalledAppSmoke'
}
} else {
$failureStage = 'app-smoke'
$failureClassification = 'app-smoke-failure'
$appStageSummary = Invoke-InstalledAppSmoke
$failureStage = ''
$failureClassification = ''
}
$finalSummary = New-FinalSummary -Ok $true -FailureStage '' -FailureClassification '' -ErrorMessage ''
Write-JsonFile -FilePath $SmokeOutput -Payload $finalSummary
Write-Output ($finalSummary | ConvertTo-Json -Depth 20)
Stop-SmokeAppProcesses -KnownPaths $knownObservationPaths
exit 0
} catch {
if (-not $failureError) {
$failureError = $_.Exception.Message
}
if (-not $failureStage) {
$failureStage = if ($appStageSummary) { 'app-smoke' } else { 'installer-materialization' }
}
if (-not $failureClassification) {
$failureClassification = if ($failureStage -eq 'app-smoke') { 'app-smoke-failure' } else { 'partial-materialization' }
}
if ($installerStageSummary -and -not $installerStageSummary.ok -and [string]::IsNullOrWhiteSpace($installerStageSummary.failureClassification)) {
$installerStageSummary.failureClassification = $failureClassification
}
$finalSummary = New-FinalSummary -Ok $false -FailureStage $failureStage -FailureClassification $failureClassification -ErrorMessage $failureError
Write-JsonFile -FilePath $SmokeOutput -Payload $finalSummary
Stop-SmokeAppProcesses -KnownPaths $knownObservationPaths
throw
}
param(
[string]$SetupExe,
[string]$BaseOutputDir,
[int]$InstallTimeoutSeconds = 480
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$installerSmokeScript = Join-Path $repoRoot 'build\scripts\installer-smoke.ps1'
$installerDir = Join-Path $repoRoot 'dist\installer'
if (-not $SetupExe) {
$latestSetup = Get-ChildItem -Path $installerDir -Filter '*-Setup-*.exe' |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if (-not $latestSetup) {
throw "No installer exe found under $installerDir"
}
$SetupExe = $latestSetup.FullName
}
$SetupExe = (Resolve-Path $SetupExe).Path
if (-not $BaseOutputDir) {
$BaseOutputDir = Join-Path $repoRoot '.tmp\installer-target-residue-smoke'
}
$BaseOutputDir = [System.IO.Path]::GetFullPath($BaseOutputDir)
if (Test-Path $BaseOutputDir) {
Remove-Item -Path $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -ItemType Directory -Force -Path $BaseOutputDir | Out-Null
$installDir = Join-Path $BaseOutputDir 'target\QianjiangClaw'
$resultPath = Join-Path $BaseOutputDir 'installer-target-residue-result.json'
$staleUninstallerPath = Join-Path $installDir 'Uninstall QianjiangClaw.exe'
New-Item -ItemType Directory -Force -Path $installDir | Out-Null
[System.IO.File]::WriteAllText($staleUninstallerPath, 'stale residue', (New-Object System.Text.UTF8Encoding $false))
Write-Host "Running installer smoke against target with a preseeded stale uninstaller: $staleUninstallerPath"
powershell -ExecutionPolicy Bypass -File $installerSmokeScript `
-SetupExe $SetupExe `
-InstallDir $installDir `
-SmokeOutput $resultPath `
-InstallTimeoutSeconds $InstallTimeoutSeconds `
-SkipInstalledAppSmoke
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
$result = Get-Content -LiteralPath $resultPath | ConvertFrom-Json
if (-not $result.ok) {
throw 'Installer target residue smoke did not report ok=true.'
}
if (-not $result.uninstallerExists) {
throw 'Installer target residue smoke did not materialize the uninstaller after residue cleanup.'
}
$summary = [ordered]@{
ok = $true
setupExe = $SetupExe
installDir = $installDir
staleUninstallerPath = $staleUninstallerPath
resultPath = $resultPath
installerExitCode = $result.installerExitCode
fileCount = $result.fileCount
uninstallerExists = $result.uninstallerExists
}
[System.IO.File]::WriteAllText((Join-Path $BaseOutputDir 'installer-target-residue-summary.json'), ($summary | ConvertTo-Json -Depth 12), (New-Object System.Text.UTF8Encoding $false))
Write-Output ($summary | ConvertTo-Json -Depth 12)
......@@ -55,11 +55,12 @@ function Test-RuntimePayloadReady {
[string]$RuntimeDir
)
$openClawPackageDir = Join-Path $RuntimeDir 'openclaw\package'
$requiredPaths = @(
(Join-Path $RuntimeDir 'node\node.exe'),
(Join-Path $RuntimeDir 'openclaw\index.js'),
(Join-Path $RuntimeDir 'openclaw\package\openclaw.mjs'),
(Join-Path $RuntimeDir 'openclaw\package\package.json'),
(Join-Path $openClawPackageDir 'openclaw.mjs'),
(Join-Path $openClawPackageDir 'package.json'),
(Join-Path $RuntimeDir 'config\openclaw.json'),
(Join-Path $RuntimeDir 'python\python.exe'),
(Join-Path $RuntimeDir 'python\python-manifest.json'),
......@@ -68,6 +69,7 @@ function Test-RuntimePayloadReady {
(Join-Path $RuntimeDir 'runtime-manifest.json'),
(Join-Path $RuntimeDir 'README.md')
)
$requiredPaths += Get-RequiredWorkspaceTemplatePaths -OpenClawPackageDir $openClawPackageDir
foreach ($requiredPath in $requiredPaths) {
if (-not (Test-Path $requiredPath)) {
......@@ -78,6 +80,324 @@ function Test-RuntimePayloadReady {
return $true
}
function Get-RelativePath {
param(
[Parameter(Mandatory = $true)]
[string]$Root,
[Parameter(Mandatory = $true)]
[string]$Path
)
$normalizedRoot = [System.IO.Path]::GetFullPath($Root).TrimEnd('\', '/')
$normalizedPath = [System.IO.Path]::GetFullPath($Path)
$rootPrefix = $normalizedRoot + '\'
if ($normalizedPath.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
return $normalizedPath.Substring($rootPrefix.Length).Replace('\', '/')
}
$rootUri = [System.Uri]($rootPrefix.Replace('\', '/'))
$pathUri = [System.Uri]($normalizedPath.Replace('\', '/'))
return [System.Uri]::UnescapeDataString($rootUri.MakeRelativeUri($pathUri).ToString()).Replace('\', '/')
}
function Get-DirectoryMetrics {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
if (-not (Test-Path $Path)) {
return [ordered]@{
fileCount = 0
sizeBytes = [int64]0
}
}
$item = Get-Item -LiteralPath $Path -ErrorAction Stop
if (-not $item.PSIsContainer) {
return [ordered]@{
fileCount = 1
sizeBytes = [int64]$item.Length
}
}
$files = Get-ChildItem -LiteralPath $Path -Recurse -File -Force -ErrorAction SilentlyContinue
$sizeBytes = ($files | Measure-Object -Property Length -Sum).Sum
if ($null -eq $sizeBytes) {
$sizeBytes = 0
}
return [ordered]@{
fileCount = @($files).Count
sizeBytes = [int64]$sizeBytes
}
}
function Get-RuntimePayloadStats {
param(
[Parameter(Mandatory = $true)]
[string]$RuntimeDir
)
$rootMetrics = Get-DirectoryMetrics -Path $RuntimeDir
$topLevelBreakdown = @()
if (Test-Path $RuntimeDir) {
$topLevelBreakdown = @(
Get-ChildItem -LiteralPath $RuntimeDir -Force -ErrorAction SilentlyContinue |
Sort-Object Name |
ForEach-Object {
$metrics = Get-DirectoryMetrics -Path $_.FullName
[ordered]@{
path = Get-RelativePath -Root $RuntimeDir -Path $_.FullName
fileCount = $metrics.fileCount
sizeBytes = [int64]$metrics.sizeBytes
}
}
)
}
return [ordered]@{
fileCount = $rootMetrics.fileCount
sizeBytes = [int64]$rootMetrics.sizeBytes
topLevelBreakdown = $topLevelBreakdown
}
}
function Remove-OptionalLiteralPath {
param(
[Parameter(Mandatory = $true)]
[string]$RuntimeRoot,
[Parameter(Mandatory = $true)]
[string]$Path,
[System.Collections.IList]$RemovedPaths
)
if (-not (Test-Path $Path)) {
return $false
}
Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path $Path) {
return $false
}
if ($null -ne $RemovedPaths) {
$RemovedPaths.Add((Get-RelativePath -Root $RuntimeRoot -Path $Path)) | Out-Null
}
return $true
}
function Remove-OptionalNamedDirectories {
param(
[Parameter(Mandatory = $true)]
[string]$RuntimeRoot,
[Parameter(Mandatory = $true)]
[string]$Root,
[Parameter(Mandatory = $true)]
[string[]]$Names
)
$examples = [System.Collections.Generic.List[string]]::new()
$removedCount = 0
$candidates = @(
Get-ChildItem -LiteralPath $Root -Recurse -Directory -Force -ErrorAction SilentlyContinue |
Where-Object { $Names -contains $_.Name } |
Sort-Object FullName -Descending
)
foreach ($candidate in $candidates) {
if (-not (Test-Path $candidate.FullName)) {
continue
}
Remove-Item -LiteralPath $candidate.FullName -Recurse -Force -ErrorAction SilentlyContinue
if (-not (Test-Path $candidate.FullName)) {
$removedCount += 1
if ($examples.Count -lt 12) {
$examples.Add((Get-RelativePath -Root $RuntimeRoot -Path $candidate.FullName)) | Out-Null
}
}
}
return [ordered]@{
removedCount = $removedCount
examples = @($examples)
}
}
function Remove-OptionalFilesByPattern {
param(
[Parameter(Mandatory = $true)]
[string]$RuntimeRoot,
[Parameter(Mandatory = $true)]
[string]$Root,
[Parameter(Mandatory = $true)]
[string[]]$Patterns
)
$examples = [System.Collections.Generic.List[string]]::new()
$removedCount = 0
$candidates = @(
Get-ChildItem -LiteralPath $Root -Recurse -File -Force -ErrorAction SilentlyContinue |
Where-Object {
$name = $_.Name
foreach ($pattern in $Patterns) {
if ($name -like $pattern) {
return $true
}
}
return $false
}
)
foreach ($candidate in $candidates) {
if (-not (Test-Path $candidate.FullName)) {
continue
}
Remove-Item -LiteralPath $candidate.FullName -Force -ErrorAction SilentlyContinue
if (-not (Test-Path $candidate.FullName)) {
$removedCount += 1
if ($examples.Count -lt 12) {
$examples.Add((Get-RelativePath -Root $RuntimeRoot -Path $candidate.FullName)) | Out-Null
}
}
}
return [ordered]@{
removedCount = $removedCount
examples = @($examples)
}
}
function Get-RequiredWorkspaceTemplatePaths {
param(
[Parameter(Mandatory = $true)]
[string]$OpenClawPackageDir
)
return @(
(Join-Path $OpenClawPackageDir 'docs\reference\templates\AGENTS.md'),
(Join-Path $OpenClawPackageDir 'docs\reference\templates\BOOTSTRAP.md'),
(Join-Path $OpenClawPackageDir 'docs\reference\templates\HEARTBEAT.md'),
(Join-Path $OpenClawPackageDir 'docs\reference\templates\IDENTITY.md'),
(Join-Path $OpenClawPackageDir 'docs\reference\templates\SOUL.md'),
(Join-Path $OpenClawPackageDir 'docs\reference\templates\TOOLS.md'),
(Join-Path $OpenClawPackageDir 'docs\reference\templates\USER.md')
)
}
function Prune-OpenClawDocsPreservingTemplates {
param(
[Parameter(Mandatory = $true)]
[string]$RuntimeRoot,
[Parameter(Mandatory = $true)]
[string]$DocsDir
)
$preservedDirs = [System.Collections.Generic.List[string]]::new()
if (-not (Test-Path $DocsDir)) {
return [ordered]@{
pruned = $false
preservedDirs = @()
}
}
$preserveRoot = Join-Path $RuntimeRoot ('.docs-preserve-' + [guid]::NewGuid().ToString('N'))
$templateDirs = @(
(Join-Path $DocsDir 'reference\templates'),
(Join-Path $DocsDir 'zh-CN\reference\templates')
)
try {
foreach ($templateDir in $templateDirs) {
if (-not (Test-Path $templateDir)) {
continue
}
$relativeDir = Get-RelativePath -Root $DocsDir -Path $templateDir
$preservedPath = Join-Path $preserveRoot $relativeDir
$preservedParent = Split-Path $preservedPath -Parent
if ($preservedParent) {
New-Item -ItemType Directory -Force -Path $preservedParent | Out-Null
}
Copy-Item -LiteralPath $templateDir -Destination $preservedPath -Recurse -Force
}
Remove-Item -LiteralPath $DocsDir -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $DocsDir | Out-Null
foreach ($templateDir in $templateDirs) {
$relativeDir = Get-RelativePath -Root $DocsDir -Path $templateDir
$preservedPath = Join-Path $preserveRoot $relativeDir
if (-not (Test-Path $preservedPath)) {
continue
}
$restoreParent = Split-Path $templateDir -Parent
if ($restoreParent) {
New-Item -ItemType Directory -Force -Path $restoreParent | Out-Null
}
Copy-Item -LiteralPath $preservedPath -Destination $templateDir -Recurse -Force
$preservedDirs.Add((Get-RelativePath -Root $RuntimeRoot -Path $templateDir)) | Out-Null
}
} finally {
if (Test-Path $preserveRoot) {
Remove-Item -LiteralPath $preserveRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
return [ordered]@{
pruned = $true
preservedDirs = @($preservedDirs)
}
}
function Invoke-RuntimePayloadCleanup {
param(
[Parameter(Mandatory = $true)]
[string]$RuntimeRoot,
[Parameter(Mandatory = $true)]
[string]$OpenClawPackageDir,
[Parameter(Mandatory = $true)]
[string]$PythonDir
)
$removedLiteralPaths = [System.Collections.Generic.List[string]]::new()
$docsCleanup = Prune-OpenClawDocsPreservingTemplates -RuntimeRoot $RuntimeRoot -DocsDir (Join-Path $OpenClawPackageDir 'docs')
foreach ($targetPath in @(
(Join-Path $OpenClawPackageDir 'README.md'),
(Join-Path $OpenClawPackageDir 'README-header.png'),
(Join-Path $OpenClawPackageDir 'CHANGELOG.md'),
(Join-Path $PythonDir 'Doc'),
(Join-Path $PythonDir 'Tools'),
(Join-Path $PythonDir 'Lib\test'),
(Join-Path $PythonDir 'Lib\idlelib'),
(Join-Path $PythonDir 'Lib\tkinter'),
(Join-Path $PythonDir 'Lib\turtledemo'),
(Join-Path $PythonDir 'Lib\ensurepip'),
(Join-Path $PythonDir 'Lib\site-packages\pip'),
(Join-Path $PythonDir 'Lib\site-packages\setuptools'),
(Join-Path $PythonDir 'Lib\site-packages\wheel')
)) {
[void](Remove-OptionalLiteralPath -RuntimeRoot $RuntimeRoot -Path $targetPath -RemovedPaths $removedLiteralPaths)
}
$pythonCacheDirs = Remove-OptionalNamedDirectories -RuntimeRoot $RuntimeRoot -Root $PythonDir -Names @('__pycache__')
$sourceMapFiles = Remove-OptionalFilesByPattern -RuntimeRoot $RuntimeRoot -Root $OpenClawPackageDir -Patterns @('*.map')
$pythonCompiledFiles = Remove-OptionalFilesByPattern -RuntimeRoot $RuntimeRoot -Root $PythonDir -Patterns @('*.pyc', '*.pyo')
return [ordered]@{
cleanupRulesVersion = 2
prunedOpenClawDocs = [bool]$docsCleanup.pruned
preservedWorkspaceTemplateDirs = @($docsCleanup.preservedDirs)
removedLiteralPaths = @($removedLiteralPaths)
removedLiteralPathCount = $removedLiteralPaths.Count
removedPycacheDirectories = $pythonCacheDirs
removedSourceMapFiles = $sourceMapFiles
removedCompiledPythonFiles = $pythonCompiledFiles
}
}
function New-RuntimeSummary {
param(
[Parameter(Mandatory = $true)]
......@@ -104,6 +424,11 @@ function New-RuntimeSummary {
gatewayPort = $Manifest.gatewayPort
gatewayToken = $Manifest.gatewayToken
installedPythonPackages = $Manifest.installedPythonPackages
sizeBytes = if ($null -ne $Manifest.sizeBytes) { [int64]$Manifest.sizeBytes } else { $null }
fileCount = if ($null -ne $Manifest.fileCount) { [int]$Manifest.fileCount } else { $null }
topLevelBreakdown = if ($null -ne $Manifest.topLevelBreakdown) { $Manifest.topLevelBreakdown } else { @() }
payloadStats = if ($null -ne $Manifest.payloadStats) { $Manifest.payloadStats } else { $null }
cleanupSummary = if ($null -ne $Manifest.cleanupSummary) { $Manifest.cleanupSummary } else { $null }
}
}
......@@ -178,7 +503,7 @@ if (-not (Test-Path $SourcePythonDir)) {
}
$materializationInputs = [ordered]@{
schemaVersion = 1
schemaVersion = 2
gatewayPort = $GatewayPort
gatewayToken = $GatewayToken
sourceConfig = Get-FileFingerprint -Path $SourceConfigPath -IncludeHash
......@@ -394,6 +719,25 @@ The payload is considered ready only when the Node entry, OpenClaw package, Pyth
"@
[System.IO.File]::WriteAllText($payloadReadmePath, $readme.TrimStart(), $utf8NoBom)
$payloadStatsBeforeCleanup = Get-RuntimePayloadStats -RuntimeDir $stagingDir
$cleanupSummary = Invoke-RuntimePayloadCleanup -RuntimeRoot $stagingDir -OpenClawPackageDir $openclawPackageDir -PythonDir $pythonDir
$payloadStatsAfterCleanup = Get-RuntimePayloadStats -RuntimeDir $stagingDir
$cleanupBytesRemoved = [int64]($payloadStatsBeforeCleanup.sizeBytes - $payloadStatsAfterCleanup.sizeBytes)
$cleanupFilesRemoved = [int]($payloadStatsBeforeCleanup.fileCount - $payloadStatsAfterCleanup.fileCount)
Write-Host ("Runtime payload cleanup removed {0} files and {1} bytes" -f $cleanupFilesRemoved, $cleanupBytesRemoved)
$manifest['sizeBytes'] = [int64]$payloadStatsAfterCleanup.sizeBytes
$manifest['fileCount'] = [int]$payloadStatsAfterCleanup.fileCount
$manifest['topLevelBreakdown'] = $payloadStatsAfterCleanup.topLevelBreakdown
$manifest['payloadStats'] = [ordered]@{
beforeCleanup = $payloadStatsBeforeCleanup
afterCleanup = $payloadStatsAfterCleanup
bytesRemoved = $cleanupBytesRemoved
filesRemoved = $cleanupFilesRemoved
}
$manifest['cleanupSummary'] = $cleanupSummary
[System.IO.File]::WriteAllText($manifestPath, ($manifest | ConvertTo-Json -Depth 100), $utf8NoBom)
$requiredPaths = @(
(Join-Path $nodeDir 'node.exe'),
$wrapperPath,
......@@ -405,6 +749,7 @@ The payload is considered ready only when the Node entry, OpenClaw package, Pyth
$manifestPath,
$payloadReadmePath
)
$requiredPaths += Get-RequiredWorkspaceTemplatePaths -OpenClawPackageDir $openclawPackageDir
foreach ($requiredPath in $requiredPaths) {
if (-not (Test-Path $requiredPath)) {
throw "Bundled runtime materialization failed; missing $requiredPath"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment