mirror of
https://github.com/MAKS-IT-COM/uscheduler.git
synced 2026-09-29 20:48:09 +02:00
(bugfix): copy bundled scripts to C:\MaksIT\Scripts on windows install
This commit is contained in:
parent
5f3f7f3324
commit
e4c2c4678d
@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.1.1] - 2026-09-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- Windows setup exe now ships bundled Scripts next to the worker and runs `--prepare-data` after files are installed, so example scripts are copied to `C:\MaksIT\Scripts` when those folders do not already exist.
|
||||
|
||||
## [1.1.0] - 2026-09-06
|
||||
|
||||
### Added
|
||||
|
||||
@ -57,7 +57,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, commit format, and
|
||||
|
||||
## Scripts Examples
|
||||
|
||||
> **Note:** These examples are **bundled with the release** and copied to `C:\MaksIT\Scripts` on first install **only if that folder or script is not already present**. Existing files and script folders are never overwritten or merged. They are listed in the default configuration but **disabled by default**. To enable an example, set `"Disabled": false` in `%ProgramData%\MaksIT\UScheduler\settings.json` (or use the UI).
|
||||
> **Note:** These examples are **bundled with the release** and copied to `C:\MaksIT\Scripts` by the Windows setup exe (and by `--install` / `--prepare-data`) **only if that folder or script is not already present**. Existing files and script folders are never overwritten or merged. They are listed in the default configuration but **disabled by default**. To enable an example, set `"Disabled": false` in `%ProgramData%\MaksIT\UScheduler\settings.json` (or use the UI).
|
||||
|
||||
- [Hyper-V Backup](./src/Scripts/HyperV-Backup/README.md) - Production-ready Hyper-V VM backup solution with scheduling and retention management
|
||||
- [Native-Sync](./src/Scripts/Native-Sync/README.md) - Production-ready file synchronization solution using pure PowerShell with no external dependencies
|
||||
@ -91,12 +91,12 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, commit format, and
|
||||
| Location | Purpose | Who can write |
|
||||
|----------|---------|----------------|
|
||||
| `C:\Program Files\MaksIT\UScheduler` | Worker (`MaksIT.UScheduler.exe`) and UI (`MaksIT.UScheduler.UI.exe`) | Administrators |
|
||||
| `C:\MaksIT\Scripts` | Scheduled scripts (all users). Install copies bundled examples only into missing folders; existing scripts are never overwritten. | Users (after install) |
|
||||
| `C:\MaksIT\Scripts` | Scheduled scripts (all users). The Windows setup exe (and `--install` / `--prepare-data`) copies bundled examples only into missing folders; existing scripts are never overwritten. | Users (after install) |
|
||||
| `C:\MaksIT\Logs` | Service and script logs | Users (after install) |
|
||||
| `%ProgramData%\MaksIT\UScheduler\settings.json` | Shared schedule configuration | Users (after install) |
|
||||
| `%AppData%\MaksIT\UScheduler\settings.json` | Per-user UI prefs (service bin path override) | Current user |
|
||||
|
||||
Registering the service (or `MaksIT.UScheduler --prepare-data`) creates the `C:\MaksIT` and ProgramData folders and grants the Users group modify rights, so the UI can stay unelevated.
|
||||
The Windows setup exe, registering the service, or `MaksIT.UScheduler --prepare-data` creates the `C:\MaksIT` and ProgramData folders, copies bundled example scripts when missing, and grants the Users group modify rights so the UI can stay unelevated.
|
||||
|
||||
### Using CLI Commands
|
||||
|
||||
|
||||
@ -41,13 +41,23 @@ public static class HostDataDirectories {
|
||||
GrantUnixGroupWrite(settingsDir, messages);
|
||||
}
|
||||
|
||||
var copy = CopySeedScripts(installDir, HostPaths.DefaultScriptsDirectory);
|
||||
if (copy.CopiedFiles > 0)
|
||||
messages.Add($"Copied {copy.CopiedFiles} new seed script file(s) to {HostPaths.DefaultScriptsDirectory}.");
|
||||
if (copy.SkippedItems > 0)
|
||||
messages.Add($"Left {copy.SkippedItems} existing script folder(s)/file(s) unchanged in {HostPaths.DefaultScriptsDirectory}.");
|
||||
if (copy.CopiedFiles == 0 && copy.SkippedItems == 0)
|
||||
var source = HostPaths.FindBundledScriptsDirectory(installDir);
|
||||
if (source is null || !Directory.Exists(source)) {
|
||||
messages.Add(
|
||||
$"Bundled scripts were not found next to '{installDir}'; left {HostPaths.DefaultScriptsDirectory} unchanged.");
|
||||
}
|
||||
else if (PathsEqual(source, HostPaths.DefaultScriptsDirectory)) {
|
||||
messages.Add($"Scripts directory: {HostPaths.DefaultScriptsDirectory}");
|
||||
}
|
||||
else {
|
||||
var copy = CopySeedScriptsIfMissing(source, HostPaths.DefaultScriptsDirectory);
|
||||
if (copy.CopiedFiles > 0)
|
||||
messages.Add($"Copied {copy.CopiedFiles} new seed script file(s) to {HostPaths.DefaultScriptsDirectory}.");
|
||||
if (copy.SkippedItems > 0)
|
||||
messages.Add($"Left {copy.SkippedItems} existing script folder(s)/file(s) unchanged in {HostPaths.DefaultScriptsDirectory}.");
|
||||
if (copy.CopiedFiles == 0 && copy.SkippedItems == 0)
|
||||
messages.Add($"Scripts directory: {HostPaths.DefaultScriptsDirectory}");
|
||||
}
|
||||
|
||||
messages.Add($"Logs directory: {HostPaths.DefaultLogDirectory}");
|
||||
messages.Add($"Shared settings: {HostPaths.SharedSettingsFile}");
|
||||
@ -58,14 +68,6 @@ public static class HostDataDirectories {
|
||||
}
|
||||
}
|
||||
|
||||
private static SeedCopyResult CopySeedScripts(string installDirectory, string destination) {
|
||||
var source = HostPaths.FindBundledScriptsDirectory(installDirectory);
|
||||
if (source is null || !Directory.Exists(source) || PathsEqual(source, destination))
|
||||
return default;
|
||||
|
||||
return CopySeedScriptsIfMissing(source, destination);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies bundled example scripts only when the destination does not already
|
||||
/// have that file or script folder. Existing folders are left as-is (no merge),
|
||||
|
||||
@ -141,6 +141,10 @@ public static class HostPaths {
|
||||
}
|
||||
|
||||
private static bool LooksLikeScriptsRoot(string path) =>
|
||||
Directory.Exists(Path.Combine(path, "File-Sync"))
|
||||
|| File.Exists(Path.Combine(path, "SchedulerTemplate.psm1"));
|
||||
Directory.Exists(path)
|
||||
&& (Directory.Exists(Path.Combine(path, "File-Sync"))
|
||||
|| Directory.Exists(Path.Combine(path, "Native-Sync"))
|
||||
|| Directory.Exists(Path.Combine(path, "HyperV-Backup"))
|
||||
|| Directory.Exists(Path.Combine(path, "Windows-Update"))
|
||||
|| File.Exists(Path.Combine(path, "SchedulerTemplate.psm1")));
|
||||
}
|
||||
|
||||
@ -173,6 +173,40 @@ public class HostPathsTests {
|
||||
path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindBundledScriptsDirectory_finds_scripts_next_to_start() {
|
||||
var root = Path.Combine(Path.GetTempPath(), $"uscheduler-bundled-{Guid.NewGuid():N}");
|
||||
var start = Path.Combine(root, "install");
|
||||
var scripts = Path.Combine(start, "Scripts");
|
||||
Directory.CreateDirectory(Path.Combine(scripts, "Native-Sync"));
|
||||
File.WriteAllText(Path.Combine(scripts, "Native-Sync", "native-sync.ps1"), "seed");
|
||||
|
||||
try {
|
||||
var found = HostPaths.FindBundledScriptsDirectory(start);
|
||||
Assert.Equal(Path.GetFullPath(scripts), found is null ? null : Path.GetFullPath(found));
|
||||
}
|
||||
finally {
|
||||
Directory.Delete(root, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindBundledScriptsDirectory_finds_sibling_scripts_folder() {
|
||||
var root = Path.Combine(Path.GetTempPath(), $"uscheduler-sibling-{Guid.NewGuid():N}");
|
||||
var start = Path.Combine(root, "MaksIT.UScheduler");
|
||||
var scripts = Path.Combine(root, "Scripts");
|
||||
Directory.CreateDirectory(start);
|
||||
Directory.CreateDirectory(Path.Combine(scripts, "File-Sync"));
|
||||
|
||||
try {
|
||||
var found = HostPaths.FindBundledScriptsDirectory(start);
|
||||
Assert.Equal(Path.GetFullPath(scripts), found is null ? null : Path.GetFullPath(found));
|
||||
}
|
||||
finally {
|
||||
Directory.Delete(root, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveScriptPath_uses_scripts_dir() {
|
||||
var dir = Path.Combine(Path.GetTempPath(), $"uscheduler-scripts-{Guid.NewGuid():N}");
|
||||
|
||||
@ -76,4 +76,27 @@ public class HostDataDirectoriesTests {
|
||||
Directory.Delete(root, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CopySeedScriptsIfMissing_copies_into_empty_destination() {
|
||||
var root = Path.Combine(Path.GetTempPath(), $"uscheduler-seed-{Guid.NewGuid():N}");
|
||||
var source = Path.Combine(root, "seed");
|
||||
var dest = Path.Combine(root, "scripts");
|
||||
Directory.CreateDirectory(Path.Combine(source, "File-Sync"));
|
||||
Directory.CreateDirectory(dest);
|
||||
File.WriteAllText(Path.Combine(source, "File-Sync", "file-sync.ps1"), "seed");
|
||||
File.WriteAllText(Path.Combine(source, "SchedulerTemplate.psm1"), "module");
|
||||
|
||||
try {
|
||||
var result = HostDataDirectories.CopySeedScriptsIfMissing(source, dest);
|
||||
|
||||
Assert.Equal(2, result.CopiedFiles);
|
||||
Assert.Equal(0, result.SkippedItems);
|
||||
Assert.Equal("seed", File.ReadAllText(Path.Combine(dest, "File-Sync", "file-sync.ps1")));
|
||||
Assert.Equal("module", File.ReadAllText(Path.Combine(dest, "SchedulerTemplate.psm1")));
|
||||
}
|
||||
finally {
|
||||
Directory.Delete(root, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>MaksIT.UScheduler.UI</RootNamespace>
|
||||
<Version>1.1.0</Version>
|
||||
<Version>1.1.1</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
|
||||
<Version>1.1.0</Version>
|
||||
<Version>1.1.1</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>dotnet-UScheduler-040d8105-9e07-4024-a632-cbe091387b66</UserSecretsId>
|
||||
@ -41,5 +41,9 @@
|
||||
<None Include="..\..\assets\**\*" Link="assets\%(RecursiveDir)%(Filename)%(Extension)">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<Content Include="..\Scripts\**\*" Link="Scripts\%(RecursiveDir)%(Filename)%(Extension)">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
Does not publish. Copies src/Scripts into each MaksIT.UScheduler RID output,
|
||||
rewrites worker seed appsettings for the bundled layout, and
|
||||
writes Windows/Linux launchers. The portable zip is the win-x64 bundle folder
|
||||
(unique names). A flat installer payload (worker + UI) is staged for WiX.
|
||||
A flat installer payload (worker + UI + Scripts) is staged for WiX.
|
||||
#>
|
||||
|
||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||
@ -254,7 +254,7 @@ exec "$DIR/MaksIT.UScheduler.UI/MaksIT.UScheduler.UI" "$@"
|
||||
Join-Path $sharedSettings.artifactsDirectory "installer-payload"
|
||||
}
|
||||
|
||||
Write-Log -Level "STEP" -Message "Preparing per-machine installer payload (worker + UI)..."
|
||||
Write-Log -Level "STEP" -Message "Preparing per-machine installer payload (worker + UI + Scripts)..."
|
||||
if (Test-Path $installerPayload) {
|
||||
Remove-Item -Path $installerPayload -Recurse -Force
|
||||
}
|
||||
@ -262,7 +262,13 @@ exec "$DIR/MaksIT.UScheduler.UI/MaksIT.UScheduler.UI" "$@"
|
||||
New-Item -ItemType Directory -Path $installerPayload | Out-Null
|
||||
Copy-Item -Path (Join-Path ([string]$uiWin.directory) '*') -Destination $installerPayload -Recurse -Force
|
||||
Copy-Item -Path (Join-Path ([string]$workerWin.directory) '*') -Destination $installerPayload -Recurse -Force
|
||||
$payloadScripts = Join-Path $installerPayload "Scripts"
|
||||
if (Test-Path -LiteralPath $payloadScripts) {
|
||||
Remove-Item -LiteralPath $payloadScripts -Recurse -Force
|
||||
}
|
||||
Copy-Item -Path $scriptsSourcePath -Destination $payloadScripts -Recurse
|
||||
Write-Log -Level "OK" -Message " Installer payload: $installerPayload"
|
||||
Write-Log -Level "OK" -Message " Installer Scripts: $payloadScripts"
|
||||
|
||||
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value @($bundleDirectory) -Overwrite Replace -LegacyProperty 'releaseArchiveInputs'
|
||||
Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'publishOutputs' -Value @() -Overwrite Replace
|
||||
|
||||
@ -80,7 +80,9 @@
|
||||
"installScope": "perMachine",
|
||||
"installFolderName": "UScheduler",
|
||||
"iconPath": "..\\..\\..\\src\\MaksIT.UScheduler.UI\\Assets\\icon.ico",
|
||||
"exeNamePattern": "maksit-uscheduler-{version}.exe"
|
||||
"exeNamePattern": "maksit-uscheduler-{version}.exe",
|
||||
"postInstallExecutableName": "MaksIT.UScheduler.exe",
|
||||
"postInstallArguments": "--prepare-data"
|
||||
},
|
||||
{
|
||||
"name": "FlatpakPack",
|
||||
|
||||
@ -283,7 +283,13 @@ function New-WixPackageXml {
|
||||
[string]$Architecture = 'x64',
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$IconPath
|
||||
[string]$IconPath,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$PostInstallFileName,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$PostInstallArguments
|
||||
)
|
||||
|
||||
$ns = 'http://wixtoolset.org/schemas/v4/wxs'
|
||||
@ -371,6 +377,7 @@ function New-WixPackageXml {
|
||||
'' = $installFolder
|
||||
}
|
||||
$componentIds = [System.Collections.Generic.List[string]]::new()
|
||||
$postInstallFileId = $null
|
||||
|
||||
$publishRoot = [System.IO.Path]::GetFullPath($PublishDirectory)
|
||||
$files = @(Get-ChildItem -LiteralPath $publishRoot -Recurse -File)
|
||||
@ -415,6 +422,12 @@ function New-WixPackageXml {
|
||||
$null = $fileNode.SetAttribute('KeyPath', 'yes')
|
||||
$null = $component.AppendChild($fileNode)
|
||||
$null = $parent.AppendChild($component)
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($PostInstallFileName) `
|
||||
-and [string]::IsNullOrWhiteSpace($relativeDir) `
|
||||
-and [string]::Equals($file.Name, $PostInstallFileName, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
$postInstallFileId = $fileNode.GetAttribute('Id')
|
||||
}
|
||||
}
|
||||
|
||||
$exeName = [System.IO.Path]::GetFileName($ExecutablePath)
|
||||
@ -489,6 +502,34 @@ function New-WixPackageXml {
|
||||
|
||||
$null = $package.AppendChild($feature)
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($PostInstallFileName)) {
|
||||
if ([string]::IsNullOrWhiteSpace($postInstallFileId)) {
|
||||
throw "WindowsInstaller postInstallExecutableName '$PostInstallFileName' was not harvested from $publishRoot."
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($PostInstallArguments)) {
|
||||
throw "WindowsInstaller postInstallArguments is required when postInstallExecutableName is set."
|
||||
}
|
||||
|
||||
$ca = $xml.CreateElement('CustomAction', $ns)
|
||||
$null = $ca.SetAttribute('Id', 'PostInstallPrepareData')
|
||||
$null = $ca.SetAttribute('FileRef', $postInstallFileId)
|
||||
$arguments = if ($null -eq $PostInstallArguments) { '' } else { $PostInstallArguments }
|
||||
$null = $ca.SetAttribute('ExeCommand', $arguments)
|
||||
$null = $ca.SetAttribute('Execute', 'deferred')
|
||||
$null = $ca.SetAttribute('Impersonate', 'no')
|
||||
$null = $ca.SetAttribute('Return', 'check')
|
||||
$null = $package.AppendChild($ca)
|
||||
|
||||
$seq = $xml.CreateElement('InstallExecuteSequence', $ns)
|
||||
$custom = $xml.CreateElement('Custom', $ns)
|
||||
$null = $custom.SetAttribute('Action', 'PostInstallPrepareData')
|
||||
$null = $custom.SetAttribute('After', 'InstallFiles')
|
||||
$null = $custom.SetAttribute('Condition', 'NOT REMOVE')
|
||||
$null = $seq.AppendChild($custom)
|
||||
$null = $package.AppendChild($seq)
|
||||
}
|
||||
|
||||
return $xml
|
||||
}
|
||||
|
||||
|
||||
@ -9,6 +9,8 @@
|
||||
Harvests a win-* (or sole) DotNetPublish folder into a WiX MSI, then wraps
|
||||
it in a Burn bootstrapper .exe. The .exe is the GitHub asset; the MSI/WXS
|
||||
stay in a staging folder and are not added to the portable zip.
|
||||
Optional postInstallExecutableName + postInstallArguments run a deferred
|
||||
elevated custom action after InstallFiles (used to seed C:\MaksIT\Scripts).
|
||||
`wix build -arch` follows `runtimeIdentifier` (default win-x64 → x64) so
|
||||
per-machine installs go to `C:\Program Files`, not Program Files (x86).
|
||||
Requires the WiX CLI (`dotnet tool install -g wix`). WiX v7: accept the
|
||||
@ -213,6 +215,9 @@ function Invoke-Plugin {
|
||||
$msiPath = Join-Path $stageDir ($safeName + '-' + $version + '.msi')
|
||||
$bundleWxsPath = Join-Path $stageDir ($safeName + '-' + $version + '-bundle.wxs')
|
||||
|
||||
$postInstallFileName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'postInstallExecutableName' -Default '')
|
||||
$postInstallArguments = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'postInstallArguments' -Default '')
|
||||
|
||||
Write-Log -Level "STEP" -Message "Generating WiX source for '$appName' from $publishDirectory ($wixArch)"
|
||||
$xml = New-WixPackageXml `
|
||||
-AppName $appName `
|
||||
@ -224,7 +229,9 @@ function Invoke-Plugin {
|
||||
-InstallScope $installScope `
|
||||
-InstallFolderName $installFolderName `
|
||||
-Architecture $wixArch `
|
||||
-IconPath $iconPath
|
||||
-IconPath $iconPath `
|
||||
-PostInstallFileName $postInstallFileName `
|
||||
-PostInstallArguments $postInstallArguments
|
||||
|
||||
$xml.Save($wxsPath)
|
||||
Write-Log -Level "OK" -Message " WiX source: $wxsPath"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user