mirror of
https://github.com/MAKS-IT-COM/maksit-lto-backup.git
synced 2026-10-01 09:08:09 +02:00
(feature): crash dialog and flatpak release packaging
This commit is contained in:
parent
b01bf04e7f
commit
ccc0740843
22
CHANGELOG.md
22
CHANGELOG.md
@ -6,6 +6,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.1.0-alpha.3] - 2026-09-30
|
||||
|
||||
Still alpha versus **0.1.0-alpha.2**. Emulator and build validated; physical Windows/Linux tape, changer, and Linux SMB staging are not hardware-proven yet. Not a production-ready line.
|
||||
|
||||
### Added
|
||||
|
||||
- Avalonia UI crash dialog for unhandled dispatcher, AppDomain, and task exceptions. The report is copyable and written as `crash-*.txt` under the product logs folder (`%AppData%/MaksIT/LTO Backup/logs`, and the Linux application-data equivalent).
|
||||
- Flatpak desktop file and AppStream metainfo in `data/` (`com.maks_it.ltobackup`). README documents user and system `flatpak install`.
|
||||
- Release publish is self-contained for `win-x64` and `linux-x64`. The portable zip stays win-x64. `WindowsInstaller` and `FlatpakPack` run in the release engine. GitHub release requires the zip, setup exe, and Flatpak; cleanup keeps those three.
|
||||
|
||||
### Changed
|
||||
|
||||
- Avalonia **12.1.2** (was 12.1.0).
|
||||
- Linux UI stays on X11/XWayland (`UsePlatformDetect` only). Avalonia 12.1.2 native Wayland still hangs on GNOME `xdg_toplevel.configure(0, 0)` when launched from the GNOME app icon.
|
||||
- Flatpak app id is lowercase `com.maks_it.ltobackup` (replaces `com.maks_it.LtoBackup`). Uninstall the old id before installing a new bundle. Finish args include `--device=all` (tape) and `--filesystem=home`. AppStream `<release version>` is stamped from the same `Directory.Build.props` version as the zip and setup exe, so `flatpak info` matches the bundle file name.
|
||||
- Windows installer build also links `WixToolset.Util.wixext`.
|
||||
- Vendored RepoUtils platform plugins: `CollectCoverage`, `ContainerEngineProbe`, `DiscoverPackageArtifacts`.
|
||||
|
||||
### Removed
|
||||
|
||||
- Unused npm pack plugins (`NpmBuild`, `NpmJestTest`, `NpmPack`, `NpmPackageSupport`, `NpmPublish`, `NpmReleaseVersion`). `NpmPublish` stays disabled in release settings.
|
||||
|
||||
## [0.1.0-alpha.2] - 2026-09-06
|
||||
|
||||
Still alpha versus **0.1.0-alpha.1**. Emulator and build validated; physical Windows/Linux tape, changer, and Linux SMB staging are not hardware-proven yet. Not a production-ready line.
|
||||
|
||||
@ -3,6 +3,6 @@
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>0.1.0-alpha.2</Version>
|
||||
<Version>0.1.0-alpha.3</Version>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
34
README.md
34
README.md
@ -1,8 +1,8 @@
|
||||
# MaksIT.LTO.Backup
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@ -22,7 +22,7 @@ Device access modes:
|
||||
- **Physical** — real drive/library on **Windows** (`\\.\Tape0`, `\\.\Changer0`; admin) or **Linux** (`/dev/nst0`, `/dev/sg*`; `tape`/`disk` group)
|
||||
- **Emulated** — file-backed drive + library (no hardware; primary CI / no-hardware path)
|
||||
|
||||
> Current line is **`0.1.0-alpha.2`** (prerelease, not production-ready). Emulator coverage is the primary validation path; physical tape/changer and Linux SMB are not hardware-proven yet. Use at your own risk.
|
||||
> Current line is **`0.1.0-alpha.3`** (prerelease, not production-ready). Emulator coverage is the primary validation path; physical tape/changer and Linux SMB are not hardware-proven yet. Use at your own risk.
|
||||
|
||||
---
|
||||
|
||||
@ -237,6 +237,32 @@ Vendored under `utils/` (from MaksIT RepoUtils community):
|
||||
|
||||
GitHub assets are siblings: portable `maksit-lto-backup-{version}.zip` (win-x64), Windows setup exe (Avalonia UI), and Flatpak (Avalonia UI). The installer and Flatpak are not inside the zip. On Windows the Flatpak bundle is built via WSL Debian.
|
||||
|
||||
### Linux (Flatpak)
|
||||
|
||||
GitHub releases include `maksit-lto-backup-{version}.flatpak` (Avalonia UI).
|
||||
|
||||
**User** (this account only):
|
||||
|
||||
```bash
|
||||
flatpak install --user ./maksit-lto-backup-{version}.flatpak
|
||||
flatpak run com.maks_it.ltobackup
|
||||
```
|
||||
|
||||
**System** (all users):
|
||||
|
||||
```bash
|
||||
sudo flatpak install --system ./maksit-lto-backup-{version}.flatpak
|
||||
flatpak run com.maks_it.ltobackup
|
||||
```
|
||||
|
||||
Uninstall: `flatpak uninstall --user com.maks_it.ltobackup` or `sudo flatpak uninstall --system com.maks_it.ltobackup`.
|
||||
|
||||
The previous id `com.maks_it.LtoBackup` is replaced by this lowercase id. Uninstall the old app before installing the new bundle if it was installed.
|
||||
|
||||
If GNOME or KDE does not show a launcher icon, `flatpak run` may warn that `/var/lib/flatpak/exports/share` and `~/.local/share/flatpak/exports/share` are not on `XDG_DATA_DIRS`. Log out and back in once so the session picks up those paths.
|
||||
|
||||
Linux uses X11/XWayland (Avalonia native Wayland still hangs on GNOME). The sandbox grants `--device=all` for tape and `--filesystem=home` for backup paths. AppStream and the desktop file live in [`data/`](data/).
|
||||
|
||||
Settings:
|
||||
|
||||
- `utils/engines/test/scriptSettings.json`
|
||||
|
||||
12
data/com.maks_it.ltobackup.desktop
Normal file
12
data/com.maks_it.ltobackup.desktop
Normal file
@ -0,0 +1,12 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=LTO Backup
|
||||
GenericName=Tape backup
|
||||
Comment=LTO tape backup and restore
|
||||
Exec=maksit-lto-backup
|
||||
Icon=com.maks_it.ltobackup
|
||||
StartupWMClass=com.maks_it.ltobackup
|
||||
StartupNotify=true
|
||||
Terminal=false
|
||||
Categories=Utility;Archiving;
|
||||
Keywords=lto;tape;backup;restore;
|
||||
55
data/com.maks_it.ltobackup.metainfo.xml
Normal file
55
data/com.maks_it.ltobackup.metainfo.xml
Normal file
@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>com.maks_it.ltobackup</id>
|
||||
<name>LTO Backup</name>
|
||||
<summary>LTO tape backup and restore</summary>
|
||||
<developer id="com.maks_it">
|
||||
<name>MaksIT</name>
|
||||
</developer>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>GPL-2.0</project_license>
|
||||
<description>
|
||||
<p>Run LTO tape backup and restore from the desktop. Inventory a drive or library, start jobs, and watch progress without a vendor GUI.</p>
|
||||
<p>The Flatpak is the Avalonia UI. Tape devices need host SCSI/sg access; backup destinations use the home folder.</p>
|
||||
<ul>
|
||||
<li>Drive and library inventory</li>
|
||||
<li>Backup and restore jobs</li>
|
||||
<li>Settings and a worker/service monitor</li>
|
||||
</ul>
|
||||
</description>
|
||||
<launchable type="desktop-id">com.maks_it.ltobackup.desktop</launchable>
|
||||
<provides>
|
||||
<id>com.maks_it.LtoBackup</id>
|
||||
</provides>
|
||||
<replaces>
|
||||
<id>com.maks_it.LtoBackup</id>
|
||||
</replaces>
|
||||
<url type="homepage">https://maks-it.com/</url>
|
||||
<url type="bugtracker">https://github.com/MAKS-IT-COM/maksit-lto-backup/issues</url>
|
||||
<url type="vcs-browser">https://github.com/MAKS-IT-COM/maksit-lto-backup</url>
|
||||
<url type="donation">https://www.buymeacoffee.com/maksitcom</url>
|
||||
<branding>
|
||||
<color type="primary" scheme_preference="light">#33A5CF</color>
|
||||
<color type="primary" scheme_preference="dark">#003D7A</color>
|
||||
</branding>
|
||||
<content_rating type="oars-1.1"/>
|
||||
<requires>
|
||||
<control>keyboard</control>
|
||||
<control>pointing</control>
|
||||
<display_length compare="ge">768</display_length>
|
||||
</requires>
|
||||
<releases>
|
||||
<release version="0.1.0-alpha.3" date="2026-09-30">
|
||||
<url type="details">https://github.com/MAKS-IT-COM/maksit-lto-backup/blob/v0.1.0-alpha.3/CHANGELOG.md</url>
|
||||
<description>
|
||||
<p>Crash dialog with a copyable report and crash log. Linux Flatpak uses X11/XWayland, lowercase app id com.maks_it.ltobackup, and the same release version as the zip and setup exe.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.1.0-alpha.2" date="2026-09-06">
|
||||
<url type="details">https://github.com/MAKS-IT-COM/maksit-lto-backup/blob/v0.1.0-alpha.2/CHANGELOG.md</url>
|
||||
<description>
|
||||
<p>Linux Flatpak uses X11/XWayland from the GNOME app icon and a lowercase app id. GitHub ships a sibling .flatpak of the Avalonia UI.</p>
|
||||
</description>
|
||||
</release>
|
||||
</releases>
|
||||
</component>
|
||||
77
src/MaksIT.LTO.Backup.Shared/ErrorReport.cs
Normal file
77
src/MaksIT.LTO.Backup.Shared/ErrorReport.cs
Normal file
@ -0,0 +1,77 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
|
||||
namespace MaksIT.LTO.Backup.Shared;
|
||||
|
||||
|
||||
/// <summary>Formats unhandled exceptions for a copyable dialog and a crash log file.</summary>
|
||||
public static class ErrorReport {
|
||||
public static string Capture(Exception exception) {
|
||||
ArgumentNullException.ThrowIfNull(exception);
|
||||
var body = Format(exception);
|
||||
var path = TryWrite(body);
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
return body;
|
||||
return body + Environment.NewLine + Environment.NewLine + "Log: " + path;
|
||||
}
|
||||
|
||||
public static string Format(Exception exception) {
|
||||
ArgumentNullException.ThrowIfNull(exception);
|
||||
var text = new StringBuilder();
|
||||
text.AppendLine("LTO Backup " + Version());
|
||||
text.AppendLine("MaksIT");
|
||||
text.AppendLine(DateTimeOffset.UtcNow.ToString("u"));
|
||||
text.AppendLine(RuntimeInformation.OSDescription);
|
||||
text.AppendLine(RuntimeInformation.FrameworkDescription);
|
||||
text.AppendLine((Environment.Is64BitProcess ? "64-bit" : "32-bit") + " process");
|
||||
text.AppendLine();
|
||||
AppendException(text, exception);
|
||||
return text.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
public static string? TryWrite(string report) {
|
||||
try {
|
||||
var dir = UserSettingsPath.LogsDirectory(ConfigurationFileService.ProductFolder);
|
||||
Directory.CreateDirectory(dir);
|
||||
var name = "crash-" + DateTime.UtcNow.ToString("yyyyMMdd-HHmmss") + "-" + Environment.ProcessId + ".txt";
|
||||
var path = Path.Combine(dir, name);
|
||||
File.WriteAllText(path, report ?? "");
|
||||
return path;
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Version() {
|
||||
var version = Assembly.GetEntryAssembly()?.GetName().Version;
|
||||
return version is null ? "" : version.ToString();
|
||||
}
|
||||
|
||||
private static void AppendException(StringBuilder text, Exception exception) {
|
||||
var seen = new HashSet<Exception>();
|
||||
var current = exception;
|
||||
var depth = 0;
|
||||
while (current is not null && seen.Add(current)) {
|
||||
if (depth > 0)
|
||||
text.AppendLine().AppendLine("--- inner ---");
|
||||
text.AppendLine(current.GetType().FullName);
|
||||
text.AppendLine(current.Message);
|
||||
if (!string.IsNullOrWhiteSpace(current.StackTrace))
|
||||
text.AppendLine(current.StackTrace);
|
||||
if (current is AggregateException aggregate) {
|
||||
foreach (var inner in aggregate.InnerExceptions) {
|
||||
if (inner is null || ReferenceEquals(inner, current.InnerException) || !seen.Add(inner))
|
||||
continue;
|
||||
text.AppendLine().AppendLine("--- aggregate ---");
|
||||
text.AppendLine(inner.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
current = current.InnerException;
|
||||
depth++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9,9 +9,14 @@ namespace MaksIT.LTO.Backup.Shared;
|
||||
/// </summary>
|
||||
public static class UserSettingsPath {
|
||||
public static string Get(string product, string fileName = "settings.json") =>
|
||||
Path.Combine(ProductDirectory(product), fileName);
|
||||
|
||||
public static string LogsDirectory(string product) =>
|
||||
Path.Combine(ProductDirectory(product), "logs");
|
||||
|
||||
public static string ProductDirectory(string product) =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"MaksIT",
|
||||
product,
|
||||
fileName);
|
||||
product);
|
||||
}
|
||||
|
||||
146
src/MaksIT.LTO.Backup.UI/Dialogs/ErrorDialog.cs
Normal file
146
src/MaksIT.LTO.Backup.UI/Dialogs/ErrorDialog.cs
Normal file
@ -0,0 +1,146 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Threading;
|
||||
using MaksIT.LTO.Backup.Shared;
|
||||
using MaksIT.LTO.Backup.UI.ViewModels;
|
||||
using MaksIT.LTO.Backup.UI.Windows;
|
||||
|
||||
|
||||
namespace MaksIT.LTO.Backup.UI.Dialogs;
|
||||
|
||||
|
||||
internal static class ErrorDialog {
|
||||
private static int _open;
|
||||
|
||||
public static void Report(Exception? exception) =>
|
||||
Present(exception, wait: false);
|
||||
|
||||
public static void ReportBlocking(Exception? exception) =>
|
||||
Present(exception, wait: true);
|
||||
|
||||
private static void Present(Exception? exception, bool wait) {
|
||||
if (exception is null)
|
||||
return;
|
||||
var report = ErrorReport.Capture(exception);
|
||||
var dispatcher = TryDispatcher();
|
||||
if (dispatcher is null) {
|
||||
if (wait)
|
||||
ShowStandalone(report);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dispatcher.CheckAccess()) {
|
||||
if (wait)
|
||||
ShowUntilClosed(report);
|
||||
else
|
||||
_ = ShowAsync(report);
|
||||
return;
|
||||
}
|
||||
|
||||
if (wait)
|
||||
dispatcher.Invoke(() => ShowUntilClosed(report));
|
||||
else
|
||||
dispatcher.Post(() => _ = ShowAsync(report));
|
||||
}
|
||||
|
||||
private static Dispatcher? TryDispatcher() {
|
||||
try {
|
||||
return Dispatcher.UIThread;
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ShowAsync(string report) {
|
||||
if (Interlocked.Exchange(ref _open, 1) != 0)
|
||||
return;
|
||||
try {
|
||||
var window = Create(report);
|
||||
var owner = ActiveWindow();
|
||||
if (owner is { IsVisible: true }) {
|
||||
window.WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||
await window.ShowDialog(owner);
|
||||
return;
|
||||
}
|
||||
|
||||
window.WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
||||
var closed = new TaskCompletionSource();
|
||||
window.Closed += (_, _) => closed.TrySetResult();
|
||||
window.Show();
|
||||
await closed.Task;
|
||||
}
|
||||
catch {
|
||||
}
|
||||
finally {
|
||||
Interlocked.Exchange(ref _open, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShowUntilClosed(string report) {
|
||||
if (Interlocked.Exchange(ref _open, 1) != 0)
|
||||
return;
|
||||
try {
|
||||
var window = Create(report);
|
||||
var owner = ActiveWindow();
|
||||
var closed = false;
|
||||
window.Closed += (_, _) => closed = true;
|
||||
if (owner is { IsVisible: true }) {
|
||||
window.WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||
window.Show(owner);
|
||||
}
|
||||
else {
|
||||
window.WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
||||
window.Show();
|
||||
}
|
||||
|
||||
var dispatcher = Dispatcher.UIThread;
|
||||
while (!closed)
|
||||
dispatcher.RunJobs();
|
||||
}
|
||||
catch {
|
||||
}
|
||||
finally {
|
||||
Interlocked.Exchange(ref _open, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShowStandalone(string report) {
|
||||
if (Interlocked.Exchange(ref _open, 1) != 0)
|
||||
return;
|
||||
try {
|
||||
AppBuilder.Configure<Application>()
|
||||
.UsePlatformDetect()
|
||||
.AfterSetup(builder => {
|
||||
if (builder.Instance?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime life)
|
||||
return;
|
||||
life.ShutdownMode = ShutdownMode.OnMainWindowClose;
|
||||
life.MainWindow = Create(report);
|
||||
})
|
||||
.StartWithClassicDesktopLifetime([]);
|
||||
}
|
||||
catch {
|
||||
}
|
||||
finally {
|
||||
Interlocked.Exchange(ref _open, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private static ErrorWindow Create(string report) =>
|
||||
new() {
|
||||
DataContext = new ErrorReportViewModel(report),
|
||||
WindowStartupLocation = WindowStartupLocation.CenterScreen
|
||||
};
|
||||
|
||||
private static Window? ActiveWindow() {
|
||||
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime life)
|
||||
return null;
|
||||
foreach (var window in life.Windows) {
|
||||
if (window.IsActive && window is not ErrorWindow)
|
||||
return window;
|
||||
}
|
||||
|
||||
return life.MainWindow is ErrorWindow ? null : life.MainWindow;
|
||||
}
|
||||
}
|
||||
@ -12,10 +12,10 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.1.0" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.0" />
|
||||
<PackageReference Include="Avalonia.Themes.Simple" Version="12.1.0" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.0" />
|
||||
<PackageReference Include="Avalonia" Version="12.1.2" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.2" />
|
||||
<PackageReference Include="Avalonia.Themes.Simple" Version="12.1.2" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.2" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
<PackageReference Include="MaksIT.Core" Version="1.6.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.9" />
|
||||
|
||||
@ -1,16 +1,48 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Logging;
|
||||
using Avalonia.Threading;
|
||||
using MaksIT.LTO.Backup.UI.Dialogs;
|
||||
|
||||
|
||||
namespace MaksIT.LTO.Backup.UI;
|
||||
|
||||
internal static class Program {
|
||||
[STAThread]
|
||||
public static void Main(string[] args) =>
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
public static void Main(string[] args) {
|
||||
AppDomain.CurrentDomain.UnhandledException += OnDomainUnhandled;
|
||||
TaskScheduler.UnobservedTaskException += OnUnobservedTask;
|
||||
try {
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
ErrorDialog.ReportBlocking(ex);
|
||||
}
|
||||
}
|
||||
|
||||
// Linux: X11/XWayland. Avalonia 12.1.2 native Wayland still hangs on GNOME's
|
||||
// xdg_toplevel.configure(0, 0) and never maps a window (GNOME app icon).
|
||||
public static AppBuilder BuildAvaloniaApp() =>
|
||||
AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.LogToTrace(LogEventLevel.Warning);
|
||||
.LogToTrace(LogEventLevel.Warning)
|
||||
.AfterSetup(_ => Dispatcher.UIThread.UnhandledException += OnDispatcherUnhandled);
|
||||
|
||||
private static void OnDispatcherUnhandled(object? sender, DispatcherUnhandledExceptionEventArgs e) {
|
||||
e.Handled = true;
|
||||
ErrorDialog.Report(e.Exception);
|
||||
}
|
||||
|
||||
private static void OnDomainUnhandled(object? sender, UnhandledExceptionEventArgs e) {
|
||||
if (e.ExceptionObject is not Exception ex)
|
||||
return;
|
||||
if (e.IsTerminating)
|
||||
ErrorDialog.ReportBlocking(ex);
|
||||
else
|
||||
ErrorDialog.Report(ex);
|
||||
}
|
||||
|
||||
private static void OnUnobservedTask(object? sender, UnobservedTaskExceptionEventArgs e) {
|
||||
e.SetObserved();
|
||||
ErrorDialog.Report(e.Exception);
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,12 @@
|
||||
<Setter Property="Foreground" Value="#9aa0a6" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
<Style Selector="TextBox.error-dump">
|
||||
<Setter Property="FontFamily" Value="Cascadia Mono, Consolas, Ubuntu Mono, monospace" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Background" Value="#0E1620" />
|
||||
<Setter Property="Foreground" Value="#E8F4FC" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.warning">
|
||||
<Setter Property="Foreground" Value="#ff4d6d" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
|
||||
19
src/MaksIT.LTO.Backup.UI/ViewModels/ErrorReportViewModel.cs
Normal file
19
src/MaksIT.LTO.Backup.UI/ViewModels/ErrorReportViewModel.cs
Normal file
@ -0,0 +1,19 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
|
||||
namespace MaksIT.LTO.Backup.UI.ViewModels;
|
||||
|
||||
|
||||
public sealed partial class ErrorReportViewModel : ObservableObject {
|
||||
public ErrorReportViewModel(string report) {
|
||||
Report = report ?? "";
|
||||
}
|
||||
|
||||
public string Report { get; }
|
||||
|
||||
[ObservableProperty]
|
||||
private string copyStatus = "";
|
||||
|
||||
public void MarkCopied() =>
|
||||
CopyStatus = "Copied";
|
||||
}
|
||||
47
src/MaksIT.LTO.Backup.UI/Windows/ErrorWindow.axaml
Normal file
47
src/MaksIT.LTO.Backup.UI/Windows/ErrorWindow.axaml
Normal file
@ -0,0 +1,47 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:MaksIT.LTO.Backup.UI.ViewModels"
|
||||
x:Class="MaksIT.LTO.Backup.UI.Windows.ErrorWindow"
|
||||
x:DataType="vm:ErrorReportViewModel"
|
||||
Width="720"
|
||||
Height="520"
|
||||
MinWidth="480"
|
||||
MinHeight="360"
|
||||
CanResize="True"
|
||||
ShowInTaskbar="True"
|
||||
Topmost="True"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Icon="/Assets/icon.ico"
|
||||
Title="LTO Backup">
|
||||
<DockPanel Margin="16">
|
||||
<StackPanel DockPanel.Dock="Bottom"
|
||||
Orientation="Horizontal"
|
||||
HorizontalAlignment="Right"
|
||||
Spacing="8"
|
||||
Margin="0,12,0,0">
|
||||
<TextBlock Text="{Binding CopyStatus}"
|
||||
Classes="hint"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,8,0" />
|
||||
<Button Content="Copy"
|
||||
Click="OnCopyClick" />
|
||||
<Button Content="Close"
|
||||
Padding="16,8"
|
||||
Click="OnCloseClick"
|
||||
IsDefault="True"
|
||||
IsCancel="True" />
|
||||
</StackPanel>
|
||||
<TextBlock DockPanel.Dock="Top"
|
||||
Text="Something went wrong. The details below are also saved to a crash log."
|
||||
Classes="hint"
|
||||
TextWrapping="Wrap"
|
||||
Margin="0,0,0,12" />
|
||||
<TextBox Text="{Binding Report}"
|
||||
Classes="error-dump"
|
||||
IsReadOnly="True"
|
||||
AcceptsReturn="True"
|
||||
TextWrapping="NoWrap"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
||||
ScrollViewer.VerticalScrollBarVisibility="Auto" />
|
||||
</DockPanel>
|
||||
</Window>
|
||||
31
src/MaksIT.LTO.Backup.UI/Windows/ErrorWindow.axaml.cs
Normal file
31
src/MaksIT.LTO.Backup.UI/Windows/ErrorWindow.axaml.cs
Normal file
@ -0,0 +1,31 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Interactivity;
|
||||
using MaksIT.LTO.Backup.UI.ViewModels;
|
||||
|
||||
|
||||
namespace MaksIT.LTO.Backup.UI.Windows;
|
||||
|
||||
|
||||
public partial class ErrorWindow : Window {
|
||||
public ErrorWindow() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private async void OnCopyClick(object? sender, RoutedEventArgs e) {
|
||||
if (DataContext is not ErrorReportViewModel vm)
|
||||
return;
|
||||
var clipboard = Clipboard;
|
||||
if (clipboard is null)
|
||||
return;
|
||||
try {
|
||||
await clipboard.SetTextAsync(vm.Report);
|
||||
vm.MarkCopied();
|
||||
}
|
||||
catch {
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCloseClick(object? sender, RoutedEventArgs e) =>
|
||||
Close();
|
||||
}
|
||||
40
src/MaksIT.LTO.Tests/ErrorReportTests.cs
Normal file
40
src/MaksIT.LTO.Tests/ErrorReportTests.cs
Normal file
@ -0,0 +1,40 @@
|
||||
using MaksIT.LTO.Backup.Shared;
|
||||
|
||||
|
||||
namespace MaksIT.LTO.Tests;
|
||||
|
||||
public class ErrorReportTests {
|
||||
[Fact]
|
||||
public void Format_IncludesTypeMessageInnerAndStack() {
|
||||
Exception thrown;
|
||||
try {
|
||||
throw new InvalidOperationException("outer", new ArgumentException("inner"));
|
||||
}
|
||||
catch (Exception ex) {
|
||||
thrown = ex;
|
||||
}
|
||||
|
||||
var text = ErrorReport.Format(thrown);
|
||||
Assert.Contains("LTO Backup", text, StringComparison.Ordinal);
|
||||
Assert.Contains("MaksIT", text, StringComparison.Ordinal);
|
||||
Assert.Contains("InvalidOperationException", text, StringComparison.Ordinal);
|
||||
Assert.Contains("outer", text, StringComparison.Ordinal);
|
||||
Assert.Contains("--- inner ---", text, StringComparison.Ordinal);
|
||||
Assert.Contains("ArgumentException", text, StringComparison.Ordinal);
|
||||
Assert.Contains("inner", text, StringComparison.Ordinal);
|
||||
Assert.Contains(nameof(Format_IncludesTypeMessageInnerAndStack), text, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Format_IncludesAggregateInners() {
|
||||
var error = new AggregateException(
|
||||
"batch",
|
||||
new InvalidOperationException("one"),
|
||||
new ArgumentException("two"));
|
||||
var text = ErrorReport.Format(error);
|
||||
Assert.Contains("AggregateException", text, StringComparison.Ordinal);
|
||||
Assert.Contains("one", text, StringComparison.Ordinal);
|
||||
Assert.Contains("two", text, StringComparison.Ordinal);
|
||||
Assert.Contains("--- aggregate ---", text, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@ -43,13 +43,66 @@
|
||||
"..\\..\\..\\src\\MaksIT.LTO.Backup.UI\\MaksIT.LTO.Backup.UI.csproj",
|
||||
"..\\..\\..\\src\\MaksIT.LTO.Backup.Service\\MaksIT.LTO.Backup.Service.csproj"
|
||||
],
|
||||
"artifactsDir": "..\\..\\..\\releases"
|
||||
"artifactsDir": "..\\..\\..\\releases",
|
||||
"runtimeIdentifiers": [
|
||||
"win-x64",
|
||||
"linux-x64"
|
||||
],
|
||||
"selfContained": true,
|
||||
"publishSingleFile": false
|
||||
},
|
||||
{
|
||||
"name": "DotNetCreateArchive",
|
||||
"stageLabel": "build",
|
||||
"enabled": true,
|
||||
"zipNamePattern": "maksit-lto-backup-{version}.zip"
|
||||
"zipNamePattern": "maksit-lto-backup-{version}.zip",
|
||||
"portableRuntimeIdentifier": "win-x64"
|
||||
},
|
||||
{
|
||||
"name": "WindowsInstaller",
|
||||
"stageLabel": "build",
|
||||
"enabled": true,
|
||||
"appName": "MaksIT LTO Backup",
|
||||
"manufacturer": "MaksIT",
|
||||
"installFolderName": "LTO Backup",
|
||||
"upgradeCode": "b3f7c2a1-8e4d-4f90-9c1b-6a2e5d8f3b10",
|
||||
"executableName": "MaksIT.LTO.Backup.UI.exe",
|
||||
"runtimeIdentifier": "win-x64",
|
||||
"publishDir": "..\\..\\..\\releases\\MaksIT.LTO.Backup.UI\\win-x64",
|
||||
"installScope": "perMachine",
|
||||
"iconPath": "..\\..\\..\\src\\MaksIT.LTO.Backup.UI\\Assets\\icon.ico",
|
||||
"exeNamePattern": "maksit-lto-backup-{version}.exe"
|
||||
},
|
||||
{
|
||||
"name": "FlatpakPack",
|
||||
"stageLabel": "build",
|
||||
"enabled": true,
|
||||
"appId": "com.maks_it.ltobackup",
|
||||
"appName": "MaksIT LTO Backup",
|
||||
"command": "maksit-lto-backup",
|
||||
"executableName": "MaksIT.LTO.Backup.UI",
|
||||
"runtimeIdentifier": "linux-x64",
|
||||
"publishDir": "..\\..\\..\\releases\\MaksIT.LTO.Backup.UI\\linux-x64",
|
||||
"summary": "LTO tape backup and restore",
|
||||
"description": "Desktop UI for LTO tape backup and restore. Inventory, backup, and restore jobs for a tape drive or library.",
|
||||
"projectLicense": "GPL-2.0",
|
||||
"categories": "Utility;Archiving;",
|
||||
"iconPath": "..\\..\\..\\src\\MaksIT.LTO.Backup.UI\\Assets\\icon.png",
|
||||
"metainfoPath": "..\\..\\..\\data\\com.maks_it.ltobackup.metainfo.xml",
|
||||
"desktopPath": "..\\..\\..\\data\\com.maks_it.ltobackup.desktop",
|
||||
"finishArgs": [
|
||||
"--share=ipc",
|
||||
"--share=network",
|
||||
"--socket=wayland",
|
||||
"--socket=x11",
|
||||
"--device=all",
|
||||
"--filesystem=home"
|
||||
],
|
||||
"bundleNamePattern": "maksit-lto-backup-{version}.flatpak",
|
||||
"buildBundle": true,
|
||||
"attachSourceZip": false,
|
||||
"wslDistro": "Debian",
|
||||
"whenBuilderMissing": "skip"
|
||||
},
|
||||
{
|
||||
"name": "ReleasePublishGuard",
|
||||
@ -72,7 +125,8 @@
|
||||
"githubSecret": "GitClone",
|
||||
"repository": "https://github.com/MAKS-IT-COM/maksit-lto-backup",
|
||||
"releaseNotesFile": "..\\..\\..\\CHANGELOG.md",
|
||||
"releaseTitlePattern": "Release {version}"
|
||||
"releaseTitlePattern": "Release {version}",
|
||||
"requireReleaseAssets": true
|
||||
},
|
||||
{
|
||||
"name": "DotNetNuGet",
|
||||
@ -98,7 +152,9 @@
|
||||
"*"
|
||||
],
|
||||
"excludePatterns": [
|
||||
"*.zip"
|
||||
"*.zip",
|
||||
"*.exe",
|
||||
"*.flatpak"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@ -323,8 +323,33 @@ function New-WixPackageXml {
|
||||
$desktopProp = $xml.CreateElement('Property', $ns)
|
||||
$null = $desktopProp.SetAttribute('Id', 'INSTALLDESKTOPSHORTCUT')
|
||||
$null = $desktopProp.SetAttribute('Value', '0')
|
||||
$null = $desktopProp.SetAttribute('Secure', 'yes')
|
||||
$null = $package.AppendChild($desktopProp)
|
||||
|
||||
# Present when a previous install wrote the desktop shortcut marker.
|
||||
# Burn also searches HKCU as the user and passes DESKTOPSHORTCUTFOUND,
|
||||
# because a per-machine execute sequence runs as SYSTEM.
|
||||
$foundProp = $xml.CreateElement('Property', $ns)
|
||||
$null = $foundProp.SetAttribute('Id', 'DESKTOPSHORTCUTFOUND')
|
||||
$null = $foundProp.SetAttribute('Secure', 'yes')
|
||||
$foundSearch = $xml.CreateElement('RegistrySearch', $ns)
|
||||
$null = $foundSearch.SetAttribute('Id', 'DesktopShortcutFoundSearch')
|
||||
$null = $foundSearch.SetAttribute('Root', 'HKCU')
|
||||
$null = $foundSearch.SetAttribute('Key', "Software\$Manufacturer\$AppName")
|
||||
$null = $foundSearch.SetAttribute('Name', 'desktop')
|
||||
$null = $foundSearch.SetAttribute('Type', 'raw')
|
||||
$null = $foundProp.AppendChild($foundSearch)
|
||||
$null = $package.AppendChild($foundProp)
|
||||
|
||||
# Checkbox off on reinstall must not drop an icon that is already there.
|
||||
# Major upgrade removes the previous shortcut; this installs that one again.
|
||||
$keepShortcut = $xml.CreateElement('SetProperty', $ns)
|
||||
$null = $keepShortcut.SetAttribute('Id', 'INSTALLDESKTOPSHORTCUT')
|
||||
$null = $keepShortcut.SetAttribute('Value', '1')
|
||||
$null = $keepShortcut.SetAttribute('Before', 'CostFinalize')
|
||||
$null = $keepShortcut.SetAttribute('Condition', 'DESKTOPSHORTCUTFOUND = "1"')
|
||||
$null = $package.AppendChild($keepShortcut)
|
||||
|
||||
$productFolder = Get-DesktopInstallFolderName `
|
||||
-AppName $AppName `
|
||||
-Manufacturer $Manufacturer `
|
||||
@ -449,7 +474,8 @@ function New-WixPackageXml {
|
||||
$desktop = $xml.CreateElement('Component', $ns)
|
||||
$null = $desktop.SetAttribute('Id', 'DesktopShortcut')
|
||||
$null = $desktop.SetAttribute('Directory', 'DesktopFolder')
|
||||
$null = $desktop.SetAttribute('Guid', '*')
|
||||
$shortcutGuid = Get-DesktopShortcutComponentGuid -Manufacturer $Manufacturer -AppName $AppName
|
||||
$null = $desktop.SetAttribute('Guid', $shortcutGuid.ToString('D'))
|
||||
$null = $desktop.SetAttribute('Condition', 'INSTALLDESKTOPSHORTCUT = 1')
|
||||
$desktopShortcut = $xml.CreateElement('Shortcut', $ns)
|
||||
$null = $desktopShortcut.SetAttribute('Id', 'AppDesktopShortcut')
|
||||
@ -461,6 +487,26 @@ function New-WixPackageXml {
|
||||
}
|
||||
|
||||
$null = $desktop.AppendChild($desktopShortcut)
|
||||
# Same file name is replaced. A second ".lnk" (and the "(2)" copy) is removed
|
||||
# before CreateShortcuts so a checked box does not leave two desktop icons.
|
||||
$cleanupNames = [System.Collections.Generic.List[string]]::new()
|
||||
$cleanupNames.Add($productFolder)
|
||||
if (-not $cleanupNames.Contains($AppName)) {
|
||||
$cleanupNames.Add($AppName)
|
||||
}
|
||||
|
||||
$cleanupIndex = 0
|
||||
foreach ($shortcutName in $cleanupNames) {
|
||||
foreach ($suffix in @('', ' (2)')) {
|
||||
$cleanupIndex++
|
||||
$removeLnk = $xml.CreateElement('RemoveFile', $ns)
|
||||
$null = $removeLnk.SetAttribute('Id', "DesktopShortcutCleanup$cleanupIndex")
|
||||
$null = $removeLnk.SetAttribute('Name', "$shortcutName$suffix.lnk")
|
||||
$null = $removeLnk.SetAttribute('On', 'install')
|
||||
$null = $desktop.AppendChild($removeLnk)
|
||||
}
|
||||
}
|
||||
|
||||
$desktopReg = $xml.CreateElement('RegistryValue', $ns)
|
||||
$null = $desktopReg.SetAttribute('Root', 'HKCU')
|
||||
$null = $desktopReg.SetAttribute('Key', "Software\$Manufacturer\$AppName")
|
||||
@ -496,9 +542,10 @@ function Get-DefaultFlatpakFinishArgs {
|
||||
return @(
|
||||
'--share=ipc',
|
||||
'--share=network',
|
||||
'--socket=fallback-x11',
|
||||
'--socket=x11',
|
||||
'--socket=wayland',
|
||||
'--device=dri',
|
||||
'--socket=pulseaudio',
|
||||
'--filesystem=home'
|
||||
)
|
||||
}
|
||||
@ -524,12 +571,118 @@ function New-FlatpakDesktopEntry {
|
||||
"Name=$AppName",
|
||||
"Exec=$Command",
|
||||
"Icon=$AppId",
|
||||
"StartupWMClass=$AppId",
|
||||
'StartupNotify=true',
|
||||
'Terminal=false',
|
||||
"Categories=$Categories"
|
||||
)
|
||||
return (($lines -join "`n") + "`n")
|
||||
}
|
||||
|
||||
function Copy-FlatpakHicolorIcons {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$AppId,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$HicolorRoot,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$IconPath,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$SvgIconPath
|
||||
)
|
||||
|
||||
$pngSource = $null
|
||||
$svgSource = $null
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($IconPath) -and (Test-Path -LiteralPath $IconPath -PathType Leaf)) {
|
||||
$ext = [System.IO.Path]::GetExtension($IconPath)
|
||||
if ($ext -ieq '.png') {
|
||||
$pngSource = $IconPath
|
||||
}
|
||||
elseif ($ext -ieq '.svg') {
|
||||
$svgSource = $IconPath
|
||||
}
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($SvgIconPath) -and (Test-Path -LiteralPath $SvgIconPath -PathType Leaf)) {
|
||||
$svgSource = $SvgIconPath
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($svgSource) -and -not [string]::IsNullOrWhiteSpace($pngSource)) {
|
||||
$siblingSvg = [System.IO.Path]::ChangeExtension($pngSource, '.svg')
|
||||
if (Test-Path -LiteralPath $siblingSvg -PathType Leaf) {
|
||||
$svgSource = $siblingSvg
|
||||
}
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($pngSource) -and -not [string]::IsNullOrWhiteSpace($svgSource)) {
|
||||
$siblingPng = [System.IO.Path]::ChangeExtension($svgSource, '.png')
|
||||
if (Test-Path -LiteralPath $siblingPng -PathType Leaf) {
|
||||
$pngSource = $siblingPng
|
||||
}
|
||||
}
|
||||
|
||||
$installed = $false
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($pngSource)) {
|
||||
foreach ($size in @('48x48', '64x64', '128x128', '256x256')) {
|
||||
$pngDir = Join-Path $HicolorRoot "$size\apps"
|
||||
New-Item -ItemType Directory -Path $pngDir -Force | Out-Null
|
||||
Copy-Item -LiteralPath $pngSource -Destination (Join-Path $pngDir "$AppId.png") -Force
|
||||
}
|
||||
|
||||
$installed = $true
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($svgSource)) {
|
||||
$svgDir = Join-Path $HicolorRoot 'scalable\apps'
|
||||
New-Item -ItemType Directory -Path $svgDir -Force | Out-Null
|
||||
Copy-Item -LiteralPath $svgSource -Destination (Join-Path $svgDir "$AppId.svg") -Force
|
||||
$installed = $true
|
||||
}
|
||||
|
||||
return $installed
|
||||
}
|
||||
|
||||
function ConvertTo-FlatpakMetainfoDescriptionXml {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$AppName,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[AllowEmptyString()]
|
||||
[string]$Summary,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[AllowEmptyString()]
|
||||
[string]$Description
|
||||
)
|
||||
|
||||
$text = $Description
|
||||
if ([string]::IsNullOrWhiteSpace($text)) {
|
||||
$text = "$AppName is a desktop application. $Summary".Trim()
|
||||
}
|
||||
|
||||
$paragraphs = [System.Collections.Generic.List[string]]::new()
|
||||
foreach ($chunk in @($text -split '(\r?\n){2,}')) {
|
||||
$trimmed = $chunk.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($trimmed)) {
|
||||
continue
|
||||
}
|
||||
|
||||
$paragraphs.Add(' <p>' + [System.Security.SecurityElement]::Escape($trimmed) + '</p>')
|
||||
}
|
||||
|
||||
if ($paragraphs.Count -eq 0) {
|
||||
$paragraphs.Add(' <p>' + [System.Security.SecurityElement]::Escape("$AppName is a desktop application.") + '</p>')
|
||||
}
|
||||
|
||||
return ($paragraphs -join "`n")
|
||||
}
|
||||
|
||||
function New-FlatpakMetainfoXml {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
@ -541,26 +694,124 @@ function New-FlatpakMetainfoXml {
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Summary,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[AllowEmptyString()]
|
||||
[string]$Description = '',
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$ProjectLicense = 'MIT',
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$MetadataLicense = 'CC0-1.0'
|
||||
[string]$MetadataLicense = 'CC0-1.0',
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$Version = ''
|
||||
)
|
||||
|
||||
$escapedName = [System.Security.SecurityElement]::Escape($AppName)
|
||||
$escapedSummary = [System.Security.SecurityElement]::Escape($Summary)
|
||||
return @"
|
||||
$descriptionXml = ConvertTo-FlatpakMetainfoDescriptionXml -AppName $AppName -Summary $Summary -Description $Description
|
||||
$xml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>$AppId</id>
|
||||
<name>$escapedName</name>
|
||||
<summary>$escapedSummary</summary>
|
||||
<description>
|
||||
$descriptionXml
|
||||
</description>
|
||||
<launchable type="desktop-id">$AppId.desktop</launchable>
|
||||
<metadata_license>$MetadataLicense</metadata_license>
|
||||
<project_license>$ProjectLicense</project_license>
|
||||
</component>
|
||||
"@
|
||||
if ([string]::IsNullOrWhiteSpace($Version)) {
|
||||
return $xml
|
||||
}
|
||||
|
||||
return Set-FlatpakMetainfoRelease -XmlText $xml -Version $Version
|
||||
}
|
||||
|
||||
function Set-FlatpakMetainfoRelease {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$XmlText,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Version,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$Date = ''
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Version)) {
|
||||
throw "Set-FlatpakMetainfoRelease requires Version."
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Date)) {
|
||||
$Date = [datetime]::UtcNow.ToString('yyyy-MM-dd')
|
||||
}
|
||||
|
||||
$doc = New-Object System.Xml.XmlDocument
|
||||
$doc.PreserveWhitespace = $false
|
||||
$doc.LoadXml($XmlText)
|
||||
$component = $doc.SelectSingleNode('/component')
|
||||
if ($null -eq $component) {
|
||||
throw "Flatpak metainfo has no <component> root."
|
||||
}
|
||||
|
||||
$releases = $component.SelectSingleNode('releases')
|
||||
if ($null -eq $releases) {
|
||||
$releases = $doc.CreateElement('releases')
|
||||
$null = $component.AppendChild($releases)
|
||||
}
|
||||
|
||||
$match = $null
|
||||
foreach ($node in @($releases.SelectNodes('release'))) {
|
||||
if ([string]$node.GetAttribute('version') -eq $Version) {
|
||||
$match = $node
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
$first = $releases.SelectSingleNode('release')
|
||||
if ($null -ne $match) {
|
||||
$match.SetAttribute('date', $Date)
|
||||
if (-not [object]::ReferenceEquals($match, $first)) {
|
||||
$null = $releases.InsertBefore($match, $first)
|
||||
}
|
||||
}
|
||||
else {
|
||||
$release = $doc.CreateElement('release')
|
||||
$release.SetAttribute('version', $Version)
|
||||
$release.SetAttribute('date', $Date)
|
||||
if ($null -eq $first) {
|
||||
$null = $releases.AppendChild($release)
|
||||
}
|
||||
else {
|
||||
$null = $releases.InsertBefore($release, $first)
|
||||
}
|
||||
}
|
||||
|
||||
$settings = New-Object System.Xml.XmlWriterSettings
|
||||
$settings.Indent = $true
|
||||
$settings.OmitXmlDeclaration = $false
|
||||
$settings.Encoding = [System.Text.UTF8Encoding]::new($false)
|
||||
$stream = New-Object System.IO.MemoryStream
|
||||
try {
|
||||
$writer = [System.Xml.XmlWriter]::Create($stream, $settings)
|
||||
try {
|
||||
$doc.Save($writer)
|
||||
}
|
||||
finally {
|
||||
$writer.Dispose()
|
||||
}
|
||||
|
||||
return [System.Text.Encoding]::UTF8.GetString($stream.ToArray())
|
||||
}
|
||||
finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function New-FlatpakManifestObject {
|
||||
@ -597,8 +848,8 @@ function New-FlatpakManifestObject {
|
||||
if ($null -eq $BuildCommands -or $BuildCommands.Count -eq 0) {
|
||||
$BuildCommands = @(
|
||||
'mkdir -p /app/lib /app/share',
|
||||
'cp -a lib /app/lib',
|
||||
'cp -a share /app/share',
|
||||
'cp -a lib/. /app/lib/',
|
||||
'cp -a share/. /app/share/',
|
||||
"install -Dm755 bin/$Command /app/bin/$Command",
|
||||
"chmod +x /app/lib/$ModuleName/$Command"
|
||||
)
|
||||
@ -636,7 +887,15 @@ function New-FlatpakLaunchScript {
|
||||
[string]$ExecutableFileName
|
||||
)
|
||||
|
||||
return "#!/bin/sh`nexec /app/lib/$ModuleName/$ExecutableFileName `"`$@`"`n"
|
||||
$lib = "/app/lib/$ModuleName"
|
||||
return @"
|
||||
#!/bin/sh
|
||||
LIB='$lib'
|
||||
if [ -f "`$LIB/flatpak-native.env" ]; then
|
||||
. "`$LIB/flatpak-native.env"
|
||||
fi
|
||||
exec `$LIB/$ExecutableFileName "`$`@"
|
||||
"@
|
||||
}
|
||||
|
||||
function Assert-FlatpakAppId {
|
||||
@ -661,6 +920,27 @@ function Assert-FlatpakAppId {
|
||||
}
|
||||
}
|
||||
|
||||
function Get-DesktopShortcutComponentGuid {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Manufacturer,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$AppName
|
||||
)
|
||||
|
||||
$identity = "maksit-desktop-shortcut|$Manufacturer|$AppName"
|
||||
$md5 = [System.Security.Cryptography.MD5]::Create()
|
||||
try {
|
||||
$hash = $md5.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($identity))
|
||||
}
|
||||
finally {
|
||||
$md5.Dispose()
|
||||
}
|
||||
|
||||
return [guid]::new($hash)
|
||||
}
|
||||
|
||||
function Get-DerivedBundleUpgradeCode {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
@ -983,15 +1263,18 @@ function New-WixBundleXml {
|
||||
$folderRoot + $Manufacturer + '\' + $productFolder
|
||||
}
|
||||
$escapedFolder = [System.Security.SecurityElement]::Escape($folderPath)
|
||||
$escapedShortcutKey = [System.Security.SecurityElement]::Escape("Software\$Manufacturer\$AppName")
|
||||
|
||||
# WiX v7 Bundle has no Scope attribute (WIX0004). MSI Package/@Scope plus
|
||||
# InstallFolder tokens decide per-machine vs per-user; Burn infers bundle scope.
|
||||
return @"
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs" xmlns:bal="http://wixtoolset.org/schemas/v4/wxs/bal">
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs" xmlns:bal="http://wixtoolset.org/schemas/v4/wxs/bal" xmlns:util="http://wixtoolset.org/schemas/v4/wxs/util">
|
||||
<Bundle Name="$escapedName" Manufacturer="$escapedMfr" Version="$ProductVersion" UpgradeCode="$($bundleUpgrade.ToString('D'))"$iconAttr>
|
||||
<Variable Name="InstallFolder" Type="formatted" Value="$escapedFolder" bal:Overridable="yes" />
|
||||
<Variable Name="InstallDesktopShortcut" Type="numeric" Value="0" bal:Overridable="yes" />
|
||||
<Variable Name="DesktopShortcutFound" Type="string" Value="" bal:Overridable="yes" />
|
||||
<util:RegistrySearch Id="DesktopShortcutFoundSearch" Variable="DesktopShortcutFound" Root="HKCU" Key="$escapedShortcutKey" Value="desktop" Result="exists" />
|
||||
<BootstrapperApplication>
|
||||
<bal:WixStandardBootstrapperApplication Theme="$theme" LicenseUrl=""$logoAttrs />
|
||||
</BootstrapperApplication>
|
||||
@ -999,6 +1282,7 @@ function New-WixBundleXml {
|
||||
<MsiPackage SourceFile="$escapedMsi" Compressed="yes" Vital="yes">
|
||||
<MsiProperty Name="INSTALLFOLDER" Value="[InstallFolder]" />
|
||||
<MsiProperty Name="INSTALLDESKTOPSHORTCUT" Value="[InstallDesktopShortcut]" />
|
||||
<MsiProperty Name="DESKTOPSHORTCUTFOUND" Value="[DesktopShortcutFound]" />
|
||||
</MsiPackage>
|
||||
</Chain>
|
||||
</Bundle>
|
||||
@ -1020,7 +1304,9 @@ Export-ModuleMember -Function `
|
||||
New-WixBundleXml, `
|
||||
Get-DefaultFlatpakFinishArgs, `
|
||||
New-FlatpakDesktopEntry, `
|
||||
Copy-FlatpakHicolorIcons, `
|
||||
New-FlatpakMetainfoXml, `
|
||||
Set-FlatpakMetainfoRelease, `
|
||||
New-FlatpakManifestObject, `
|
||||
New-FlatpakLaunchScript, `
|
||||
Assert-FlatpakAppId, `
|
||||
|
||||
@ -13,6 +13,8 @@
|
||||
(default Debian) on the Linux filesystem, then copies the bundle back.
|
||||
Docker Desktop WSL distros are never used. whenBuilderMissing skip|fail
|
||||
applies when neither native nor WSL builder is available.
|
||||
AppStream <release version> and the bundle file name use the same shared
|
||||
release version as zip/MSI (DotNetReleaseVersion: csproj or Directory.Build.props).
|
||||
#>
|
||||
|
||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||
@ -34,6 +36,37 @@ function Get-FlatpakWhenBuilderMissing {
|
||||
return $value
|
||||
}
|
||||
|
||||
function Resolve-FlatpakPackOptionalFile {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ScriptDir,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[AllowEmptyString()]
|
||||
[string]$Setting,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Setting)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$path = if ([System.IO.Path]::IsPathRooted($Setting)) {
|
||||
$Setting
|
||||
}
|
||||
else {
|
||||
[System.IO.Path]::GetFullPath((Join-Path $ScriptDir $Setting))
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
|
||||
throw "FlatpakPack $Name not found: $path"
|
||||
}
|
||||
|
||||
return $path
|
||||
}
|
||||
|
||||
function Test-FlatpakBuilderMissingException {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
@ -188,6 +221,9 @@ function Invoke-Plugin {
|
||||
$scriptDir = $sharedSettings.scriptDir
|
||||
$version = [string]$sharedSettings.version
|
||||
$artifactsDirectory = $sharedSettings.artifactsDirectory
|
||||
if ([string]::IsNullOrWhiteSpace($version)) {
|
||||
throw "FlatpakPack requires a release version in the shared context (same as zip/MSI: DotNetReleaseVersion from csproj / Directory.Build.props)."
|
||||
}
|
||||
|
||||
$appId = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'appId')
|
||||
$appName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'appName')
|
||||
@ -206,9 +242,13 @@ function Invoke-Plugin {
|
||||
$runtimeIdentifier = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'runtimeIdentifier' -Default 'linux-x64')
|
||||
$publishDirSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'publishDir')
|
||||
$iconSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'iconPath')
|
||||
$svgIconSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'svgIconPath')
|
||||
$summary = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'summary' -Default $appName)
|
||||
$description = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'description' -Default '')
|
||||
$projectLicense = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'projectLicense' -Default 'MIT')
|
||||
$categories = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'categories' -Default 'Utility;')
|
||||
$metainfoSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'metainfoPath' -Default '')
|
||||
$desktopFileSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'desktopPath' -Default '')
|
||||
$runtime = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'runtime' -Default 'org.freedesktop.Platform')
|
||||
$runtimeVersion = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'runtimeVersion' -Default '24.08')
|
||||
$sdk = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'sdk' -Default 'org.freedesktop.Sdk')
|
||||
@ -264,8 +304,8 @@ function Invoke-Plugin {
|
||||
$binDir = Join-Path $filesRoot 'bin'
|
||||
$shareAppDir = Join-Path $filesRoot 'share\applications'
|
||||
$shareMetaDir = Join-Path $filesRoot 'share\metainfo'
|
||||
$shareIconDir = Join-Path $filesRoot 'share\icons\hicolor\256x256\apps'
|
||||
New-Item -ItemType Directory -Path $libDir, $binDir, $shareAppDir, $shareMetaDir, $shareIconDir -Force | Out-Null
|
||||
$hicolorRoot = Join-Path $filesRoot 'share\icons\hicolor'
|
||||
New-Item -ItemType Directory -Path $libDir, $binDir, $shareAppDir, $shareMetaDir, $hicolorRoot -Force | Out-Null
|
||||
|
||||
Write-Log -Level "STEP" -Message "Staging Flatpak layout from $publishDirectory"
|
||||
Copy-Item -Path (Join-Path $publishDirectory '*') -Destination $libDir -Recurse -Force
|
||||
@ -274,20 +314,56 @@ function Invoke-Plugin {
|
||||
$launchPath = Join-Path $binDir $command
|
||||
[System.IO.File]::WriteAllText($launchPath, $launchScript.Replace("`r`n", "`n"), [System.Text.UTF8Encoding]::new($false))
|
||||
|
||||
$desktopPath = Join-Path $shareAppDir "$appId.desktop"
|
||||
[System.IO.File]::WriteAllText(
|
||||
$desktopPath,
|
||||
(New-FlatpakDesktopEntry -AppId $appId -AppName $appName -Command $command -Categories $categories).Replace("`r`n", "`n"),
|
||||
[System.Text.UTF8Encoding]::new($false)
|
||||
)
|
||||
$desktopDest = Join-Path $shareAppDir "$appId.desktop"
|
||||
$upstreamDesktop = Resolve-FlatpakPackOptionalFile -ScriptDir $scriptDir -Setting $desktopFileSetting -Name 'desktopPath'
|
||||
if ($null -ne $upstreamDesktop) {
|
||||
$desktopText = [System.IO.File]::ReadAllText($upstreamDesktop) -replace "`r`n", "`n" -replace "`r", "`n"
|
||||
if (-not $desktopText.EndsWith("`n")) {
|
||||
$desktopText += "`n"
|
||||
}
|
||||
|
||||
$metaPath = Join-Path $shareMetaDir "$appId.metainfo.xml"
|
||||
[System.IO.File]::WriteAllText(
|
||||
$metaPath,
|
||||
(New-FlatpakMetainfoXml -AppId $appId -AppName $appName -Summary $summary -ProjectLicense $projectLicense).Replace("`r`n", "`n"),
|
||||
[System.Text.UTF8Encoding]::new($false)
|
||||
)
|
||||
[System.IO.File]::WriteAllText($desktopDest, $desktopText, [System.Text.UTF8Encoding]::new($false))
|
||||
}
|
||||
else {
|
||||
[System.IO.File]::WriteAllText(
|
||||
$desktopDest,
|
||||
(New-FlatpakDesktopEntry -AppId $appId -AppName $appName -Command $command -Categories $categories).Replace("`r`n", "`n"),
|
||||
[System.Text.UTF8Encoding]::new($false)
|
||||
)
|
||||
}
|
||||
|
||||
$metaDest = Join-Path $shareMetaDir "$appId.metainfo.xml"
|
||||
$upstreamMetainfo = Resolve-FlatpakPackOptionalFile -ScriptDir $scriptDir -Setting $metainfoSetting -Name 'metainfoPath'
|
||||
if ($null -ne $upstreamMetainfo) {
|
||||
$metaText = [System.IO.File]::ReadAllText($upstreamMetainfo) -replace "`r`n", "`n" -replace "`r", "`n"
|
||||
if (-not $metaText.EndsWith("`n")) {
|
||||
$metaText += "`n"
|
||||
}
|
||||
|
||||
$metaText = $metaText.Replace('{version}', $version)
|
||||
$metaText = Set-FlatpakMetainfoRelease -XmlText $metaText -Version $version
|
||||
if (-not $metaText.EndsWith("`n")) {
|
||||
$metaText += "`n"
|
||||
}
|
||||
|
||||
[System.IO.File]::WriteAllText($metaDest, $metaText, [System.Text.UTF8Encoding]::new($false))
|
||||
}
|
||||
else {
|
||||
[System.IO.File]::WriteAllText(
|
||||
$metaDest,
|
||||
(New-FlatpakMetainfoXml -AppId $appId -AppName $appName -Summary $summary -Description $description -ProjectLicense $projectLicense -Version $version).Replace("`r`n", "`n"),
|
||||
[System.Text.UTF8Encoding]::new($false)
|
||||
)
|
||||
}
|
||||
|
||||
$stagedMeta = [System.IO.File]::ReadAllText($metaDest)
|
||||
if ($stagedMeta -notmatch [regex]::Escape("version=`"$version`"")) {
|
||||
throw "FlatpakPack failed to stamp AppStream release version '$version' into $metaDest."
|
||||
}
|
||||
|
||||
Write-Log -Level "OK" -Message " AppStream release version: $version"
|
||||
|
||||
$iconPath = $null
|
||||
if (-not [string]::IsNullOrWhiteSpace($iconSetting)) {
|
||||
$iconPath = if ([System.IO.Path]::IsPathRooted($iconSetting)) {
|
||||
$iconSetting
|
||||
@ -299,21 +375,39 @@ function Invoke-Plugin {
|
||||
if (-not (Test-Path -LiteralPath $iconPath -PathType Leaf)) {
|
||||
throw "FlatpakPack iconPath not found: $iconPath"
|
||||
}
|
||||
}
|
||||
|
||||
if ([System.IO.Path]::GetExtension($iconPath) -ine '.png') {
|
||||
Write-Log -Level "WARN" -Message " Flatpak icon should be PNG; skipping copy of $iconPath"
|
||||
$svgIconPath = $null
|
||||
if (-not [string]::IsNullOrWhiteSpace($svgIconSetting)) {
|
||||
$svgIconPath = if ([System.IO.Path]::IsPathRooted($svgIconSetting)) {
|
||||
$svgIconSetting
|
||||
}
|
||||
else {
|
||||
Copy-Item -LiteralPath $iconPath -Destination (Join-Path $shareIconDir "$appId.png") -Force
|
||||
[System.IO.Path]::GetFullPath((Join-Path $scriptDir $svgIconSetting))
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $svgIconPath -PathType Leaf)) {
|
||||
throw "FlatpakPack svgIconPath not found: $svgIconPath"
|
||||
}
|
||||
}
|
||||
|
||||
$iconInstalled = Copy-FlatpakHicolorIcons `
|
||||
-AppId $appId `
|
||||
-HicolorRoot $hicolorRoot `
|
||||
-IconPath $iconPath `
|
||||
-SvgIconPath $svgIconPath
|
||||
|
||||
if (-not $iconInstalled -and -not [string]::IsNullOrWhiteSpace($iconPath)) {
|
||||
Write-Log -Level "WARN" -Message " Flatpak icon should be PNG or SVG; skipping copy of $iconPath"
|
||||
}
|
||||
|
||||
$buildCommands = @(
|
||||
'mkdir -p /app/lib /app/share',
|
||||
"cp -a lib/$moduleName /app/lib/$moduleName",
|
||||
'cp -a share /app/share',
|
||||
'cp -a share/. /app/share/',
|
||||
"install -Dm755 bin/$command /app/bin/$command",
|
||||
"chmod +x /app/lib/$moduleName/$executableFileName"
|
||||
"chmod +x /app/lib/$moduleName/$executableFileName",
|
||||
'gtk-update-icon-cache -f /app/share/icons/hicolor >/dev/null 2>&1 || true'
|
||||
)
|
||||
|
||||
$manifest = New-FlatpakManifestObject `
|
||||
|
||||
@ -13,7 +13,8 @@
|
||||
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
|
||||
OSMF EULA (`wix eula accept wix7` or `-acceptEula wix7`) and
|
||||
`wix extension add -g WixToolset.BootstrapperApplications.wixext`.
|
||||
`wix extension add -g WixToolset.BootstrapperApplications.wixext` and
|
||||
`wix extension add -g WixToolset.Util.wixext`.
|
||||
Reopen the shell so `%USERPROFILE%\.dotnet\tools` is on PATH.
|
||||
#>
|
||||
|
||||
@ -40,7 +41,7 @@ function Test-WixMissingException {
|
||||
}
|
||||
|
||||
function Get-WixMissingInstallMessage {
|
||||
return "WiX CLI ('wix') is not on PATH. Install with: dotnet tool install -g wix. For WiX v7 also run: wix eula accept wix7; wix extension add -g WixToolset.BootstrapperApplications.wixext. Reopen the terminal so %USERPROFILE%\.dotnet\tools is on PATH."
|
||||
return "WiX CLI ('wix') is not on PATH. Install with: dotnet tool install -g wix. For WiX v7 also run: wix eula accept wix7; wix extension add -g WixToolset.BootstrapperApplications.wixext; wix extension add -g WixToolset.Util.wixext. Reopen the terminal so %USERPROFILE%\.dotnet\tools is on PATH."
|
||||
}
|
||||
|
||||
function Get-WixCliVersionText {
|
||||
@ -280,7 +281,7 @@ function Invoke-Plugin {
|
||||
|
||||
Write-Log -Level "STEP" -Message "Building Windows installer exe ($wixArch)..."
|
||||
try {
|
||||
Invoke-ExternalCommand -Name wix -ArgumentList (@('build') + $eulaArgs + $archArgs + @($bundleWxsPath, '-ext', $bundleExt, '-o', $exePath)) | Out-Null
|
||||
Invoke-ExternalCommand -Name wix -ArgumentList (@('build') + $eulaArgs + $archArgs + @($bundleWxsPath, '-ext', $bundleExt, '-ext', 'WixToolset.Util.wixext', '-o', $exePath)) | Out-Null
|
||||
}
|
||||
catch {
|
||||
if (Test-WixMissingException -ErrorRecord $_) {
|
||||
|
||||
@ -1,89 +0,0 @@
|
||||
#requires -Version 7.0
|
||||
#requires -PSEdition Core
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds an npm workspace (install + build script).
|
||||
|
||||
.DESCRIPTION
|
||||
Runs npm ci (or npm install when useCi is false) and npm run build in the
|
||||
configured workspace root. Requires NpmReleaseVersion to have set
|
||||
shared npmWorkspaceRoot unless workspaceRoot is configured explicitly.
|
||||
#>
|
||||
|
||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
||||
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
|
||||
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
|
||||
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Plugin {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
$Settings
|
||||
)
|
||||
|
||||
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
||||
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
|
||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
|
||||
|
||||
$pluginSettings = $Settings
|
||||
$shared = $Settings.context
|
||||
|
||||
Assert-Command npm
|
||||
|
||||
$workspaceRoot = $null
|
||||
if ($pluginSettings.workspaceRoot) {
|
||||
$workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $shared.scriptDir)
|
||||
$workspaceRoot = $workspaceRoots[0]
|
||||
}
|
||||
elseif ($shared.PSObject.Properties['npmWorkspaceRoot'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.npmWorkspaceRoot)) {
|
||||
$workspaceRoot = [string]$shared.npmWorkspaceRoot
|
||||
}
|
||||
else {
|
||||
throw "NpmBuild plugin requires 'workspaceRoot' or a prior NpmReleaseVersion plugin run."
|
||||
}
|
||||
|
||||
$useCi = $true
|
||||
if ($null -ne $pluginSettings.useCi) {
|
||||
$useCi = [bool]$pluginSettings.useCi
|
||||
}
|
||||
|
||||
$buildScript = 'build'
|
||||
if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.buildScript)) {
|
||||
$buildScript = [string]$pluginSettings.buildScript
|
||||
}
|
||||
|
||||
Push-Location $workspaceRoot
|
||||
try {
|
||||
if ($useCi) {
|
||||
Write-Log -Level "STEP" -Message "Running npm ci in '$workspaceRoot'..."
|
||||
npm ci
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "npm ci failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Log -Level "STEP" -Message "Running npm install in '$workspaceRoot'..."
|
||||
npm install
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "npm install failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log -Level "STEP" -Message "Running npm run $buildScript..."
|
||||
npm run $buildScript
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "npm run $buildScript failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
Write-Log -Level "OK" -Message " npm build completed."
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Invoke-Plugin
|
||||
@ -1,75 +0,0 @@
|
||||
#requires -Version 7.0
|
||||
#requires -PSEdition Core
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
npm/Jest test plugin for the test engine.
|
||||
|
||||
.DESCRIPTION
|
||||
Runs Jest with coverage via TestRunner.Invoke-NpmJestTestsWithCoverage and publishes
|
||||
normalized metrics on the shared engine context for downstream plugins.
|
||||
#>
|
||||
|
||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
||||
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
|
||||
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
|
||||
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Plugin {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
$Settings
|
||||
)
|
||||
|
||||
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
||||
Import-PluginDependency -ModuleName "TestRunner" -RequiredCommand "Invoke-NpmJestTestsWithCoverage"
|
||||
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
|
||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
|
||||
|
||||
$pluginSettings = $Settings
|
||||
$sharedSettings = $Settings.context
|
||||
$scriptDir = $sharedSettings.scriptDir
|
||||
|
||||
Assert-Command npm
|
||||
|
||||
if (-not $pluginSettings.workspaceRoot) {
|
||||
throw "NpmJestTest plugin requires 'workspaceRoot' in scriptSettings.json."
|
||||
}
|
||||
|
||||
$workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $scriptDir)
|
||||
$workspaceRoot = $workspaceRoots[0]
|
||||
|
||||
$testScript = 'test'
|
||||
if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.testScript)) {
|
||||
$testScript = [string]$pluginSettings.testScript
|
||||
}
|
||||
|
||||
$coverageDirectory = 'coverage'
|
||||
if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.coverageDirectory)) {
|
||||
$coverageDirectory = [string]$pluginSettings.coverageDirectory
|
||||
}
|
||||
|
||||
$testResult = Invoke-NpmJestTestsWithCoverage -WorkspaceRoot $workspaceRoot -TestScript $testScript -CoverageDirectory $coverageDirectory
|
||||
|
||||
if (-not $testResult.Success) {
|
||||
throw "Tests failed. $($testResult.Error)"
|
||||
}
|
||||
|
||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact"
|
||||
Import-PluginDependency -ModuleName "TestRunner" -RequiredCommand "Publish-CoverageMetricsToSharedContext"
|
||||
Set-EngineFact -Context $sharedSettings -Namespace 'npm' -Name 'workspaceRoot' -Value $workspaceRoot -Overwrite Replace -LegacyProperty 'npmWorkspaceRoot'
|
||||
Publish-CoverageMetricsToSharedContext -SharedSettings $sharedSettings -TestResult $testResult
|
||||
if (($testResult.PSObject.Properties.Name -contains 'CoverageSummaryFile') -and $testResult.CoverageSummaryFile) {
|
||||
Set-EngineFact -Context $sharedSettings -Namespace 'test' -Name 'coverageSummaryFile' -Value $testResult.CoverageSummaryFile -Overwrite Replace -LegacyProperty 'coverageSummaryFile'
|
||||
}
|
||||
|
||||
Write-Log -Level "OK" -Message " All tests passed!"
|
||||
Write-Log -Level "INFO" -Message " Line Coverage: $($testResult.LineRate)%"
|
||||
Write-Log -Level "INFO" -Message " Branch Coverage: $($testResult.BranchRate)%"
|
||||
Write-Log -Level "INFO" -Message " Method Coverage: $($testResult.MethodRate)%"
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Invoke-Plugin
|
||||
@ -1,139 +0,0 @@
|
||||
#requires -Version 7.0
|
||||
#requires -PSEdition Core
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Packs npm workspace packages into .tgz release artifacts.
|
||||
|
||||
.DESCRIPTION
|
||||
Runs npm pack for each configured workspace package and publishes the
|
||||
resulting tarball paths into shared release context (releaseAssetPaths)
|
||||
for the GitHub release plugin.
|
||||
#>
|
||||
|
||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
||||
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
|
||||
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
|
||||
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Plugin {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
$Settings
|
||||
)
|
||||
|
||||
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
||||
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
|
||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact"
|
||||
|
||||
$pluginSettings = $Settings
|
||||
$shared = $Settings.context
|
||||
|
||||
Assert-Command npm
|
||||
|
||||
$workspaceRoot = $null
|
||||
if ($pluginSettings.workspaceRoot) {
|
||||
$workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $shared.scriptDir)
|
||||
$workspaceRoot = $workspaceRoots[0]
|
||||
}
|
||||
elseif ($shared.PSObject.Properties['npmWorkspaceRoot'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.npmWorkspaceRoot)) {
|
||||
$workspaceRoot = [string]$shared.npmWorkspaceRoot
|
||||
}
|
||||
else {
|
||||
throw "NpmPack plugin requires 'workspaceRoot' or a prior NpmReleaseVersion plugin run."
|
||||
}
|
||||
|
||||
$artifactsDirectory = $null
|
||||
if ($pluginSettings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.artifactsDir)) {
|
||||
$artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $shared.scriptDir ([string]$pluginSettings.artifactsDir)))
|
||||
Set-EngineState -Context $shared -Name 'artifactsDirectory' -Value $artifactsDirectory
|
||||
}
|
||||
elseif ($shared.PSObject.Properties['artifactsDirectory'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.artifactsDirectory)) {
|
||||
$artifactsDirectory = [string]$shared.artifactsDirectory
|
||||
}
|
||||
else {
|
||||
throw "NpmPack plugin requires release-stage artifactsDirectory."
|
||||
}
|
||||
|
||||
$packOrder = @()
|
||||
if ($pluginSettings.publishOrder) {
|
||||
if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) {
|
||||
$packOrder = @($pluginSettings.publishOrder | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
|
||||
}
|
||||
elseif (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.publishOrder)) {
|
||||
$packOrder = @([string]$pluginSettings.publishOrder)
|
||||
}
|
||||
}
|
||||
elseif ($pluginSettings.packOrder) {
|
||||
if ($pluginSettings.packOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.packOrder -is [string])) {
|
||||
$packOrder = @($pluginSettings.packOrder | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
|
||||
}
|
||||
elseif (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.packOrder)) {
|
||||
$packOrder = @([string]$pluginSettings.packOrder)
|
||||
}
|
||||
}
|
||||
|
||||
if ($packOrder.Count -eq 0) {
|
||||
throw "NpmPack plugin requires non-empty 'publishOrder' or 'packOrder' (workspace package names)."
|
||||
}
|
||||
|
||||
Import-Module (Join-Path $PSScriptRoot 'NpmPackageSupport.psm1') -Force
|
||||
$useWorkspaces = Test-NpmWorkspacesConfigured -WorkspaceRoot $workspaceRoot
|
||||
if (-not $useWorkspaces -and $packOrder.Count -gt 1) {
|
||||
throw "NpmPack plugin requires npm workspaces when packing more than one package."
|
||||
}
|
||||
|
||||
if (-not (Test-Path $artifactsDirectory -PathType Container)) {
|
||||
New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null
|
||||
}
|
||||
|
||||
$releaseAssetPaths = @()
|
||||
|
||||
Push-Location $workspaceRoot
|
||||
try {
|
||||
foreach ($packageName in $packOrder) {
|
||||
Write-Log -Level "STEP" -Message "Packing npm package '$packageName'..."
|
||||
if ($useWorkspaces) {
|
||||
$tarballName = (npm pack -w $packageName --pack-destination $artifactsDirectory 2>$null | Select-Object -Last 1)
|
||||
}
|
||||
else {
|
||||
Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName
|
||||
$tarballName = (npm pack --pack-destination $artifactsDirectory 2>$null | Select-Object -Last 1)
|
||||
}
|
||||
|
||||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace([string]$tarballName)) {
|
||||
throw "npm pack failed for '$packageName'."
|
||||
}
|
||||
|
||||
$tarballPath = Join-Path $artifactsDirectory ([string]$tarballName).Trim()
|
||||
if (-not (Test-Path $tarballPath -PathType Leaf)) {
|
||||
throw "Could not locate pack output for '$packageName' at: $tarballPath"
|
||||
}
|
||||
|
||||
$strayTarballPath = Join-Path $workspaceRoot ([string]$tarballName).Trim()
|
||||
if ((Test-Path $strayTarballPath -PathType Leaf) -and $strayTarballPath -ne $tarballPath) {
|
||||
Remove-Item -LiteralPath $strayTarballPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Log -Level "OK" -Message " Package ready: $tarballPath"
|
||||
$releaseAssetPaths += $tarballPath
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Set-EngineState -Context $shared -Name 'releaseDir' -Value $artifactsDirectory
|
||||
Set-EngineFact -Context $shared -Namespace 'release' -Name 'assetPaths' -Value $releaseAssetPaths -Overwrite Replace -LegacyProperty 'releaseAssetPaths'
|
||||
if ($releaseAssetPaths.Count -gt 0) {
|
||||
$packageItem = Get-Item -LiteralPath $releaseAssetPaths[0]
|
||||
Set-EngineFact -Context $shared -Namespace 'npm' -Name 'packageFile' -Value $packageItem -Overwrite Replace -LegacyProperty 'packageFile'
|
||||
}
|
||||
|
||||
Write-Log -Level "OK" -Message " npm pack completed ($($releaseAssetPaths.Count) tarball(s))."
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Invoke-Plugin
|
||||
@ -1,62 +0,0 @@
|
||||
#requires -Version 7.0
|
||||
#requires -PSEdition Core
|
||||
|
||||
function Get-NpmRootPackageJson {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$WorkspaceRoot
|
||||
)
|
||||
|
||||
$packageJsonPath = Join-Path $WorkspaceRoot 'package.json'
|
||||
if (-not (Test-Path $packageJsonPath -PathType Leaf)) {
|
||||
throw "package.json not found at '$packageJsonPath'."
|
||||
}
|
||||
|
||||
return Get-Content -Path $packageJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Test-NpmWorkspacesConfigured {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$WorkspaceRoot
|
||||
)
|
||||
|
||||
$json = Get-NpmRootPackageJson -WorkspaceRoot $WorkspaceRoot
|
||||
if (-not $json.PSObject.Properties['workspaces'] -or $null -eq $json.workspaces) {
|
||||
return $false
|
||||
}
|
||||
|
||||
$workspaces = $json.workspaces
|
||||
if ($workspaces -is [string]) {
|
||||
return -not [string]::IsNullOrWhiteSpace($workspaces)
|
||||
}
|
||||
|
||||
if ($workspaces -is [System.Collections.IEnumerable]) {
|
||||
$entries = @($workspaces | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
|
||||
return $entries.Count -gt 0
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
function Assert-NpmRootPackageName {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$WorkspaceRoot,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ExpectedPackageName
|
||||
)
|
||||
|
||||
$json = Get-NpmRootPackageJson -WorkspaceRoot $WorkspaceRoot
|
||||
$rootPackageName = [string]$json.name
|
||||
if ([string]::IsNullOrWhiteSpace($rootPackageName)) {
|
||||
throw "Root package.json at '$WorkspaceRoot' is missing 'name'."
|
||||
}
|
||||
|
||||
if ($rootPackageName -ne $ExpectedPackageName) {
|
||||
throw "publishOrder package '$ExpectedPackageName' does not match root package name '$rootPackageName' (npm workspaces not configured)."
|
||||
}
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Test-NpmWorkspacesConfigured, Assert-NpmRootPackageName, Get-NpmRootPackageJson
|
||||
@ -1,158 +0,0 @@
|
||||
#requires -Version 7.0
|
||||
#requires -PSEdition Core
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Publishes npm workspace packages to the npm registry.
|
||||
|
||||
.DESCRIPTION
|
||||
Publishes packages in configured order using RepoUtilsSecrets slot Npm.
|
||||
Uses a temporary .npmrc in the workspace root.
|
||||
#>
|
||||
|
||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
||||
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
|
||||
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
|
||||
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Plugin {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
$Settings
|
||||
)
|
||||
|
||||
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
||||
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
|
||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
|
||||
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-ReleaseSemverPrereleaseLabel"
|
||||
|
||||
$pluginSettings = $Settings
|
||||
$shared = $Settings.context
|
||||
|
||||
$dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $shared
|
||||
|
||||
Assert-Command npm
|
||||
|
||||
$workspaceRoot = $null
|
||||
if ($pluginSettings.workspaceRoot) {
|
||||
$workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $shared.scriptDir)
|
||||
$workspaceRoot = $workspaceRoots[0]
|
||||
}
|
||||
elseif ($shared.PSObject.Properties['npmWorkspaceRoot'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.npmWorkspaceRoot)) {
|
||||
$workspaceRoot = [string]$shared.npmWorkspaceRoot
|
||||
}
|
||||
else {
|
||||
throw "NpmPublish plugin requires 'workspaceRoot' or a prior NpmReleaseVersion plugin run."
|
||||
}
|
||||
|
||||
$registry = if ([string]::IsNullOrWhiteSpace([string]$pluginSettings.registry)) {
|
||||
'https://registry.npmjs.org'
|
||||
}
|
||||
else {
|
||||
[string]$pluginSettings.registry
|
||||
}
|
||||
|
||||
$access = if ([string]::IsNullOrWhiteSpace([string]$pluginSettings.access)) {
|
||||
'public'
|
||||
}
|
||||
else {
|
||||
[string]$pluginSettings.access
|
||||
}
|
||||
|
||||
$npmDistTag = $null
|
||||
if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.npmDistTag)) {
|
||||
$npmDistTag = [string]$pluginSettings.npmDistTag
|
||||
}
|
||||
else {
|
||||
$npmDistTag = Get-ReleaseSemverPrereleaseLabel -Version ([string]$shared.version)
|
||||
}
|
||||
|
||||
$publishOrder = @()
|
||||
if ($pluginSettings.publishOrder) {
|
||||
if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) {
|
||||
$publishOrder = @($pluginSettings.publishOrder | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
|
||||
}
|
||||
elseif (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.publishOrder)) {
|
||||
$publishOrder = @([string]$pluginSettings.publishOrder)
|
||||
}
|
||||
}
|
||||
|
||||
if ($publishOrder.Count -eq 0) {
|
||||
throw "NpmPublish plugin requires non-empty 'publishOrder' (workspace package names)."
|
||||
}
|
||||
|
||||
$npmSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'npmSecret' -PluginDisplayName 'NpmPublish' -Required
|
||||
|
||||
Import-Module (Join-Path $PSScriptRoot 'NpmPackageSupport.psm1') -Force
|
||||
$useWorkspaces = Test-NpmWorkspacesConfigured -WorkspaceRoot $workspaceRoot
|
||||
if (-not $useWorkspaces -and $publishOrder.Count -gt 1) {
|
||||
throw "NpmPublish plugin requires npm workspaces when publishing more than one package."
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
foreach ($packageName in $publishOrder) {
|
||||
$tagNote = if ([string]::IsNullOrWhiteSpace($npmDistTag)) { 'latest' } else { $npmDistTag }
|
||||
Write-Log -Level "INFO" -Message "Dry run: would publish npm package '$packageName' to $registry (dist-tag $tagNote)"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
$npmToken = Get-RepoUtilsSecretSlot -Name $npmSecret -Settings $shared
|
||||
if ([string]::IsNullOrWhiteSpace($npmToken)) {
|
||||
throw "npm API key is not set. Set RepoUtilsSecrets slot '$npmSecret' (npmSecret)."
|
||||
}
|
||||
|
||||
$registryHost = ([uri]$registry).Host
|
||||
$tempNpmRcPath = Join-Path $workspaceRoot ".npmrc.release-temp"
|
||||
$npmRcContent = @"
|
||||
registry=$registry
|
||||
//$registryHost/:_authToken=$npmToken
|
||||
"@
|
||||
|
||||
Push-Location $workspaceRoot
|
||||
try {
|
||||
Set-Content -Path $tempNpmRcPath -Value $npmRcContent -Encoding UTF8 -NoNewline
|
||||
|
||||
foreach ($packageName in $publishOrder) {
|
||||
Write-Log -Level "STEP" -Message "Publishing npm package '$packageName'..."
|
||||
$publishArgs = @('publish')
|
||||
if ($useWorkspaces) {
|
||||
$publishArgs += @('-w', $packageName)
|
||||
}
|
||||
else {
|
||||
Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName
|
||||
}
|
||||
$publishArgs += @('--access', $access, '--userconfig', $tempNpmRcPath)
|
||||
if (-not [string]::IsNullOrWhiteSpace($npmDistTag)) {
|
||||
$publishArgs += @('--tag', $npmDistTag)
|
||||
Write-Log -Level "INFO" -Message " Using npm dist-tag '$npmDistTag' (prerelease)."
|
||||
}
|
||||
|
||||
npm @publishArgs
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Failed to publish npm package '$packageName'."
|
||||
}
|
||||
Write-Log -Level "OK" -Message " Published $packageName."
|
||||
}
|
||||
|
||||
Write-Log -Level "OK" -Message " npm publish completed."
|
||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Add-EnginePublishCompletion"
|
||||
Add-EnginePublishCompletion -Context $shared -Publisher 'NpmPublish'
|
||||
}
|
||||
finally {
|
||||
if (Test-Path $tempNpmRcPath -PathType Leaf) {
|
||||
Remove-Item -Path $tempNpmRcPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PluginMetadata {
|
||||
[pscustomobject]@{ mutatesRemote = $true }
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata
|
||||
@ -1,106 +0,0 @@
|
||||
#requires -Version 7.0
|
||||
#requires -PSEdition Core
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Loads npm workspace release version into shared context.
|
||||
|
||||
.DESCRIPTION
|
||||
Reads semver from the configured workspace package.json and writes it to
|
||||
shared context version. Optionally synchronizes version fields across
|
||||
workspace package manifests before build/publish.
|
||||
#>
|
||||
|
||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
||||
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
|
||||
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
|
||||
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PackageJsonVersionInternal {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$PackageJsonPath
|
||||
)
|
||||
|
||||
if (-not (Test-Path $PackageJsonPath -PathType Leaf)) {
|
||||
throw "NpmReleaseVersion: package.json not found at '$PackageJsonPath'."
|
||||
}
|
||||
|
||||
$json = Get-Content -Path $PackageJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$version = [string]$json.version
|
||||
if ([string]::IsNullOrWhiteSpace($version)) {
|
||||
throw "NpmReleaseVersion: 'version' is missing in '$PackageJsonPath'."
|
||||
}
|
||||
|
||||
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
|
||||
if (-not (Test-ReleaseSemver -Version $version)) {
|
||||
throw "NpmReleaseVersion: version '$version' in '$PackageJsonPath' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)."
|
||||
}
|
||||
|
||||
return $version
|
||||
}
|
||||
|
||||
function Set-PackageJsonVersionInternal {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$PackageJsonPath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Version
|
||||
)
|
||||
|
||||
$raw = Get-Content -Path $PackageJsonPath -Raw -Encoding UTF8
|
||||
$json = $raw | ConvertFrom-Json
|
||||
$json.version = $Version
|
||||
($json | ConvertTo-Json -Depth 100) + [Environment]::NewLine | Set-Content -Path $PackageJsonPath -Encoding UTF8 -NoNewline
|
||||
}
|
||||
|
||||
function Get-PluginMetadata {
|
||||
return [pscustomobject]@{ providesVersion = $true }
|
||||
}
|
||||
|
||||
function Invoke-Plugin {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
$Settings
|
||||
)
|
||||
|
||||
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState"
|
||||
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
|
||||
|
||||
$pluginSettings = $Settings
|
||||
$shared = $Settings.context
|
||||
|
||||
$packageJsonPaths = @(Resolve-RelativePaths -Value $pluginSettings.packageJsonPath -BasePath $shared.scriptDir)
|
||||
if ($packageJsonPaths.Count -eq 0) {
|
||||
throw "NpmReleaseVersion plugin requires 'packageJsonPath' in scriptSettings.json."
|
||||
}
|
||||
$packageJsonPath = $packageJsonPaths[0]
|
||||
|
||||
$version = Get-PackageJsonVersionInternal -PackageJsonPath $packageJsonPath
|
||||
$syncWorkspaceVersions = $false
|
||||
if ($null -ne $pluginSettings.syncWorkspaceVersions) {
|
||||
$syncWorkspaceVersions = [bool]$pluginSettings.syncWorkspaceVersions
|
||||
}
|
||||
|
||||
if ($syncWorkspaceVersions) {
|
||||
$workspaceRoot = Split-Path -Parent $packageJsonPath
|
||||
$packageManifests = Get-ChildItem -Path (Join-Path $workspaceRoot 'packages') -Recurse -Filter package.json -File -ErrorAction SilentlyContinue
|
||||
foreach ($manifest in $packageManifests) {
|
||||
Set-PackageJsonVersionInternal -PackageJsonPath $manifest.FullName -Version $version
|
||||
}
|
||||
Write-Log -Level "OK" -Message " Synchronized workspace package versions to $version."
|
||||
}
|
||||
|
||||
$npmWorkspaceRoot = Split-Path -Parent $packageJsonPath
|
||||
Set-EngineState -Context $shared -Name 'version' -Value $version
|
||||
Set-EngineFact -Context $shared -Namespace 'npm' -Name 'workspaceRoot' -Value $npmWorkspaceRoot -Overwrite Replace -LegacyProperty 'npmWorkspaceRoot'
|
||||
Set-EngineFact -Context $shared -Namespace 'npm' -Name 'packageJsonPath' -Value $packageJsonPath -Overwrite Replace -LegacyProperty 'npmPackageJsonPath'
|
||||
Write-Log -Level "OK" -Message " Release version loaded by NpmReleaseVersion plugin: $version"
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata
|
||||
78
utils/plugins/Platform/CollectCoverage.psm1
Normal file
78
utils/plugins/Platform/CollectCoverage.psm1
Normal file
@ -0,0 +1,78 @@
|
||||
#requires -Version 7.0
|
||||
#requires -PSEdition Core
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Collects coverage metrics from an existing test results directory.
|
||||
|
||||
.DESCRIPTION
|
||||
Stack-agnostic parser for recovered container or host test output. Auto-detects .NET
|
||||
Cobertura (coverage.cobertura.xml) or Jest (coverage-summary.json) and publishes the
|
||||
same shared-context keys as DotNetTest / NpmJestTest for QualityGate.
|
||||
Use after DockerContainerBuilder / PodmanContainerBuilder when tests run inside a container image.
|
||||
#>
|
||||
|
||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
||||
$pluginSupportModulePath = Join-Path $srcDir 'modules/Engine/PluginSupport.psm1'
|
||||
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
|
||||
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Plugin {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
$Settings
|
||||
)
|
||||
|
||||
Import-PluginDependency -ModuleName 'Logging' -RequiredCommand 'Write-Log'
|
||||
Import-PluginDependency -ModuleName 'TestRunner' -RequiredCommand 'Get-CoverageFromResultsDirectory'
|
||||
Import-PluginDependency -ModuleName 'TestRunner' -RequiredCommand 'Publish-CoverageMetricsToSharedContext'
|
||||
|
||||
$pluginSettings = $Settings
|
||||
$sharedSettings = $Settings.context
|
||||
$scriptDir = $sharedSettings.scriptDir
|
||||
|
||||
$resultsDirSetting = $pluginSettings.resultsDir
|
||||
if ([string]::IsNullOrWhiteSpace($resultsDirSetting)) {
|
||||
if ($sharedSettings.PSObject.Properties.Name -contains 'testResultsDirectory' -and $sharedSettings.testResultsDirectory) {
|
||||
$resultsDirSetting = $sharedSettings.testResultsDirectory
|
||||
}
|
||||
else {
|
||||
throw "CollectCoverage requires 'resultsDir' in plugin settings or testResultsDirectory on shared context (from ContainerBuilder)."
|
||||
}
|
||||
}
|
||||
|
||||
$resultsDirectory = if ([System.IO.Path]::IsPathRooted([string]$resultsDirSetting)) {
|
||||
[string]$resultsDirSetting
|
||||
}
|
||||
else {
|
||||
[System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$resultsDirSetting)))
|
||||
}
|
||||
|
||||
$format = 'auto'
|
||||
if ($pluginSettings.PSObject.Properties.Name -contains 'format' -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.format)) {
|
||||
$format = [string]$pluginSettings.format
|
||||
}
|
||||
|
||||
Write-Log -Level 'STEP' -Message "Collecting coverage from $resultsDirectory ..."
|
||||
$testResult = Get-CoverageFromResultsDirectory -ResultsDirectory $resultsDirectory -Format $format -Silent:$true
|
||||
if (-not $testResult.Success) {
|
||||
throw $testResult.Error
|
||||
}
|
||||
|
||||
Publish-CoverageMetricsToSharedContext -SharedSettings $sharedSettings -TestResult $testResult
|
||||
|
||||
$formatLabel = if ($testResult.CoverageFormat) { $testResult.CoverageFormat } else { 'unknown' }
|
||||
Write-Log -Level 'OK' -Message " Coverage collected ($formatLabel)."
|
||||
Write-Log -Level 'INFO' -Message " Line Coverage: $($testResult.LineRate)%"
|
||||
Write-Log -Level 'INFO' -Message " Branch Coverage: $($testResult.BranchRate)%"
|
||||
Write-Log -Level 'INFO' -Message " Method Coverage: $($testResult.MethodRate)%"
|
||||
}
|
||||
|
||||
function Get-PluginMetadata {
|
||||
[pscustomobject]@{ mutatesRemote = $false }
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata
|
||||
52
utils/plugins/Platform/ContainerEngineProbe.psm1
Normal file
52
utils/plugins/Platform/ContainerEngineProbe.psm1
Normal file
@ -0,0 +1,52 @@
|
||||
#requires -Version 7.0
|
||||
#requires -PSEdition Core
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Probes docker/podman availability and sets mandatory shared engine state for the pipeline.
|
||||
|
||||
.DESCRIPTION
|
||||
Must be the first enabled plugin. Sets containerEngineProbeCompleted, activeContainerEngine,
|
||||
dockerEngineAvailable, podmanEngineAvailable, and compose CLI fields on shared context.
|
||||
Fails the pipeline when no usable engine is found.
|
||||
#>
|
||||
|
||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
||||
$pluginSupportModulePath = Join-Path $srcDir 'modules/Engine/PluginSupport.psm1'
|
||||
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
|
||||
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Plugin {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
$Settings
|
||||
)
|
||||
|
||||
Import-PluginDependency -ModuleName 'Logging' -RequiredCommand 'Write-Log'
|
||||
Import-PluginDependency -ModuleName 'ContainerEngineSupport' -RequiredCommand 'Resolve-ActiveContainerEngineState'
|
||||
|
||||
$shared = $Settings.context
|
||||
|
||||
Write-Log -Level 'STEP' -Message 'Probing container engine availability...'
|
||||
$state = Resolve-ActiveContainerEngineState
|
||||
Set-ContainerEngineSharedState -SharedSettings $shared -State $state
|
||||
|
||||
$display = Get-ContainerEngineDisplayName -Engine (Get-SharedContainerEngineInvocation -SharedSettings $shared)
|
||||
Write-Log -Level 'OK' -Message " Active engine: $display"
|
||||
Write-Log -Level 'INFO' -Message " docker CLI available: $($state.DockerEngineAvailable); podman CLI available: $($state.PodmanEngineAvailable)"
|
||||
if ([string]::IsNullOrWhiteSpace([string]$state.ComposeExecutable)) {
|
||||
Write-Log -Level 'INFO' -Message ' Compose CLI: not available (Compose plugins will fail if enabled).'
|
||||
}
|
||||
else {
|
||||
Write-Log -Level 'INFO' -Message " Compose CLI: $($state.ComposeExecutable) $($state.ComposeBaseArgs -join ' ')"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PluginMetadata {
|
||||
[pscustomobject]@{ mutatesRemote = $false }
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata
|
||||
124
utils/plugins/Platform/DiscoverPackageArtifacts.psm1
Normal file
124
utils/plugins/Platform/DiscoverPackageArtifacts.psm1
Normal file
@ -0,0 +1,124 @@
|
||||
#requires -Version 7.0
|
||||
#requires -PSEdition Core
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Discovers package artifacts produced by pack plugins or container build pipelines.
|
||||
|
||||
.DESCRIPTION
|
||||
Stack-agnostic scanner for artifactsDir. Detects .NET .nupkg/.snupkg or npm .tgz files
|
||||
and populates shared context (packageFile, releaseAssetPaths, releaseArchiveInputs).
|
||||
#>
|
||||
|
||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
||||
$pluginSupportModulePath = Join-Path $srcDir 'modules/Engine/PluginSupport.psm1'
|
||||
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
|
||||
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Plugin {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
$Settings
|
||||
)
|
||||
|
||||
Import-PluginDependency -ModuleName 'Logging' -RequiredCommand 'Write-Log'
|
||||
Import-PluginDependency -ModuleName 'DotNetArtifactSupport' -RequiredCommand 'Resolve-DotNetPackageArtifacts'
|
||||
|
||||
$pluginSettings = $Settings
|
||||
$sharedSettings = $Settings.context
|
||||
$scriptDir = $sharedSettings.scriptDir
|
||||
$version = $sharedSettings.version
|
||||
|
||||
if ($Settings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$Settings.artifactsDir)) {
|
||||
$artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$Settings.artifactsDir)))
|
||||
if (Get-Command Set-EngineState -ErrorAction SilentlyContinue) {
|
||||
Set-EngineState -Context $sharedSettings -Name 'artifactsDirectory' -Value $artifactsDirectory
|
||||
Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory
|
||||
}
|
||||
else {
|
||||
$sharedSettings | Add-Member -NotePropertyName artifactsDirectory -NotePropertyValue $artifactsDirectory -Force
|
||||
$sharedSettings | Add-Member -NotePropertyName releaseDir -NotePropertyValue $artifactsDirectory -Force
|
||||
}
|
||||
}
|
||||
else {
|
||||
$artifactsDirectory = $sharedSettings.artifactsDirectory
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) {
|
||||
throw 'DiscoverPackageArtifacts requires artifactsDir in plugin settings or artifactsDirectory on shared context.'
|
||||
}
|
||||
|
||||
$nupkgCandidates = @(Get-ChildItem -Path $artifactsDirectory -Filter '*.nupkg' -ErrorAction SilentlyContinue | Where-Object {
|
||||
$_.Name -notlike '*.symbols.nupkg' -and $_.Name -notlike '*.snupkg'
|
||||
})
|
||||
$tgzCandidates = @(Get-ChildItem -Path $artifactsDirectory -Filter '*.tgz' -ErrorAction SilentlyContinue)
|
||||
|
||||
if ($nupkgCandidates.Count -gt 0 -and $tgzCandidates.Count -gt 0) {
|
||||
throw "DiscoverPackageArtifacts found both NuGet and npm artifacts in $artifactsDirectory. Use one package stack per repository."
|
||||
}
|
||||
|
||||
if ($nupkgCandidates.Count -gt 0) {
|
||||
if ([string]::IsNullOrWhiteSpace([string]$version)) {
|
||||
throw 'DiscoverPackageArtifacts requires release version on shared context when discovering .nupkg artifacts.'
|
||||
}
|
||||
|
||||
Write-Log -Level 'STEP' -Message "Discovering NuGet package artifacts in $artifactsDirectory ..."
|
||||
$resolved = Resolve-DotNetPackageArtifacts -ArtifactsDirectory $artifactsDirectory -Version $version
|
||||
|
||||
Write-Log -Level 'OK' -Message " Package ready: $($resolved.PackageFile.FullName)"
|
||||
if ($resolved.SymbolsPackageFile) {
|
||||
Write-Log -Level 'OK' -Message " Symbols package ready: $($resolved.SymbolsPackageFile.FullName)"
|
||||
}
|
||||
else {
|
||||
Write-Log -Level 'WARN' -Message " Symbols package (.snupkg) not found for version $version."
|
||||
}
|
||||
|
||||
$sharedSettings | Add-Member -NotePropertyName packageFile -NotePropertyValue $resolved.PackageFile -Force
|
||||
$sharedSettings | Add-Member -NotePropertyName symbolsPackageFile -NotePropertyValue $resolved.SymbolsPackageFile -Force
|
||||
$sharedSettings | Add-Member -NotePropertyName releaseArchiveInputs -NotePropertyValue $resolved.ReleaseArchiveInputs -Force
|
||||
$sharedSettings | Add-Member -NotePropertyName releaseAssetPaths -NotePropertyValue $resolved.ReleaseArchiveInputs -Force
|
||||
if (Get-Command Set-EngineFact -ErrorAction SilentlyContinue) {
|
||||
Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -Value $resolved.PackageFile -Overwrite Replace -LegacyProperty 'packageFile'
|
||||
Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -Value $resolved.SymbolsPackageFile -Overwrite Replace -LegacyProperty 'symbolsPackageFile'
|
||||
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value $resolved.ReleaseArchiveInputs -Overwrite Replace -LegacyProperty 'releaseArchiveInputs'
|
||||
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'assetPaths' -Value $resolved.ReleaseArchiveInputs -Overwrite Replace -LegacyProperty 'releaseAssetPaths'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if ($tgzCandidates.Count -gt 0) {
|
||||
Import-PluginDependency -ModuleName 'NpmArtifactSupport' -RequiredCommand 'Resolve-NpmPackageArtifacts'
|
||||
|
||||
Write-Log -Level 'STEP' -Message "Discovering npm package artifacts in $artifactsDirectory ..."
|
||||
$resolved = Resolve-NpmPackageArtifacts -ArtifactsDirectory $artifactsDirectory -Version $version
|
||||
|
||||
foreach ($tarball in $resolved.PackageFiles) {
|
||||
Write-Log -Level 'OK' -Message " Package ready: $($tarball.FullName)"
|
||||
}
|
||||
|
||||
$sharedSettings | Add-Member -NotePropertyName packageFile -NotePropertyValue $resolved.PackageFile -Force
|
||||
$sharedSettings | Add-Member -NotePropertyName releaseArchiveInputs -NotePropertyValue $resolved.ReleaseArchiveInputs -Force
|
||||
$sharedSettings | Add-Member -NotePropertyName releaseAssetPaths -NotePropertyValue $resolved.ReleaseAssetPaths -Force
|
||||
$sharedSettings | Add-Member -NotePropertyName releaseDir -NotePropertyValue $artifactsDirectory -Force
|
||||
if (Get-Command Set-EngineFact -ErrorAction SilentlyContinue) {
|
||||
Set-EngineFact -Context $sharedSettings -Namespace 'npm' -Name 'packageFile' -Value $resolved.PackageFile -Overwrite Replace -LegacyProperty 'packageFile'
|
||||
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value $resolved.ReleaseArchiveInputs -Overwrite Replace -LegacyProperty 'releaseArchiveInputs'
|
||||
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'assetPaths' -Value $resolved.ReleaseAssetPaths -Overwrite Replace -LegacyProperty 'releaseAssetPaths'
|
||||
if (Get-Command Set-EngineState -ErrorAction SilentlyContinue) {
|
||||
Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
throw "DiscoverPackageArtifacts found no .nupkg or .tgz files in: $artifactsDirectory"
|
||||
}
|
||||
|
||||
function Get-PluginMetadata {
|
||||
[pscustomobject]@{ mutatesRemote = $false }
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata
|
||||
Loading…
Reference in New Issue
Block a user