software packages and deploying
This commit is contained in:
Executable
+902
@@ -0,0 +1,902 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import SoftwarePackage
|
||||
|
||||
|
||||
PACKAGE_ROOT = Path(os.getenv("SOFTWARE_PACKAGE_DIR", "/app/data/software-packages"))
|
||||
PACKAGE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def package_directory(package_id: int) -> Path:
|
||||
package_id = int(package_id)
|
||||
if package_id <= 0:
|
||||
raise ValueError("invalid package id")
|
||||
return PACKAGE_ROOT / str(package_id)
|
||||
|
||||
|
||||
def _safe_member_name(value: str) -> str:
|
||||
name = Path(str(value or "")).name
|
||||
if not name or name in {".", ".."}:
|
||||
raise ValueError("invalid package member name")
|
||||
return name
|
||||
|
||||
|
||||
def _safe_package_label(value: str) -> str:
|
||||
text = re.sub(r"[\x00-\x1f]+", " ", str(value or "")).strip()
|
||||
return re.sub(r"\s+", " ", text)[:180]
|
||||
|
||||
|
||||
def unique_package_name(db: Session, product_name: str, product_version: str) -> str:
|
||||
base = _safe_package_label(
|
||||
f"{product_name.strip()} {product_version.strip()}".strip()
|
||||
) or "Software package"
|
||||
candidate = base
|
||||
counter = 2
|
||||
while db.query(SoftwarePackage.id).filter(SoftwarePackage.name == candidate).first() is not None:
|
||||
candidate = _safe_package_label(f"{base} ({counter})")
|
||||
counter += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def write_package_storage(
|
||||
package_id: int,
|
||||
installer_path: Path,
|
||||
install_script: str,
|
||||
uninstall_script: str,
|
||||
detect_script: str,
|
||||
manifest: dict[str, Any],
|
||||
analysis: dict[str, Any],
|
||||
) -> Path:
|
||||
target = package_directory(package_id)
|
||||
temporary = PACKAGE_ROOT / f".{package_id}-{uuid.uuid4().hex}.tmp"
|
||||
shutil.rmtree(temporary, ignore_errors=True)
|
||||
temporary.mkdir(parents=True, exist_ok=False)
|
||||
try:
|
||||
installer_name = _safe_member_name(manifest.get("installer_file", installer_path.name))
|
||||
shutil.copy2(installer_path, temporary / installer_name)
|
||||
(temporary / "install.ps1").write_text(install_script, encoding="utf-8", newline="\n")
|
||||
(temporary / "uninstall.ps1").write_text(uninstall_script, encoding="utf-8", newline="\n")
|
||||
(temporary / "detect.ps1").write_text(detect_script, encoding="utf-8", newline="\n")
|
||||
(temporary / "package.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(temporary / "analysis.json").write_text(
|
||||
json.dumps(analysis, ensure_ascii=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
if target.exists():
|
||||
shutil.rmtree(target)
|
||||
temporary.replace(target)
|
||||
except Exception:
|
||||
shutil.rmtree(temporary, ignore_errors=True)
|
||||
raise
|
||||
return target
|
||||
|
||||
|
||||
def load_package_manifest(package_id: int) -> dict[str, Any]:
|
||||
manifest_path = package_directory(package_id) / "package.json"
|
||||
if not manifest_path.is_file():
|
||||
raise FileNotFoundError(f"Package manifest not found: {manifest_path}")
|
||||
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("package manifest is not an object")
|
||||
if str(data.get("schema") or "") not in {
|
||||
"assetmanager-software-package-v1",
|
||||
"assetmanager-setup-analyzer-package-v1",
|
||||
}:
|
||||
raise ValueError("unsupported package manifest schema")
|
||||
normalized, _notes = normalize_package_manifest(data)
|
||||
if normalized != data:
|
||||
manifest_path.write_text(json.dumps(normalized, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
|
||||
return normalized
|
||||
|
||||
|
||||
def package_payload_files(package_id: int, action: str) -> list[Path]:
|
||||
manifest = load_package_manifest(package_id)
|
||||
refresh_generated_package_runtime_scripts(package_id, manifest)
|
||||
root = package_directory(package_id)
|
||||
action = str(action or "").strip().lower()
|
||||
if action not in {"install", "uninstall", "reinstall"}:
|
||||
raise ValueError("unsupported deployment action")
|
||||
|
||||
names: list[str] = ["package.json"]
|
||||
if action in {"install", "reinstall"}:
|
||||
names.append(_safe_member_name(manifest.get("installer_file", "")))
|
||||
names.append(_safe_member_name((manifest.get("install") or {}).get("script", "install.ps1")))
|
||||
if action in {"uninstall", "reinstall"}:
|
||||
names.append(_safe_member_name((manifest.get("uninstall") or {}).get("script", "uninstall.ps1")))
|
||||
if str((manifest.get("detection") or {}).get("method") or "manual") != "manual":
|
||||
names.append(_safe_member_name((manifest.get("detection") or {}).get("script", "detect.ps1")))
|
||||
|
||||
result: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for name in names:
|
||||
if not name or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
path = root / name
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Package file not found: {path}")
|
||||
result.append(path)
|
||||
return result
|
||||
|
||||
|
||||
def package_storage_size(package_id: int) -> int:
|
||||
root = package_directory(package_id)
|
||||
if not root.is_dir():
|
||||
return 0
|
||||
return sum(path.stat().st_size for path in root.rglob("*") if path.is_file())
|
||||
|
||||
|
||||
|
||||
def human_size(size: int) -> str:
|
||||
value = float(max(0, int(size or 0)))
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if value < 1024.0 or unit == "TB":
|
||||
return f"{int(value)} {unit}" if unit == "B" else f"{value:.1f} {unit}"
|
||||
value /= 1024.0
|
||||
return f"{int(size or 0)} B"
|
||||
|
||||
def package_summary(package: SoftwarePackage) -> dict[str, Any]:
|
||||
manifest: dict[str, Any] = {}
|
||||
error = ""
|
||||
try:
|
||||
manifest = load_package_manifest(package.id)
|
||||
except Exception as exc:
|
||||
error = str(exc)
|
||||
return {
|
||||
"package": package,
|
||||
"manifest": manifest,
|
||||
"storage_size": package_storage_size(package.id),
|
||||
"storage_error": error,
|
||||
}
|
||||
|
||||
|
||||
def package_execution_timeout_seconds(manifest: dict[str, Any]) -> int:
|
||||
try:
|
||||
value = int((manifest.get("install") or {}).get("timeout_seconds") or 600)
|
||||
except (TypeError, ValueError):
|
||||
value = 600
|
||||
return max(30, min(value, 86400))
|
||||
|
||||
|
||||
def _ps_quote(value: str) -> str:
|
||||
return "'" + str(value or "").replace("'", "''") + "'"
|
||||
|
||||
|
||||
def _package_identity_text(manifest: dict[str, Any]) -> str:
|
||||
parts = [
|
||||
str(manifest.get("name") or ""),
|
||||
str(manifest.get("vendor") or ""),
|
||||
str(manifest.get("installer_file") or ""),
|
||||
str((manifest.get("detection") or {}).get("display_name") or ""),
|
||||
]
|
||||
return " ".join(parts).casefold()
|
||||
|
||||
|
||||
def _append_install_argument(arguments: str, value: str, pattern: str) -> str:
|
||||
arguments = str(arguments or "").strip()
|
||||
if re.search(pattern, arguments, flags=re.IGNORECASE):
|
||||
return arguments
|
||||
return (arguments + " " + value).strip()
|
||||
|
||||
|
||||
def normalize_process_names(value: Any) -> list[str]:
|
||||
if isinstance(value, str):
|
||||
raw_items = re.split(r"[\r\n,;]+", value)
|
||||
elif isinstance(value, (list, tuple, set)):
|
||||
raw_items = [str(item or "") for item in value]
|
||||
else:
|
||||
raw_items = []
|
||||
|
||||
result: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw in raw_items:
|
||||
name = str(raw or "").strip().strip('"').strip("'")
|
||||
name = name.replace("\\", "/").rsplit("/", 1)[-1].strip()
|
||||
if not name or any(char in name for char in "*?[]"):
|
||||
continue
|
||||
name = re.sub(r"[\x00-\x1f]", "", name).strip()
|
||||
if not name:
|
||||
continue
|
||||
if not name.casefold().endswith(".exe"):
|
||||
name += ".exe"
|
||||
name = name[:128]
|
||||
key = name.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
result.append(name)
|
||||
if len(result) >= 32:
|
||||
break
|
||||
return result
|
||||
|
||||
|
||||
def _process_control_values(manifest: dict[str, Any]) -> tuple[list[str], int, bool]:
|
||||
control = manifest.get("process_control") or {}
|
||||
if not isinstance(control, dict):
|
||||
control = {}
|
||||
process_names = normalize_process_names(control.get("process_names"))
|
||||
try:
|
||||
grace_seconds = int(control.get("grace_seconds", 5))
|
||||
except (TypeError, ValueError):
|
||||
grace_seconds = 5
|
||||
grace_seconds = max(0, min(grace_seconds, 30))
|
||||
force_close = control.get("force_close", True)
|
||||
if isinstance(force_close, str):
|
||||
force_close = force_close.strip().lower() in {"1", "true", "yes", "on"}
|
||||
else:
|
||||
force_close = bool(force_close)
|
||||
return process_names, grace_seconds, force_close
|
||||
|
||||
|
||||
def _process_control_ps_lines(manifest: dict[str, Any], phase: str) -> list[str]:
|
||||
process_names, grace_seconds, force_close = _process_control_values(manifest)
|
||||
if not process_names:
|
||||
return []
|
||||
ps_names = ", ".join(_ps_quote(name) for name in process_names)
|
||||
return [
|
||||
f"$configuredProcessNames = @({ps_names})",
|
||||
f"$processGraceSeconds = {grace_seconds}",
|
||||
"$forceCloseProcesses = $" + ("true" if force_close else "false"),
|
||||
"",
|
||||
"function Stop-ConfiguredPackageProcesses([string]$Phase) {",
|
||||
"\tforeach ($configuredName in $configuredProcessNames) {",
|
||||
"\t\t$lookupName = [System.IO.Path]::GetFileNameWithoutExtension([string]$configuredName)",
|
||||
"\t\tif ([string]::IsNullOrWhiteSpace($lookupName)) { continue }",
|
||||
"\t\t$running = @(Get-Process -Name $lookupName -ErrorAction SilentlyContinue)",
|
||||
"\t\tif ($running.Count -eq 0) {",
|
||||
"\t\t\tWrite-Output (\"No running configured process before \" + $Phase + \": \" + [string]$configuredName)",
|
||||
"\t\t\tcontinue",
|
||||
"\t\t}",
|
||||
"\t\tforeach ($item in $running) {",
|
||||
"\t\t\tWrite-Output (\"Running process found before \" + $Phase + \": \" + $item.ProcessName + '.exe; PID=' + $item.Id)",
|
||||
"\t\t}",
|
||||
"\t\t$closeRequested = $false",
|
||||
"\t\tforeach ($item in $running) {",
|
||||
"\t\t\ttry {",
|
||||
"\t\t\t\tif ($item.MainWindowHandle -ne 0) {",
|
||||
"\t\t\t\t\t$requested = $item.CloseMainWindow()",
|
||||
"\t\t\t\t\tif ($requested) {",
|
||||
"\t\t\t\t\t\t$closeRequested = $true",
|
||||
"\t\t\t\t\t\tWrite-Output (\"Close request sent: \" + $item.ProcessName + '.exe; PID=' + $item.Id)",
|
||||
"\t\t\t\t\t}",
|
||||
"\t\t\t\t}",
|
||||
"\t\t\t} catch {}",
|
||||
"\t\t}",
|
||||
"\t\tif ($closeRequested -and $processGraceSeconds -gt 0) {",
|
||||
"\t\t\t$deadline = (Get-Date).AddSeconds($processGraceSeconds)",
|
||||
"\t\t\tdo {",
|
||||
"\t\t\t\t$remaining = @(Get-Process -Name $lookupName -ErrorAction SilentlyContinue)",
|
||||
"\t\t\t\tif ($remaining.Count -eq 0) { break }",
|
||||
"\t\t\t\tStart-Sleep -Milliseconds 400",
|
||||
"\t\t\t} while ((Get-Date) -lt $deadline)",
|
||||
"\t\t}",
|
||||
"\t\t$remaining = @(Get-Process -Name $lookupName -ErrorAction SilentlyContinue)",
|
||||
"\t\tif ($remaining.Count -gt 0 -and $forceCloseProcesses) {",
|
||||
"\t\t\tforeach ($item in $remaining) {",
|
||||
"\t\t\t\tWrite-Output (\"Process still running; forcing termination: \" + $item.ProcessName + '.exe; PID=' + $item.Id)",
|
||||
"\t\t\t\ttry { Stop-Process -Id $item.Id -Force -ErrorAction Stop } catch { throw (\"Could not terminate process \" + $item.ProcessName + '.exe; PID=' + $item.Id + ': ' + $_.Exception.Message) }",
|
||||
"\t\t\t}",
|
||||
"\t\t\tStart-Sleep -Milliseconds 500",
|
||||
"\t\t}",
|
||||
"\t\t$remaining = @(Get-Process -Name $lookupName -ErrorAction SilentlyContinue)",
|
||||
"\t\tif ($remaining.Count -gt 0) {",
|
||||
"\t\t\t$ids = ($remaining | Select-Object -ExpandProperty Id) -join ','",
|
||||
"\t\t\tthrow (\"Configured process is still running before \" + $Phase + \": \" + [string]$configuredName + '; PID=' + $ids)",
|
||||
"\t\t}",
|
||||
"\t\tWrite-Output (\"Configured process stopped before \" + $Phase + \": \" + [string]$configuredName)",
|
||||
"\t}",
|
||||
"}",
|
||||
"",
|
||||
f"Stop-ConfiguredPackageProcesses -Phase {_ps_quote(phase)}",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
def normalize_package_manifest(manifest: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
|
||||
if not isinstance(manifest, dict):
|
||||
raise ValueError("package manifest is not an object")
|
||||
result = json.loads(json.dumps(manifest))
|
||||
notes: list[str] = []
|
||||
installer_type = str(result.get("installer_type") or "").strip().lower()
|
||||
identity = _package_identity_text(result)
|
||||
install = result.setdefault("install", {})
|
||||
if not isinstance(install, dict):
|
||||
install = {}
|
||||
result["install"] = install
|
||||
arguments = str(install.get("arguments") or "").strip()
|
||||
|
||||
if installer_type == "inno" and "greenshot" in identity:
|
||||
before = arguments
|
||||
current_user_scope = re.search(r"(?i)(^|\s)/CURRENTUSER(?=\s|$)", arguments) is not None
|
||||
if not current_user_scope:
|
||||
if not re.search(r"(?i)(^|\s)/ALLUSERS(?=\s|$)", arguments):
|
||||
arguments = _append_install_argument(arguments, "/ALLUSERS", r"(^|\s)/ALLUSERS(?=\s|$)")
|
||||
if not re.search(r"(?i)(^|\s)/DIR=", arguments):
|
||||
arguments = (arguments + ' /DIR="C:\\Program Files\\Greenshot"').strip()
|
||||
if arguments != before:
|
||||
notes.append("greenshot_machine_scope")
|
||||
install["arguments"] = arguments
|
||||
if not current_user_scope:
|
||||
result.setdefault("deployment_profile", "greenshot-machine")
|
||||
|
||||
existing_process_control = result.get("process_control")
|
||||
process_names_configured = isinstance(existing_process_control, dict) and "process_names" in existing_process_control
|
||||
process_control = result.setdefault("process_control", {})
|
||||
if not isinstance(process_control, dict):
|
||||
process_control = {}
|
||||
result["process_control"] = process_control
|
||||
process_names = normalize_process_names(process_control.get("process_names"))
|
||||
if "greenshot" in identity and not process_names and not process_names_configured:
|
||||
process_names = ["Greenshot.exe"]
|
||||
notes.append("greenshot_process_control")
|
||||
process_control["process_names"] = process_names
|
||||
try:
|
||||
grace_seconds = int(process_control.get("grace_seconds", 5))
|
||||
except (TypeError, ValueError):
|
||||
grace_seconds = 5
|
||||
process_control["grace_seconds"] = max(0, min(grace_seconds, 30))
|
||||
force_close = process_control.get("force_close", True)
|
||||
if isinstance(force_close, str):
|
||||
force_close = force_close.strip().lower() in {"1", "true", "yes", "on"}
|
||||
process_control["force_close"] = bool(force_close)
|
||||
|
||||
if installer_type == "inno" and process_names:
|
||||
arguments = str(install.get("arguments") or arguments).strip()
|
||||
if not re.search(r"(?i)(^|\s)/(NO)?CLOSEAPPLICATIONS(?=\s|$)", arguments):
|
||||
arguments = _append_install_argument(arguments, "/CLOSEAPPLICATIONS", r"(^|\s)/(NO)?CLOSEAPPLICATIONS(?=\s|$)")
|
||||
if process_control["force_close"] and not re.search(r"(?i)(^|\s)/(NO)?FORCECLOSEAPPLICATIONS(?=\s|$)", arguments):
|
||||
arguments = _append_install_argument(arguments, "/FORCECLOSEAPPLICATIONS", r"(^|\s)/(NO)?FORCECLOSEAPPLICATIONS(?=\s|$)")
|
||||
install["arguments"] = arguments
|
||||
|
||||
return result, notes
|
||||
|
||||
def delete_package_storage(package_id: int) -> None:
|
||||
root = package_directory(package_id)
|
||||
if root.is_dir():
|
||||
shutil.rmtree(root)
|
||||
|
||||
|
||||
def _int_codes(value: Any, fallback: list[int]) -> list[int]:
|
||||
result: list[int] = []
|
||||
for item in value if isinstance(value, (list, tuple, set)) else fallback:
|
||||
try:
|
||||
number = int(item)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if number not in result:
|
||||
result.append(number)
|
||||
return result or list(fallback)
|
||||
|
||||
|
||||
def build_generated_install_script(manifest: dict[str, Any]) -> str:
|
||||
manifest, notes = normalize_package_manifest(manifest)
|
||||
install = manifest.get("install") or {}
|
||||
installer_type = str(manifest.get("installer_type") or "").strip().lower()
|
||||
installer_file = _safe_member_name(manifest.get("installer_file", ""))
|
||||
arguments = str(install.get("arguments") or "").strip()
|
||||
success_codes = _int_codes(install.get("success_codes"), [0])
|
||||
reboot_codes = _int_codes(install.get("reboot_codes"), [])
|
||||
timeout_seconds = package_execution_timeout_seconds(manifest)
|
||||
suppress_browser = bool(install.get("suppress_browser"))
|
||||
greenshot_preset = "greenshot_machine_scope" in notes or str(manifest.get("deployment_profile") or "") == "greenshot-machine"
|
||||
success = ", ".join(str(code) for code in success_codes)
|
||||
reboot = ", ".join(str(code) for code in reboot_codes) or "-999999"
|
||||
|
||||
lines = [
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
"Set-StrictMode -Version Latest",
|
||||
"",
|
||||
"$packageDir = $PSScriptRoot",
|
||||
f"$installer = Join-Path $packageDir {_ps_quote(installer_file)}",
|
||||
f"$installerType = {_ps_quote(installer_type)}",
|
||||
f"$arguments = {_ps_quote(arguments)}",
|
||||
f"$successCodes = @({success})",
|
||||
f"$rebootCodes = @({reboot})",
|
||||
f"$timeoutSeconds = {timeout_seconds}",
|
||||
"$suppressBrowser = $" + ("true" if suppress_browser else "false"),
|
||||
"$greenshotPreset = $" + ("true" if greenshot_preset else "false"),
|
||||
"$innoLog = $null",
|
||||
"",
|
||||
"if (-not (Test-Path -LiteralPath $installer)) {",
|
||||
"\tWrite-Error \"Installer not found: $installer\"",
|
||||
"\texit 2",
|
||||
"}",
|
||||
"",
|
||||
"if ($installerType -eq 'inno') {",
|
||||
"\t$innoLog = Join-Path $env:TEMP ('AssetManager-Inno-' + [guid]::NewGuid().ToString('N') + '.log')",
|
||||
"\tif ($arguments -notmatch '(?i)(^|\\s)/LOG(=|\\s)') {",
|
||||
"\t\t$arguments = ($arguments + ' /LOG=\"' + $innoLog + '\"').Trim()",
|
||||
"\t}",
|
||||
"}",
|
||||
"",
|
||||
"Write-Output (\"Installer file: \" + $installer)",
|
||||
"Write-Output (\"Installer type: \" + $installerType)",
|
||||
"Write-Output (\"Installer working directory: \" + $packageDir)",
|
||||
"if ($greenshotPreset) { Write-Output 'Greenshot deployment preset: machine scope, C:\\Program Files\\Greenshot' }",
|
||||
"",
|
||||
"$browserPidsBefore = @()",
|
||||
"if ($suppressBrowser) {",
|
||||
"\t$browserPidsBefore = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -in @('msedge','chrome','firefox','brave','opera','iexplore') } | Select-Object -ExpandProperty Id)",
|
||||
"}",
|
||||
"",
|
||||
]
|
||||
|
||||
lines += _process_control_ps_lines(manifest, "installation")
|
||||
|
||||
if installer_type == "msi":
|
||||
lines += [
|
||||
"$processArguments = '/i \"' + $installer + '\" ' + $arguments",
|
||||
"$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList $processArguments -WorkingDirectory $packageDir -PassThru -NoNewWindow",
|
||||
]
|
||||
elif installer_type == "msp":
|
||||
lines += [
|
||||
"$processArguments = '/p \"' + $installer + '\" ' + $arguments",
|
||||
"$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList $processArguments -WorkingDirectory $packageDir -PassThru -NoNewWindow",
|
||||
]
|
||||
elif installer_type == "msu":
|
||||
lines += [
|
||||
"$processArguments = '\"' + $installer + '\" ' + $arguments",
|
||||
"$process = Start-Process -FilePath 'wusa.exe' -ArgumentList $processArguments -WorkingDirectory $packageDir -PassThru -NoNewWindow",
|
||||
]
|
||||
elif installer_type in {"msix", "appx"}:
|
||||
lines += ["Add-AppxPackage -Path $installer -ErrorAction Stop", "exit 0"]
|
||||
return "\n".join(lines) + "\n"
|
||||
else:
|
||||
lines.append("$process = Start-Process -FilePath $installer -ArgumentList $arguments -WorkingDirectory $packageDir -PassThru -NoNewWindow")
|
||||
|
||||
lines += [
|
||||
"Write-Output (\"Installer PID: \" + $process.Id)",
|
||||
"try {",
|
||||
"\tWait-Process -Id $process.Id -Timeout $timeoutSeconds -ErrorAction Stop",
|
||||
"} catch {",
|
||||
"\ttry { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue } catch {}",
|
||||
"\tWrite-Error (\"Installer timeout after \" + $timeoutSeconds + \" seconds.\")",
|
||||
"\texit 1460",
|
||||
"}",
|
||||
"$process.Refresh()",
|
||||
"$exitCode = [int]$process.ExitCode",
|
||||
"Write-Output (\"Installer exit code: \" + $exitCode)",
|
||||
"",
|
||||
"if ($suppressBrowser) {",
|
||||
"\tStart-Sleep -Milliseconds 1500",
|
||||
"\t$newBrowsers = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -in @('msedge','chrome','firefox','brave','opera','iexplore') -and $browserPidsBefore -notcontains $_.Id })",
|
||||
"\tforeach ($browser in $newBrowsers) {",
|
||||
"\t\ttry { Write-Output (\"Stopping installer-opened browser: \" + $browser.ProcessName + \"; PID=\" + $browser.Id); Stop-Process -Id $browser.Id -Force -ErrorAction SilentlyContinue } catch {}",
|
||||
"\t}",
|
||||
"}",
|
||||
"",
|
||||
"if ($null -ne $innoLog -and (Test-Path -LiteralPath $innoLog)) {",
|
||||
"\tWrite-Output 'Inno Setup log summary:'",
|
||||
"\t$interesting = @(Get-Content -LiteralPath $innoLog -ErrorAction SilentlyContinue | Where-Object { $_ -match '(?i)(Dest filename:|Creating directory:|Defaulting to Abort|Error|Installation process succeeded|Installation process failed|Setup exit code|Installation finished)' } | Select-Object -Last 60)",
|
||||
"\tforeach ($line in $interesting) { Write-Output ('Inno: ' + [string]$line) }",
|
||||
"}",
|
||||
"",
|
||||
"if ($successCodes -notcontains $exitCode) { exit $exitCode }",
|
||||
"if ($rebootCodes -contains $exitCode) { exit 3010 }",
|
||||
"exit 0",
|
||||
]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _registry_discovery_block(
|
||||
display_name: str,
|
||||
display_version: str,
|
||||
publisher: str,
|
||||
) -> str:
|
||||
return f"""$displayName = {_ps_quote(display_name)}
|
||||
$displayVersion = {_ps_quote(display_version)}
|
||||
$publisher = {_ps_quote(publisher)}
|
||||
|
||||
function Get-UninstallEntries {{
|
||||
$entries = @()
|
||||
$machineRoots = @(
|
||||
'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',
|
||||
'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',
|
||||
'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',
|
||||
'HKCU:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'
|
||||
)
|
||||
$entries += @(Get-ItemProperty -Path $machineRoots -ErrorAction SilentlyContinue)
|
||||
try {{
|
||||
$userHives = @(Get-ChildItem -Path 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue | Where-Object {{
|
||||
$_.PSChildName -match '^S-1-(5-21|12-1)-'
|
||||
}})
|
||||
foreach ($hive in $userHives) {{
|
||||
$roots = @(
|
||||
("Registry::HKEY_USERS\\" + $hive.PSChildName + "\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*"),
|
||||
("Registry::HKEY_USERS\\" + $hive.PSChildName + "\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*")
|
||||
)
|
||||
$entries += @(Get-ItemProperty -Path $roots -ErrorAction SilentlyContinue)
|
||||
}}
|
||||
}} catch {{
|
||||
}}
|
||||
return @($entries | Where-Object {{ -not [string]::IsNullOrWhiteSpace([string]$_.DisplayName) }})
|
||||
}}
|
||||
|
||||
function Get-PackageEntry {{
|
||||
$entries = @(Get-UninstallEntries)
|
||||
if ($entries.Count -eq 0) {{ return $null }}
|
||||
|
||||
$exact = @($entries | Where-Object {{ [string]$_.DisplayName -ieq $displayName }})
|
||||
$candidates = $exact
|
||||
if ($candidates.Count -eq 0) {{
|
||||
$prefixSpace = $displayName + ' '
|
||||
$prefixDash = $displayName + '-'
|
||||
$prefixParen = $displayName + ' ('
|
||||
$candidates = @($entries | Where-Object {{
|
||||
$name = [string]$_.DisplayName
|
||||
$name.StartsWith($prefixSpace, [System.StringComparison]::OrdinalIgnoreCase) -or
|
||||
$name.StartsWith($prefixDash, [System.StringComparison]::OrdinalIgnoreCase) -or
|
||||
$name.StartsWith($prefixParen, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
}})
|
||||
}}
|
||||
if ($candidates.Count -eq 0) {{ return $null }}
|
||||
|
||||
$ranked = foreach ($entry in $candidates) {{
|
||||
$score = 0
|
||||
if ([string]$entry.DisplayName -ieq $displayName) {{ $score += 100 }} else {{ $score += 60 }}
|
||||
if (-not [string]::IsNullOrWhiteSpace($displayVersion) -and [string]$entry.DisplayVersion -ieq $displayVersion) {{ $score += 30 }}
|
||||
if (-not [string]::IsNullOrWhiteSpace($publisher) -and [string]$entry.Publisher -ieq $publisher) {{ $score += 20 }}
|
||||
[pscustomobject]@{{ Score = $score; Entry = $entry }}
|
||||
}}
|
||||
return ($ranked | Sort-Object Score -Descending | Select-Object -First 1).Entry
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def build_generated_detection_script(manifest: dict[str, Any]) -> str:
|
||||
detection = manifest.get("detection") or {}
|
||||
method = str(detection.get("method") or "manual")
|
||||
product_code = str(detection.get("product_code") or "").strip()
|
||||
display_name = str(detection.get("display_name") or manifest.get("name") or "").strip()
|
||||
display_version = str(detection.get("display_version") or manifest.get("version") or "").strip()
|
||||
publisher = str(detection.get("publisher") or manifest.get("vendor") or "").strip()
|
||||
installer_type = str(manifest.get("installer_type") or "").strip().lower()
|
||||
|
||||
if method == "msi_product_code" and product_code:
|
||||
return f"""$ErrorActionPreference = 'SilentlyContinue'
|
||||
$productCode = {_ps_quote(product_code)}
|
||||
$paths = @(
|
||||
"HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\$productCode",
|
||||
"HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\$productCode"
|
||||
)
|
||||
if ($paths | Where-Object {{ Test-Path -LiteralPath $_ }}) {{
|
||||
Write-Output "Software found by MSI ProductCode: $productCode"
|
||||
exit 0
|
||||
}}
|
||||
Write-Output "Software not found by MSI ProductCode: $productCode"
|
||||
exit 1
|
||||
"""
|
||||
|
||||
if method == "appx_package" and display_name:
|
||||
return f"""$ErrorActionPreference = 'SilentlyContinue'
|
||||
$name = {_ps_quote(display_name)}
|
||||
$package = Get-AppxPackage -AllUsers | Where-Object {{ $_.Name -eq $name -or $_.PackageFullName -like "$name*" }} | Select-Object -First 1
|
||||
if ($null -ne $package) {{
|
||||
Write-Output ("Software found: " + $package.PackageFullName)
|
||||
exit 0
|
||||
}}
|
||||
Write-Output "Software not found: $name"
|
||||
exit 1
|
||||
"""
|
||||
|
||||
if method == "registry_display_name" and display_name:
|
||||
return "$ErrorActionPreference = 'SilentlyContinue'\n" + _registry_discovery_block(
|
||||
display_name,
|
||||
display_version,
|
||||
publisher,
|
||||
) + """$entry = Get-PackageEntry
|
||||
if ($null -ne $entry) {
|
||||
Write-Output ("Software found: " + [string]$entry.DisplayName + "; version=" + [string]$entry.DisplayVersion + "; publisher=" + [string]$entry.Publisher)
|
||||
exit 0
|
||||
}
|
||||
Write-Output ("Software not found: " + $displayName)
|
||||
exit 1
|
||||
"""
|
||||
|
||||
if installer_type in {"msix", "appx"} and display_name:
|
||||
fallback = dict(manifest)
|
||||
fallback["detection"] = {"method": "appx_package", "display_name": display_name}
|
||||
return build_generated_detection_script(fallback)
|
||||
|
||||
return "Write-Output 'No automatic detection rule is available for this package.'\nexit 2\n"
|
||||
|
||||
|
||||
def build_generated_uninstall_script(manifest: dict[str, Any]) -> str:
|
||||
detection = manifest.get("detection") or {}
|
||||
method = str(detection.get("method") or "manual")
|
||||
product_code = str(detection.get("product_code") or "").strip()
|
||||
display_name = str(detection.get("display_name") or manifest.get("name") or "").strip()
|
||||
display_version = str(detection.get("display_version") or manifest.get("version") or "").strip()
|
||||
publisher = str(detection.get("publisher") or manifest.get("vendor") or "").strip()
|
||||
installer_type = str(manifest.get("installer_type") or "").strip().lower()
|
||||
timeout_seconds = package_execution_timeout_seconds(manifest)
|
||||
process_block = "\n".join(_process_control_ps_lines(manifest, "uninstallation"))
|
||||
if process_block:
|
||||
process_block += "\n"
|
||||
|
||||
if method == "msi_product_code" and product_code:
|
||||
return f"""$ErrorActionPreference = 'Stop'
|
||||
{process_block}$productCode = {_ps_quote(product_code)}
|
||||
$arguments = '/x "' + $productCode + '" /qn /norestart'
|
||||
Write-Output ("Starting MSI uninstall: msiexec.exe " + $arguments)
|
||||
$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList $arguments -PassThru -NoNewWindow
|
||||
try {{
|
||||
Wait-Process -Id $process.Id -Timeout {timeout_seconds} -ErrorAction Stop
|
||||
}} catch {{
|
||||
Write-Error "MSI uninstall timed out after {timeout_seconds} seconds."
|
||||
exit 1460
|
||||
}}
|
||||
$process.Refresh()
|
||||
$exitCode = [int]$process.ExitCode
|
||||
Write-Output "Uninstaller exit code: $exitCode"
|
||||
if (@(0, 1641, 3010) -notcontains $exitCode) {{ exit $exitCode }}
|
||||
if (@(1641, 3010) -contains $exitCode) {{ exit 3010 }}
|
||||
exit 0
|
||||
"""
|
||||
|
||||
if method == "appx_package" and display_name:
|
||||
return f"""$ErrorActionPreference = 'Stop'
|
||||
{process_block}$name = {_ps_quote(display_name)}
|
||||
$packages = @(Get-AppxPackage -AllUsers | Where-Object {{ $_.Name -eq $name -or $_.PackageFullName -like "$name*" }})
|
||||
if ($packages.Count -eq 0) {{
|
||||
Write-Output "Software not found: $name. Nothing to uninstall."
|
||||
exit 0
|
||||
}}
|
||||
foreach ($package in $packages) {{
|
||||
Write-Output ("Removing AppX package: " + $package.PackageFullName)
|
||||
Remove-AppxPackage -Package $package.PackageFullName -AllUsers -ErrorAction Stop
|
||||
}}
|
||||
exit 0
|
||||
"""
|
||||
|
||||
if method == "registry_display_name" and display_name:
|
||||
base = "$ErrorActionPreference = 'Stop'\n" + process_block + _registry_discovery_block(
|
||||
display_name,
|
||||
display_version,
|
||||
publisher,
|
||||
)
|
||||
base += f"$installerType = {_ps_quote(installer_type)}\n$timeoutSeconds = {timeout_seconds}\n"
|
||||
base += """
|
||||
function Split-UninstallCommand([string]$Command) {
|
||||
$expanded = [Environment]::ExpandEnvironmentVariables([string]$Command).Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($expanded)) { throw 'Uninstall command is empty.' }
|
||||
if ($expanded.StartsWith('"')) {
|
||||
$closing = $expanded.IndexOf('"', 1)
|
||||
if ($closing -lt 1) { throw "Invalid uninstall command: $expanded" }
|
||||
$filePath = $expanded.Substring(1, $closing - 1)
|
||||
$arguments = $expanded.Substring($closing + 1).Trim()
|
||||
} else {
|
||||
$space = $expanded.IndexOf(' ')
|
||||
if ($space -lt 0) {
|
||||
$filePath = $expanded
|
||||
$arguments = ''
|
||||
} else {
|
||||
$filePath = $expanded.Substring(0, $space)
|
||||
$arguments = $expanded.Substring($space + 1).Trim()
|
||||
}
|
||||
}
|
||||
return [pscustomobject]@{ FilePath = $filePath; Arguments = $arguments }
|
||||
}
|
||||
|
||||
$entry = Get-PackageEntry
|
||||
if ($null -eq $entry) {
|
||||
Write-Output ("Software not found: " + $displayName + ". Nothing to uninstall.")
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Output ("Software found: " + [string]$entry.DisplayName + "; version=" + [string]$entry.DisplayVersion + "; publisher=" + [string]$entry.Publisher)
|
||||
$command = [string]$entry.QuietUninstallString
|
||||
$usedQuiet = -not [string]::IsNullOrWhiteSpace($command)
|
||||
if ($usedQuiet) {
|
||||
Write-Output 'QuietUninstallString found.'
|
||||
} else {
|
||||
$command = [string]$entry.UninstallString
|
||||
Write-Output 'QuietUninstallString not available; using UninstallString.'
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($command)) {
|
||||
Write-Error 'No uninstall command was found in the registry.'
|
||||
exit 3
|
||||
}
|
||||
|
||||
if (-not $usedQuiet) {
|
||||
if ($installerType -eq 'inno' -and $command -notmatch '(?i)/(very)?silent') {
|
||||
$command += ' /VERYSILENT /SUPPRESSMSGBOXES /NORESTART'
|
||||
} elseif ($installerType -eq 'nsis' -and $command -notmatch '(?i)(^|\\s)/S($|\\s)') {
|
||||
$command += ' /S'
|
||||
}
|
||||
}
|
||||
|
||||
$parts = Split-UninstallCommand $command
|
||||
Write-Output ("Starting uninstaller: " + $parts.FilePath + $(if ([string]::IsNullOrWhiteSpace($parts.Arguments)) { '' } else { ' ' + $parts.Arguments }))
|
||||
if ([string]::IsNullOrWhiteSpace($parts.Arguments)) {
|
||||
$process = Start-Process -FilePath $parts.FilePath -PassThru
|
||||
} else {
|
||||
$process = Start-Process -FilePath $parts.FilePath -ArgumentList $parts.Arguments -PassThru
|
||||
}
|
||||
Write-Output ("Uninstaller PID: " + $process.Id)
|
||||
try {
|
||||
Wait-Process -Id $process.Id -Timeout $timeoutSeconds -ErrorAction Stop
|
||||
} catch {
|
||||
Write-Error ("Uninstaller timed out after " + $timeoutSeconds + " seconds.")
|
||||
exit 1460
|
||||
}
|
||||
$process.Refresh()
|
||||
$exitCode = [int]$process.ExitCode
|
||||
Write-Output ("Uninstaller exit code: " + $exitCode)
|
||||
if (@(0, 1641, 3010) -notcontains $exitCode) { exit $exitCode }
|
||||
if (@(1641, 3010) -contains $exitCode) { exit 3010 }
|
||||
exit 0
|
||||
"""
|
||||
return base
|
||||
|
||||
return "Write-Error 'No automatic uninstall rule is available for this package.'\nexit 2\n"
|
||||
|
||||
|
||||
def refresh_generated_package_runtime_scripts(
|
||||
package_id: int,
|
||||
manifest: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
manifest = manifest or load_package_manifest(package_id)
|
||||
if not isinstance(manifest.get("analysis"), dict):
|
||||
return
|
||||
root = package_directory(package_id)
|
||||
install = manifest.get("install") or {}
|
||||
detection = manifest.get("detection") or {}
|
||||
uninstall = manifest.get("uninstall") or {}
|
||||
install_name = _safe_member_name(install.get("script", "install.ps1"))
|
||||
detect_name = _safe_member_name(detection.get("script", "detect.ps1"))
|
||||
uninstall_name = _safe_member_name(uninstall.get("script", "uninstall.ps1"))
|
||||
generated = {
|
||||
root / install_name: build_generated_install_script(manifest),
|
||||
root / detect_name: build_generated_detection_script(manifest),
|
||||
root / uninstall_name: build_generated_uninstall_script(manifest),
|
||||
}
|
||||
for path, content in generated.items():
|
||||
if not path.parent.is_dir():
|
||||
continue
|
||||
current = path.read_text(encoding="utf-8") if path.is_file() else ""
|
||||
if current != content:
|
||||
path.write_text(content, encoding="utf-8", newline="\n")
|
||||
|
||||
|
||||
def update_package_process_control(
|
||||
package_id: int,
|
||||
process_names: Any,
|
||||
grace_seconds: int = 5,
|
||||
force_close: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
manifest = load_package_manifest(package_id)
|
||||
manifest["process_control"] = {
|
||||
"process_names": normalize_process_names(process_names),
|
||||
"grace_seconds": max(0, min(int(grace_seconds), 30)),
|
||||
"force_close": bool(force_close),
|
||||
}
|
||||
manifest, _notes = normalize_package_manifest(manifest)
|
||||
manifest_path = package_directory(package_id) / "package.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(manifest, ensure_ascii=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
refresh_generated_package_runtime_scripts(package_id, manifest)
|
||||
return manifest
|
||||
|
||||
|
||||
def build_deployment_user_script(manifest: dict[str, Any], action: str) -> str:
|
||||
action = str(action or "").strip().lower()
|
||||
if action not in {"install", "uninstall", "reinstall"}:
|
||||
raise ValueError("unsupported deployment action")
|
||||
|
||||
package_name = str(manifest.get("name") or "Software package")
|
||||
install_script = _safe_member_name((manifest.get("install") or {}).get("script", "install.ps1"))
|
||||
uninstall_script = _safe_member_name((manifest.get("uninstall") or {}).get("script", "uninstall.ps1"))
|
||||
detect_script = _safe_member_name((manifest.get("detection") or {}).get("script", "detect.ps1"))
|
||||
detection_method = str((manifest.get("detection") or {}).get("method") or "manual")
|
||||
|
||||
def ps(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
lines = [
|
||||
f"$deploymentAction = {ps(action)}",
|
||||
f"$deploymentPackage = {ps(package_name)}",
|
||||
f"$detectionMethod = {ps(detection_method)}",
|
||||
"$deploymentExitCode = 0",
|
||||
"$rebootRequired = $false",
|
||||
"",
|
||||
"function Invoke-PackagePowerShell([string]$ScriptName) {",
|
||||
" $scriptPath = Join-Path $PSScriptRoot $ScriptName",
|
||||
" if (-not (Test-Path -LiteralPath $scriptPath)) { throw \"Package script not found: $scriptPath\" }",
|
||||
" Write-JobLog (\"Running package script: $ScriptName\") | Out-Null",
|
||||
" $packageOutput = @(& powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $scriptPath 2>&1)",
|
||||
" $code = [int]$LASTEXITCODE",
|
||||
" foreach ($outputLine in $packageOutput) {",
|
||||
" $outputText = [string]$outputLine",
|
||||
" if (-not [string]::IsNullOrWhiteSpace($outputText)) { Write-JobLog (\"Package output: $outputText\") | Out-Null }",
|
||||
" }",
|
||||
" Write-JobLog (\"Package script exit code: $code\") | Out-Null",
|
||||
" return [int]$code",
|
||||
"}",
|
||||
"",
|
||||
"function Assert-PackageResult([int]$Code, [string]$Step) {",
|
||||
" if ($Code -eq 3010) { $script:rebootRequired = $true; return }",
|
||||
" if ($Code -ne 0) { throw \"$Step failed with exit code $Code\" }",
|
||||
"}",
|
||||
"",
|
||||
"function Test-PackageDetected() {",
|
||||
" if ($detectionMethod -eq 'manual') { return $null }",
|
||||
f" $detectScript = {ps(detect_script)}",
|
||||
" $scriptPath = Join-Path $PSScriptRoot $detectScript",
|
||||
" if (-not (Test-Path -LiteralPath $scriptPath)) { return $null }",
|
||||
" $detectOutput = @(& powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $scriptPath 2>&1)",
|
||||
" $detectCode = [int]$LASTEXITCODE",
|
||||
" foreach ($outputLine in $detectOutput) {",
|
||||
" $outputText = [string]$outputLine",
|
||||
" if (-not [string]::IsNullOrWhiteSpace($outputText)) { Write-JobLog (\"Detection output: $outputText\") | Out-Null }",
|
||||
" }",
|
||||
" Write-JobLog (\"Detection script exit code: $detectCode\") | Out-Null",
|
||||
" return [bool]($detectCode -eq 0)",
|
||||
"}",
|
||||
"",
|
||||
"function Wait-PackageDetection([bool]$Expected, [int]$TimeoutSeconds = 30) {",
|
||||
" if ($detectionMethod -eq 'manual') { return $null }",
|
||||
" $deadline = (Get-Date).AddSeconds($TimeoutSeconds)",
|
||||
" $last = $null",
|
||||
" do {",
|
||||
" $last = Test-PackageDetected",
|
||||
" if ($null -eq $last) { return $null }",
|
||||
" if ([bool]$last -eq $Expected) { return [bool]$last }",
|
||||
" Start-Sleep -Seconds 2",
|
||||
" } while ((Get-Date) -lt $deadline)",
|
||||
" return [bool]$last",
|
||||
"}",
|
||||
"",
|
||||
"Write-JobLog (\"Software deployment started: $deploymentPackage; action=$deploymentAction\")",
|
||||
]
|
||||
|
||||
if action == "install":
|
||||
lines += [
|
||||
f"$deploymentExitCode = [int](Invoke-PackagePowerShell {ps(install_script)})",
|
||||
"Assert-PackageResult $deploymentExitCode 'Installation'",
|
||||
"$detected = Wait-PackageDetection $true 30",
|
||||
"if ($detected -eq $false) { throw 'Installation completed but the detection rule did not find the software.' }",
|
||||
]
|
||||
elif action == "uninstall":
|
||||
lines += [
|
||||
f"$deploymentExitCode = [int](Invoke-PackagePowerShell {ps(uninstall_script)})",
|
||||
"Assert-PackageResult $deploymentExitCode 'Uninstallation'",
|
||||
"$detected = Wait-PackageDetection $false 30",
|
||||
"if ($detected -eq $true) { throw 'Uninstallation completed but the detection rule still finds the software.' }",
|
||||
]
|
||||
else:
|
||||
lines += [
|
||||
f"$uninstallCode = [int](Invoke-PackagePowerShell {ps(uninstall_script)})",
|
||||
"Assert-PackageResult $uninstallCode 'Uninstallation'",
|
||||
"$detectedAfterUninstall = Wait-PackageDetection $false 30",
|
||||
"if ($detectedAfterUninstall -eq $true) { throw 'Uninstallation completed but the detection rule still finds the software.' }",
|
||||
f"$deploymentExitCode = [int](Invoke-PackagePowerShell {ps(install_script)})",
|
||||
"Assert-PackageResult $deploymentExitCode 'Installation'",
|
||||
"$detected = Wait-PackageDetection $true 30",
|
||||
"if ($detected -eq $false) { throw 'Reinstallation completed but the detection rule did not find the software.' }",
|
||||
]
|
||||
|
||||
lines += [
|
||||
"$JobResult['deployment_action'] = $deploymentAction",
|
||||
"$JobResult['deployment_package'] = $deploymentPackage",
|
||||
"$JobResult['process_exit_code'] = $deploymentExitCode",
|
||||
"$JobResult['reboot_required'] = $rebootRequired",
|
||||
"if ($null -ne $detected) { $JobResult['detected_after_action'] = [bool]$detected }",
|
||||
"Write-JobLog (\"Software deployment completed: $deploymentPackage; action=$deploymentAction; reboot=$rebootRequired\")",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
Reference in New Issue
Block a user