(feature): Breadcrumb and RepoUtils update
Some checks failed
Storybook tests / storybook-tests (push) Has been cancelled

This commit is contained in:
Maksym Sadovnychyy 2026-08-06 04:50:32 +02:00
parent eb9e8b9af0
commit ecce33e42f
22 changed files with 386 additions and 522 deletions

View File

@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.4.3] - 2026-08-06
### Added
- `Breadcrumb` — presentational page trail (`nav` + `ol`); injectable `linkComponent` (e.g. react-router `Link`); last item is current page (`aria-current`). Export: `Breadcrumb`, `BreadcrumbProps`, `BreadcrumbItem`. Storybook coverage included.
### Changed
- RepoUtils: removed `Update-RepoUtils` tool (local-copy sync only); engine/plugin/module refinements; added `utils/templates/` CI Docker/build helpers.
## [0.4.2] - 2026-07-28 ## [0.4.2] - 2026-07-28
### Added ### Added

View File

@ -44,7 +44,7 @@ Configured plugins (see `utils\engines\release\scriptSettings.json`):
| `GitHub` | GitHub release (optional; set `GitHub` env var) | | `GitHub` | GitHub release (optional; set `GitHub` env var) |
| `NpmPublish` | Publish `@maks-it.com/webui` | | `NpmPublish` | Publish `@maks-it.com/webui` |
Refresh shared utils from repoutils: **`utils\Update-RepoUtils.bat`**. Refresh shared utils from **maksit-repoutils** via local-copy sync (no Update-RepoUtils in product repos).
## Consume in product repos ## Consume in product repos

View File

@ -0,0 +1,119 @@
import {
type ComponentType,
type FC,
type ReactNode,
} from 'react'
export interface BreadcrumbItem {
label: ReactNode
/** Used by default `<a>` and as fallback for router links. */
href?: string
/** Preferred when injecting react-router `Link`. */
to?: string
/** Extra props passed to the injected link component. */
linkProps?: Record<string, unknown>
}
type BreadcrumbLinkComponent = ComponentType<{
href?: string
to?: string
className?: string
children?: ReactNode
[key: string]: unknown
}>
export interface BreadcrumbProps {
items: BreadcrumbItem[]
/** Defaults to `"/"`. */
separator?: ReactNode
className?: string
linkClassName?: string
currentClassName?: string
separatorClassName?: string
/** Host injects `Link` from react-router (or any anchor-like component). Defaults to `<a>`. */
linkComponent?: BreadcrumbLinkComponent
/** Accessible name for the nav landmark. Defaults to `"Breadcrumb"`. */
label?: string
}
const DefaultLink: BreadcrumbLinkComponent = ({
href,
to,
children,
...rest
}) => (
<a href={href ?? to} {...rest}>
{children}
</a>
)
/**
* Presentational page trail. Use links/`span` only never headings.
* Keep a single page `h1` on `FormHeader` or the page title.
*/
const Breadcrumb: FC<BreadcrumbProps> = ({
items,
separator = '/',
className = '',
linkClassName = 'text-sky-700 hover:text-sky-900 hover:underline',
currentClassName = 'text-gray-700',
separatorClassName = 'text-gray-400',
linkComponent: LinkComponent = DefaultLink,
label = 'Breadcrumb',
}) => {
if (items.length === 0)
return null
return (
<nav
aria-label={label}
className={['text-sm', className].filter(Boolean).join(' ')}
>
<ol className={'flex flex-wrap items-center gap-x-2 gap-y-1'}>
{items.map((item, index) => {
const isLast = index === items.length - 1
const target = item.to ?? item.href
const isLink = !isLast && Boolean(target)
return (
<li
key={index}
className={'inline-flex items-center gap-x-2'}
>
{index > 0 ? (
<span
className={separatorClassName}
aria-hidden={'true'}
>
{separator}
</span>
) : null}
{isLink ? (
<LinkComponent
href={item.href ?? (typeof item.to === 'string' ? item.to : undefined)}
to={item.to ?? item.href}
className={linkClassName}
{...(item.linkProps ?? {})}
>
{item.label}
</LinkComponent>
) : (
<span
className={currentClassName}
{...(isLast ? { 'aria-current': 'page' as const } : {})}
>
{item.label}
</span>
)}
</li>
)
})}
</ol>
</nav>
)
}
export {
Breadcrumb
}

View File

@ -0,0 +1,2 @@
export { Breadcrumb } from './Breadcrumb'
export type { BreadcrumbProps, BreadcrumbItem } from './Breadcrumb'

View File

@ -3,6 +3,8 @@ export { FieldContainer } from './components/editors/FieldContainer'
export { SecretComponent } from './components/editors/SecretComponent' export { SecretComponent } from './components/editors/SecretComponent'
export type { SecretDataSource, SecretComponentProps } from './components/editors/SecretComponent' export type { SecretDataSource, SecretComponentProps } from './components/editors/SecretComponent'
export { FormContainer, FormContent, FormFooter, FormHeader } from './components/FormLayout' export { FormContainer, FormContent, FormFooter, FormHeader } from './components/FormLayout'
export { Breadcrumb } from './components/Breadcrumb'
export type { BreadcrumbProps, BreadcrumbItem } from './components/Breadcrumb'
export { Offcanvas } from './components/Offcanvas' export { Offcanvas } from './components/Offcanvas'
export { Modal, ConfirmDialog } from './components/Modal' export { Modal, ConfirmDialog } from './components/Modal'
export type { ModalProps, ModalSize, ConfirmDialogProps } from './components/Modal' export type { ModalProps, ModalSize, ConfirmDialogProps } from './components/Modal'

4
src/package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "@maks-it.com/webui", "name": "@maks-it.com/webui",
"version": "0.4.2", "version": "0.4.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@maks-it.com/webui", "name": "@maks-it.com/webui",
"version": "0.4.2", "version": "0.4.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"date-fns": "^4.4.0" "date-fns": "^4.4.0"

View File

@ -1,6 +1,6 @@
{ {
"name": "@maks-it.com/webui", "name": "@maks-it.com/webui",
"version": "0.4.2", "version": "0.4.3",
"description": "Shared contracts, utilities, and React components for MaksIT WebUI apps", "description": "Shared contracts, utilities, and React components for MaksIT WebUI apps",
"type": "module", "type": "module",
"main": "./dist/index.cjs", "main": "./dist/index.cjs",

View File

@ -0,0 +1,79 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect, within } from 'storybook/test'
import { Breadcrumb } from '@webui/components/components/Breadcrumb'
import { FormHeader } from '@webui/components/components/FormLayout/FormHeader'
const meta = {
title: 'components/Breadcrumb',
component: Breadcrumb,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Presentational page trail (`nav` + `ol`). Never uses headings — keep a single page `h1` on `FormHeader` or the page title. Inject `linkComponent` (e.g. react-router `Link`) for SPA navigation.',
},
},
},
} satisfies Meta<typeof Breadcrumb>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
items: [
{ label: 'Admin', to: '/admin' },
{ label: 'Shop', to: '/admin/shop' },
{ label: 'Edit item' },
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const nav = canvas.getByRole('navigation', { name: /breadcrumb/i })
await expect(nav).toBeVisible()
await expect(within(nav).getByRole('link', { name: 'Admin' })).toHaveAttribute('href', '/admin')
await expect(within(nav).getByRole('link', { name: 'Shop' })).toHaveAttribute('href', '/admin/shop')
await expect(within(nav).getByText('Edit item')).toHaveAttribute('aria-current', 'page')
},
}
export const SingleItem: Story = {
args: {
items: [{ label: 'Dashboard' }],
},
}
export const WithFormHeader: Story = {
render: () => (
<div className="space-y-0 border border-gray-200 bg-white">
<div className="bg-gray-50 px-4 py-2">
<Breadcrumb
items={[
{ label: 'Admin', to: '/admin' },
{ label: 'Shop', to: '/admin/shop' },
{ label: 'Edit item' },
]}
/>
</div>
<FormHeader>Edit shop item</FormHeader>
<div className="p-4 text-sm text-gray-600">Form content</div>
</div>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await expect(canvas.getByRole('navigation', { name: /breadcrumb/i })).toBeVisible()
await expect(canvas.getByRole('heading', { level: 1, name: 'Edit shop item' })).toBeVisible()
},
}
export const CustomSeparator: Story = {
args: {
separator: '',
items: [
{ label: 'Shop', to: '/shop' },
{ label: 'Parent product', to: '/shop/parent' },
{ label: 'Gallery image' },
],
},
}

View File

@ -1,3 +0,0 @@
@echo off
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\Update-RepoUtils\Update-RepoUtils.ps1" %*
pause

View File

@ -80,7 +80,7 @@ else {
} }
} }
$pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $PSScriptRoot -ContinueOnError:$false $pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $PSScriptRoot
if (-not $pluginSucceeded) { if (-not $pluginSucceeded) {
$releaseHadPluginFailures = $true $releaseHadPluginFailures = $true
break break

View File

@ -6,6 +6,8 @@
Plugin-driven test and coverage engine entry script. Plugin-driven test and coverage engine entry script.
#> #>
$ErrorActionPreference = 'Stop'
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$srcDir = (Resolve-Path (Join-Path $scriptDir '..\..')).Path $srcDir = (Resolve-Path (Join-Path $scriptDir '..\..')).Path
@ -29,7 +31,7 @@ if ($configuredPlugins.Count -eq 0) {
$testHadPluginFailures = $false $testHadPluginFailures = $false
foreach ($plugin in $configuredPlugins) { foreach ($plugin in $configuredPlugins) {
$pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $engineContext -EngineDirectory $scriptDir -ContinueOnError:$false $pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $engineContext -EngineDirectory $scriptDir
if (-not $pluginSucceeded) { if (-not $pluginSucceeded) {
$testHadPluginFailures = $true $testHadPluginFailures = $true
break break

View File

@ -22,7 +22,7 @@ function Test-IsEngineRuntimeModuleName {
[string]$ModuleName [string]$ModuleName
) )
# Engine runtime under modules/ only — never dual-homed under plugins/. # Host engine runtime under modules/ (and optional modules/Extensions/) — never dual-homed under plugins/.
$engineNames = [System.Collections.Generic.HashSet[string]]::new( $engineNames = [System.Collections.Generic.HashSet[string]]::new(
[string[]]@( [string[]]@(
'ChangelogSupport', 'ChangelogSupport',
@ -34,11 +34,7 @@ function Test-IsEngineRuntimeModuleName {
'EngineContext', 'EngineContext',
'PluginSupport', 'PluginSupport',
'ReleaseSupport', 'ReleaseSupport',
'TestSupport', 'TestSupport'
'DeployConfig',
'EngineContextSupport',
'OrchestratorSupport',
'PluginPathSupport'
), ),
[System.StringComparer]::OrdinalIgnoreCase [System.StringComparer]::OrdinalIgnoreCase
) )
@ -46,6 +42,34 @@ function Test-IsEngineRuntimeModuleName {
return $engineNames.Contains($ModuleName) return $engineNames.Contains($ModuleName)
} }
function Get-PluginDependencyGroupDirectories {
param(
[Parameter(Mandatory = $true)]
[string]$PluginsRoot
)
if (-not (Test-Path -LiteralPath $PluginsRoot -PathType Container)) {
return @()
}
# Prefer Shared (helpers), then stock host groups; any other plugins/{Group}/ is discovered.
$preferred = @('Shared', 'Platform', 'DotNet', 'Npm')
$dirs = [System.Collections.Generic.List[string]]::new()
foreach ($name in $preferred) {
$path = Join-Path $PluginsRoot $name
if (Test-Path -LiteralPath $path -PathType Container) {
$dirs.Add($path)
}
}
Get-ChildItem -LiteralPath $PluginsRoot -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -notin $preferred } |
Sort-Object Name |
ForEach-Object { $dirs.Add($_.FullName) }
return @($dirs)
}
function Import-PluginDependency { function Import-PluginDependency {
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
@ -66,16 +90,16 @@ function Import-PluginDependency {
$candidatePaths = [System.Collections.Generic.List[string]]::new() $candidatePaths = [System.Collections.Generic.List[string]]::new()
if (Test-IsEngineRuntimeModuleName -ModuleName $ModuleName) { if (Test-IsEngineRuntimeModuleName -ModuleName $ModuleName) {
# Engine runtime: modules/ only (no plugins/ fallback). # Engine runtime: modules/ only (no plugins/ fallback). Optional Extensions/ for layered hosts.
$candidatePaths.Add((Join-Path $modulesDir "$ModuleName.psm1")) $candidatePaths.Add((Join-Path $modulesDir "$ModuleName.psm1"))
$candidatePaths.Add((Join-Path $engineModuleDir "$ModuleName.psm1")) $candidatePaths.Add((Join-Path $engineModuleDir "$ModuleName.psm1"))
$extensionsDir = Join-Path $modulesDir 'Extensions' $extensionsDir = Join-Path $modulesDir 'Extensions'
$candidatePaths.Add((Join-Path $extensionsDir "$ModuleName.psm1")) $candidatePaths.Add((Join-Path $extensionsDir "$ModuleName.psm1"))
} }
else { else {
# Plugin helpers: plugins/ only (no modules/ legacy shadow). # Plugin helpers: plugins/{Group}/ only (no modules/ legacy shadow). Groups are discovered.
foreach ($group in @('Shared', 'Platform', 'DotNet', 'Npm', 'Helm', 'Docker', 'Podman')) { foreach ($groupDir in Get-PluginDependencyGroupDirectories -PluginsRoot $pluginsRoot) {
$candidatePaths.Add((Join-Path (Join-Path $pluginsRoot $group) "$ModuleName.psm1")) $candidatePaths.Add((Join-Path $groupDir "$ModuleName.psm1"))
} }
} }
@ -344,8 +368,8 @@ function Get-RegistryCredentialsFromRuntime {
.DESCRIPTION .DESCRIPTION
Looks up the environment variable named by SecretName. The value must be Looks up the environment variable named by SecretName. The value must be
Base64(UTF8('username:password')). Used by Docker/Podman/Helm registry login Base64(UTF8('username:password')). Used by registry login and image-pull
and image-pull secret creation never pass the password itself as a parameter. secret creation never pass the password itself as a parameter.
.PARAMETER SecretName .PARAMETER SecretName
Logical secret name (environment variable name), not a password or token. Logical secret name (environment variable name), not a password or token.
@ -671,10 +695,7 @@ function Invoke-ConfiguredPlugin {
[psobject]$SharedSettings, [psobject]$SharedSettings,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$EngineDirectory, [string]$EngineDirectory
[Parameter(Mandatory = $false)]
[bool]$ContinueOnError = $false
) )
if (-not (Test-PluginRunnable -Plugin $Plugin -SharedSettings $SharedSettings -EngineDirectory $EngineDirectory -WriteLogs:$true)) { if (-not (Test-PluginRunnable -Plugin $Plugin -SharedSettings $SharedSettings -EngineDirectory $EngineDirectory -WriteLogs:$true)) {

View File

@ -1,6 +1,16 @@
#requires -Version 7.0 #requires -Version 7.0
#requires -PSEdition Core #requires -PSEdition Core
<#
Runs native CLIs (dotnet, git, helm, ) and keeps $LASTEXITCODE intact.
By default throws on non-zero exit so callers cannot forget to check.
Pass -ThrowOnError:$false when you need the exit code / output yourself
(e.g. TestRunner Success objects, logging full container build output first).
Test hooks: Set-ExternalCommandTestHandler / Set-ExternalCommandAvailability
let Pester stub CLIs without touching PATH.
#>
$script:ExternalCommandTestHandler = $null $script:ExternalCommandTestHandler = $null
$script:ExternalCommandAvailability = @{} $script:ExternalCommandAvailability = @{}
@ -40,7 +50,10 @@ function Invoke-ExternalCommand {
[string]$InputObject, [string]$InputObject,
[switch]$MergeErrorOutput [switch]$MergeErrorOutput,
# Default true: fail fast. Soft callers (tests, nested loggers) pass $false.
[bool]$ThrowOnError = $true
) )
$previousLocation = $null $previousLocation = $null
@ -51,6 +64,7 @@ function Invoke-ExternalCommand {
try { try {
$effectiveWorkingDirectory = (Get-Location).Path $effectiveWorkingDirectory = (Get-Location).Path
$output = @()
if ($null -ne $script:ExternalCommandTestHandler) { if ($null -ne $script:ExternalCommandTestHandler) {
$handlerResult = & $script:ExternalCommandTestHandler ` $handlerResult = & $script:ExternalCommandTestHandler `
@ -62,31 +76,46 @@ function Invoke-ExternalCommand {
$global:LASTEXITCODE = [int]$handlerResult.ExitCode $global:LASTEXITCODE = [int]$handlerResult.ExitCode
if ($null -eq $handlerResult.Output) { if ($null -eq $handlerResult.Output) {
return @() $output = @()
} }
elseif ($handlerResult.Output -is [System.Collections.IEnumerable] -and -not ($handlerResult.Output -is [string])) {
if ($handlerResult.Output -is [System.Collections.IEnumerable] -and -not ($handlerResult.Output -is [string])) { $output = @($handlerResult.Output)
return @($handlerResult.Output) }
else {
$output = @([string]$handlerResult.Output)
} }
return @([string]$handlerResult.Output)
}
if ($script:ExternalCommandAvailability.ContainsKey($Name) -and -not $script:ExternalCommandAvailability[$Name]) {
throw "External command '$Name' is marked unavailable."
}
if (-not [string]::IsNullOrWhiteSpace($InputObject)) {
$output = $InputObject | & $Name @ArgumentList 2>&1
}
elseif ($MergeErrorOutput) {
$output = & $Name @ArgumentList 2>&1
} }
else { else {
$output = & $Name @ArgumentList if ($script:ExternalCommandAvailability.ContainsKey($Name) -and -not $script:ExternalCommandAvailability[$Name]) {
throw "External command '$Name' is marked unavailable."
}
if (-not [string]::IsNullOrWhiteSpace($InputObject)) {
$raw = $InputObject | & $Name @ArgumentList 2>&1
}
elseif ($MergeErrorOutput) {
$raw = & $Name @ArgumentList 2>&1
}
else {
$raw = & $Name @ArgumentList
}
$output = @($raw)
} }
return @($output) $exitCode = [int]$global:LASTEXITCODE
if ($ThrowOnError -and $exitCode -ne 0) {
$preview = ($output | ForEach-Object {
if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.ToString() } else { [string]$_ }
} | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 8) -join ' '
if ([string]::IsNullOrWhiteSpace($preview)) {
throw "External command '$Name' failed with exit code $exitCode."
}
throw "External command '$Name' failed with exit code $exitCode. $preview"
}
return $output
} }
finally { finally {
if ($null -ne $previousLocation) { if ($null -ne $previousLocation) {

View File

@ -53,21 +53,18 @@ function Invoke-GitInternal {
if (-not (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue)) { if (-not (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path $PSScriptRoot -Parent $srcDir = Split-Path $PSScriptRoot -Parent
$externalCandidates = @( $externalModule = Join-Path $PSScriptRoot 'ExternalCommandSupport.psm1'
(Join-Path $PSScriptRoot 'ExternalCommandSupport.psm1'), if (Test-Path -LiteralPath $externalModule -PathType Leaf) {
(Join-Path $srcDir 'plugins' 'Shared' 'ExternalCommandSupport.psm1') Import-Module $externalModule -Global
) }
foreach ($externalModule in $externalCandidates) { elseif (Test-Path -LiteralPath (Join-Path $srcDir 'modules' 'ExternalCommandSupport.psm1') -PathType Leaf) {
if (Test-Path -LiteralPath $externalModule -PathType Leaf) { Import-Module (Join-Path $srcDir 'modules' 'ExternalCommandSupport.psm1') -Global
Import-Module $externalModule -Global
break
}
} }
} }
if ($CaptureOutput) { if ($CaptureOutput) {
if (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue) { if (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue) {
$output = Invoke-ExternalCommand -Name git -ArgumentList $Arguments -MergeErrorOutput $output = Invoke-ExternalCommand -Name git -ArgumentList $Arguments -MergeErrorOutput -ThrowOnError:$false
$exitCode = $LASTEXITCODE $exitCode = $LASTEXITCODE
if ($exitCode -ne 0) { if ($exitCode -ne 0) {
Write-Error "$ErrorMessage (exit code: $exitCode)" Write-Error "$ErrorMessage (exit code: $exitCode)"
@ -96,7 +93,7 @@ function Invoke-GitInternal {
} }
if (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue) { if (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue) {
Invoke-ExternalCommand -Name git -ArgumentList $Arguments -MergeErrorOutput | Out-Null Invoke-ExternalCommand -Name git -ArgumentList $Arguments -MergeErrorOutput -ThrowOnError:$false | Out-Null
} }
else { else {
& git @Arguments & git @Arguments

View File

@ -19,10 +19,8 @@ function Import-ExternalCommandSupportInternal {
return return
} }
$srcDir = Split-Path $PSScriptRoot -Parent
$candidates = @( $candidates = @(
(Join-Path $PSScriptRoot 'ExternalCommandSupport.psm1'), (Join-Path $PSScriptRoot 'ExternalCommandSupport.psm1')
(Join-Path $srcDir 'plugins' 'Shared' 'ExternalCommandSupport.psm1')
) )
foreach ($modulePath in $candidates) { foreach ($modulePath in $candidates) {
if (Test-Path -LiteralPath $modulePath -PathType Leaf) { if (Test-Path -LiteralPath $modulePath -PathType Leaf) {
@ -175,10 +173,10 @@ function Invoke-TestsWithCoverage {
Import-ExternalCommandSupportInternal Import-ExternalCommandSupportInternal
if ($Silent) { if ($Silent) {
$null = Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs -MergeErrorOutput $null = Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs -MergeErrorOutput -ThrowOnError:$false
} }
else { else {
Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs | Out-Default Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs -ThrowOnError:$false | Out-Default
} }
$testExitCode = $LASTEXITCODE $testExitCode = $LASTEXITCODE
@ -356,10 +354,10 @@ function Invoke-NpmJestTestsWithCoverage {
$npmArgs = @('run', $TestScript, '--', '--coverage', '--coverageReporters=json-summary', '--coverageReporters=text') $npmArgs = @('run', $TestScript, '--', '--coverage', '--coverageReporters=json-summary', '--coverageReporters=text')
Import-ExternalCommandSupportInternal Import-ExternalCommandSupportInternal
if ($Silent) { if ($Silent) {
$null = Invoke-ExternalCommand -Name npm -ArgumentList $npmArgs -MergeErrorOutput $null = Invoke-ExternalCommand -Name npm -ArgumentList $npmArgs -MergeErrorOutput -ThrowOnError:$false
} }
else { else {
Invoke-ExternalCommand -Name npm -ArgumentList $npmArgs | Out-Default Invoke-ExternalCommand -Name npm -ArgumentList $npmArgs -ThrowOnError:$false | Out-Default
} }
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {

View File

@ -6,7 +6,7 @@
Pester test plugin for the RepoUtils test engine. Pester test plugin for the RepoUtils test engine.
.DESCRIPTION .DESCRIPTION
Runs the community Pester suite and publishes normalized coverage metrics on the Runs the RepoUtils Pester suite and publishes normalized coverage metrics on the
shared engine context for QualityGate. shared engine context for QualityGate.
#> #>

View File

@ -0,0 +1,21 @@
# CI builder image for .NET NuGet libraries: test (Coverlet) + pack in one ENTRYPOINT.
#
# Setup in a product repo:
# 1. Copy to utils/engines/containerbuilder/{repo}-containerbuilder.Dockerfile
# 2. Customize test/pack .csproj paths below
# 3. Reference the same dockerfile from engines/test and engines/release scriptSettings
# 4. Disable host DotNetTest and DotNetPack; enable CollectCoverage + DiscoverPackageArtifacts (release)
#
# Source injection: ContainerBuilder copies sourceContextPath into WORKDIR (/src).
# - Repo root (sourceContextPath ..\..\..): use paths like src/YourProject/YourProject.csproj
# - src folder (sourceContextPath ..\..\..\src): use paths like YourProject/YourProject.csproj
#
# Reference: maksit-nats (utils/engines/containerbuilder/maksit-nats-containerbuilder.Dockerfile)
FROM mcr.microsoft.com/dotnet/sdk:10.0
WORKDIR /src
ENV ARTIFACTS_DIR=/artifacts
RUN mkdir -p /artifacts /testResults
# Option B: COPY build/ci-dotnet-pack.sh /usr/local/bin/ci-pack.sh && chmod +x ... && ENTRYPOINT ["/usr/local/bin/ci-pack.sh"]
ENTRYPOINT ["sh", "-c", "dotnet test path/to/YourProject.Tests/YourProject.Tests.csproj -c Release --nologo --collect:\"XPlat Code Coverage\" --results-directory /testResults && dotnet pack path/to/YourProject/YourProject.csproj -c Release -o /artifacts --nologo -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg"]

View File

@ -0,0 +1,22 @@
# CI builder image for npm libraries: ci, Jest coverage, build, and pack (.tgz).
#
# Setup in a product repo:
# 1. Copy to utils/engines/containerbuilder/{repo}-containerbuilder.Dockerfile
# 2. Customize WORKDIR (package root), testScript, and build script names
# 3. Reference the same dockerfile from engines/test and engines/release scriptSettings
# 4. Disable host NpmJestTest / NpmBuild / NpmPack when the container owns the pipeline
#
# Source injection: set sourceContextPath to the folder that contains package.json (often repo src/).
# Recovered paths: /artifacts (.tgz), /testResults (coverage/ for Jest json-summary).
#
# CollectCoverage reads recovered /testResults for QualityGate; DiscoverPackageArtifacts finds .tgz in /artifacts.
FROM node:24-bookworm-slim
WORKDIR /src
ENV ARTIFACTS_DIR=/artifacts
ENV NPM_CONFIG_FUND=false
ENV NPM_CONFIG_AUDIT=false
RUN mkdir -p /artifacts /testResults
# Option B: COPY build/ci-npm-pack.sh /usr/local/bin/ci-npm.sh && chmod +x ... && ENTRYPOINT ["/usr/local/bin/ci-npm.sh"]
ENTRYPOINT ["sh", "-c", "npm ci && npm run test:coverage -- --coverage --coverageReporters=json-summary --coverageReporters=text && cp -r coverage /testResults/ && npm run build && npm pack --pack-destination /artifacts"]

View File

@ -0,0 +1,11 @@
#!/bin/sh
set -eu
: "${ARTIFACTS_DIR:=/artifacts}"
mkdir -p "$ARTIFACTS_DIR" /testResults
# Customize test/pack .csproj paths (see templates/Dockerfile.ci.dotnet).
dotnet test path/to/YourProject.Tests/YourProject.Tests.csproj -c Release --nologo \
--collect:"XPlat Code Coverage" --results-directory /testResults
dotnet pack path/to/YourProject/YourProject.csproj -c Release -o "$ARTIFACTS_DIR" --nologo \
-p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg

View File

@ -0,0 +1,12 @@
#!/bin/sh
set -eu
: "${ARTIFACTS_DIR:=/artifacts}"
mkdir -p "$ARTIFACTS_DIR" /testResults
# Customize testScript / build script names for your package.json.
npm ci
npm run test:coverage -- --coverage --coverageReporters=json-summary --coverageReporters=text
cp -r coverage /testResults/
npm run build
npm pack --pack-destination "$ARTIFACTS_DIR"

View File

@ -1,439 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Refreshes a local maksit-repoutils copy from GitHub.
.DESCRIPTION
This script clones the configured repository into a temporary directory,
refreshes the parent directory of this script, preserves existing
scriptSettings.json files in subfolders, and copies the cloned source contents into that parent directory.
All configuration is stored in scriptSettings.json.
.EXAMPLE
pwsh -File .\Update-RepoUtils.ps1
.NOTES
CONFIGURATION (scriptSettings.json):
- dryRun: If true, logs the planned update without modifying files
- repository.url: Git repository to clone
- repository.sourceSubdirectory: Folder copied into the target directory
- repository.preserveFileName: Existing file name to preserve in subfolders
- repository.cloneDepth: Depth used for git clone
- repository.skippedRelativeDirectories: Relative directories to exclude from phase-two refresh (preserve dest)
- repository.omittedRelativeDirectories: Relative directories to delete from dest and never copy from source
(product repos: ["tests"] RepoUtils self-tests stay only in maksit-repoutils / enterprise)
#>
[CmdletBinding()]
param(
[switch]$ContinueAfterSelfUpdate,
[string]$TargetDirectoryOverride,
[string]$ClonedSourceDirectoryOverride,
[string]$TemporaryRootOverride
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$srcDir = Split-Path (Split-Path $scriptDir -Parent) -Parent
$modulesDir = Join-Path $srcDir 'modules'
# Refresh the src directory that contains modules, engines, plugins, and tools.
$targetDirectory = if ([string]::IsNullOrWhiteSpace($TargetDirectoryOverride)) {
$srcDir
}
else {
[System.IO.Path]::GetFullPath($TargetDirectoryOverride)
}
$currentScriptPath = [System.IO.Path]::GetFullPath($MyInvocation.MyCommand.Path)
$selfUpdateDirectory = [System.IO.Path]::Combine('tools', 'Update-RepoUtils')
function ConvertTo-NormalizedRelativePath {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
$normalizedPath = $Path.Replace('/', [System.IO.Path]::DirectorySeparatorChar).Replace('\', [System.IO.Path]::DirectorySeparatorChar)
return $normalizedPath.TrimStart('.', [System.IO.Path]::DirectorySeparatorChar).TrimEnd([System.IO.Path]::DirectorySeparatorChar)
}
function Test-IsInRelativeDirectory {
param(
[Parameter(Mandatory = $true)]
[string]$RelativePath,
[Parameter(Mandatory = $true)]
[string[]]$Directories
)
$normalizedRelativePath = ConvertTo-NormalizedRelativePath -Path $RelativePath
foreach ($directory in $Directories) {
$normalizedDirectory = ConvertTo-NormalizedRelativePath -Path $directory
if ([string]::IsNullOrWhiteSpace($normalizedDirectory)) {
continue
}
if (
$normalizedRelativePath.Equals($normalizedDirectory, [System.StringComparison]::OrdinalIgnoreCase) -or
$normalizedRelativePath.StartsWith($normalizedDirectory + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)
) {
return $true
}
}
return $false
}
function Get-ReleaseDeployPreserveFiles {
param(
[Parameter(Mandatory = $true)]
[string]$TargetDirectory
)
return @()
}
function Add-PreservedFileBackup {
param(
[Parameter(Mandatory = $true)]
[System.Collections.IList]$PreservedFiles,
[Parameter(Mandatory = $true)]
[System.IO.FileInfo]$File,
[Parameter(Mandatory = $true)]
[string]$TargetDirectory,
[Parameter(Mandatory = $true)]
[string]$TemporaryRoot,
[Parameter(Mandatory = $true)]
[bool]$DryRun
)
$relativePath = [System.IO.Path]::GetRelativePath($TargetDirectory, $File.FullName)
foreach ($existing in $PreservedFiles) {
if ($existing.RelativePath.Equals($relativePath, [System.StringComparison]::OrdinalIgnoreCase)) {
return
}
}
$backupPath = Join-Path $TemporaryRoot ("preserved-" + ($relativePath -replace '[\\/:*?""<>|]', '_'))
$PreservedFiles.Add([pscustomobject]@{
RelativePath = $relativePath
BackupPath = $backupPath
}) | Out-Null
if (-not $DryRun) {
$backupDirectory = Split-Path -Parent $backupPath
if (-not (Test-Path -Path $backupDirectory -PathType Container)) {
New-Item -ItemType Directory -Path $backupDirectory -Force | Out-Null
}
Copy-Item -Path $File.FullName -Destination $backupPath -Force
}
}
#region Import Modules
$scriptConfigModulePath = Join-Path $modulesDir "ScriptConfig.psm1"
if (-not (Test-Path $scriptConfigModulePath)) {
Write-Error "ScriptConfig module not found at: $scriptConfigModulePath"
exit 1
}
$loggingModulePath = Join-Path $modulesDir "Logging.psm1"
if (-not (Test-Path $loggingModulePath)) {
Write-Error "Logging module not found at: $loggingModulePath"
exit 1
}
Import-Module $scriptConfigModulePath -Force
Import-Module $loggingModulePath -Force
#endregion
#region Load Settings
$settings = Get-ScriptSettings -ScriptDir $scriptDir
#endregion
#region Configuration
$repositoryUrl = $settings.repository.url
$dryRun = if ($null -ne $settings.dryRun) { [bool]$settings.dryRun } else { $false }
$sourceSubdirectory = if ($settings.repository.sourceSubdirectory) { $settings.repository.sourceSubdirectory } else { 'src' }
$preserveFileName = if ($settings.repository.preserveFileName) { $settings.repository.preserveFileName } else { 'scriptSettings.json' }
$cloneDepth = if ($settings.repository.cloneDepth) { [int]$settings.repository.cloneDepth } else { 1 }
[string[]]$skippedRelativeDirectories = if ($settings.repository.skippedRelativeDirectories) {
@(
$settings.repository.skippedRelativeDirectories |
ForEach-Object {
ConvertTo-NormalizedRelativePath -Path ([string]$_)
}
)
}
else {
@(
[System.IO.Path]::Combine('engines', 'release', 'custom'),
[System.IO.Path]::Combine('engines', 'test', 'custom')
)
}
[string[]]$omittedRelativeDirectories = if ($settings.repository.omittedRelativeDirectories) {
@(
$settings.repository.omittedRelativeDirectories |
ForEach-Object {
ConvertTo-NormalizedRelativePath -Path ([string]$_)
} |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
)
}
else {
@()
}
#endregion
#region Validate CLI Dependencies
Assert-Command git
Assert-Command pwsh
if ([string]::IsNullOrWhiteSpace($repositoryUrl)) {
Write-Error "repository.url is required in scriptSettings.json."
exit 1
}
#endregion
#region Main
Write-Log -Level "INFO" -Message "========================================"
Write-Log -Level "INFO" -Message "Update RepoUtils Script"
Write-Log -Level "INFO" -Message "========================================"
Write-Log -Level "INFO" -Message "Target directory: $targetDirectory"
Write-Log -Level "INFO" -Message "Dry run: $dryRun"
$ownsTemporaryRoot = [string]::IsNullOrWhiteSpace($TemporaryRootOverride)
$temporaryRoot = if ($ownsTemporaryRoot) {
Join-Path ([System.IO.Path]::GetTempPath()) ("maksit-repoutils-update-" + [System.Guid]::NewGuid().ToString('N'))
}
else {
[System.IO.Path]::GetFullPath($TemporaryRootOverride)
}
try {
$clonedSourceDirectory = if ([string]::IsNullOrWhiteSpace($ClonedSourceDirectoryOverride)) {
Write-LogStep "Cloning latest repository snapshot..."
& git clone --depth $cloneDepth $repositoryUrl $temporaryRoot
if ($LASTEXITCODE -ne 0) {
throw "git clone failed with exit code $LASTEXITCODE."
}
Write-Log -Level "OK" -Message "Repository cloned"
Join-Path $temporaryRoot $sourceSubdirectory
}
else {
[System.IO.Path]::GetFullPath($ClonedSourceDirectoryOverride)
}
if (-not (Test-Path -Path $clonedSourceDirectory -PathType Container)) {
throw "The cloned repository does not contain the expected source directory: $clonedSourceDirectory"
}
if (-not $ContinueAfterSelfUpdate) {
if ($dryRun) {
Write-LogStep "Dry run self-update summary"
Write-Log -Level "INFO" -Message "Would refresh shared modules and $selfUpdateDirectory before relaunching the updater"
}
else {
Write-LogStep "Refreshing updater files..."
$selfUpdateFiles = Get-ChildItem -Path $clonedSourceDirectory -Recurse -Force -File |
Where-Object {
$relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $_.FullName)
$isRootFile = -not $relativePath.Contains([System.IO.Path]::DirectorySeparatorChar)
$isUpdaterFile = $relativePath.StartsWith($selfUpdateDirectory + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)
$_.Name -ne $preserveFileName -and
($isRootFile -or $isUpdaterFile)
}
foreach ($sourceFile in $selfUpdateFiles) {
$relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $sourceFile.FullName)
$destinationPath = Join-Path $targetDirectory $relativePath
$destinationDirectory = Split-Path -Parent $destinationPath
if (-not (Test-Path -Path $destinationDirectory -PathType Container)) {
New-Item -ItemType Directory -Path $destinationDirectory -Force | Out-Null
}
Copy-Item -Path $sourceFile.FullName -Destination $destinationPath -Force
}
Write-Log -Level "OK" -Message "Updater files refreshed"
}
if ($dryRun) {
Write-LogStep "Dry run bootstrap completed"
Write-Log -Level "INFO" -Message "Continuing with phase two in the current process because no files were changed"
}
else {
Write-LogStep "Relaunching the updated updater..."
& pwsh -File $currentScriptPath `
-ContinueAfterSelfUpdate `
-TargetDirectoryOverride $targetDirectory `
-ClonedSourceDirectoryOverride $clonedSourceDirectory `
-TemporaryRootOverride $temporaryRoot
if ($LASTEXITCODE -ne 0) {
throw "Relaunched updater failed with exit code $LASTEXITCODE."
}
Write-Log -Level "OK" -Message "Bootstrap phase completed"
return
}
}
$preservedFiles = [System.Collections.ArrayList]@()
[string[]]$updatePhaseSkippedDirectories = @($skippedRelativeDirectories) + $selfUpdateDirectory
$existingPreservedFiles = Get-ChildItem -Path $targetDirectory -Recurse -File -Filter $preserveFileName -ErrorAction SilentlyContinue
if ($existingPreservedFiles) {
foreach ($file in $existingPreservedFiles) {
Add-PreservedFileBackup -PreservedFiles $preservedFiles -File $file -TargetDirectory $targetDirectory -TemporaryRoot $temporaryRoot -DryRun $dryRun
}
Write-Log -Level "OK" -Message "Preserved $($existingPreservedFiles.Count) existing $preserveFileName file(s)"
}
else {
Write-Log -Level "WARN" -Message "No existing $preserveFileName files found in subfolders"
}
$releaseDeployPreserveFiles = @(Get-ReleaseDeployPreserveFiles -TargetDirectory $targetDirectory)
if ($releaseDeployPreserveFiles.Count -gt 0) {
foreach ($file in $releaseDeployPreserveFiles) {
Add-PreservedFileBackup -PreservedFiles $preservedFiles -File $file -TargetDirectory $targetDirectory -TemporaryRoot $temporaryRoot -DryRun $dryRun
}
Write-Log -Level "OK" -Message "Preserved $($releaseDeployPreserveFiles.Count) release deploy file(s)"
}
$preservedRelativePaths = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($preservedFile in $preservedFiles) {
[void]$preservedRelativePaths.Add($preservedFile.RelativePath)
}
if ($dryRun) {
Write-LogStep "Dry run summary"
Write-Log -Level "INFO" -Message "Would remove all files under target except preserved $preserveFileName and release deploy files"
Write-Log -Level "INFO" -Message "Would skip phase-two refresh for: $($updatePhaseSkippedDirectories -join ', ')"
if ($omittedRelativeDirectories.Count -gt 0) {
Write-Log -Level "INFO" -Message "Would omit (delete dest + skip copy): $($omittedRelativeDirectories -join ', ')"
}
Write-Log -Level "INFO" -Message "Would copy refreshed files from: $clonedSourceDirectory"
if ($preservedFiles.Count -gt 0) {
$preservedList = ($preservedFiles | ForEach-Object { $_.RelativePath }) -join ", "
Write-Log -Level "INFO" -Message "Would restore preserved files: $preservedList"
}
Write-Log -Level "OK" -Message "Dry run completed. No files were modified."
return
}
Write-LogStep "Cleaning target directory..."
$filesToRemove = Get-ChildItem -Path $targetDirectory -Recurse -Force -File |
Where-Object {
$relativePath = [System.IO.Path]::GetRelativePath($targetDirectory, $_.FullName)
$isInSkippedDirectory = Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $updatePhaseSkippedDirectories
$isInOmittedDirectory = ($omittedRelativeDirectories.Count -gt 0) -and
(Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $omittedRelativeDirectories)
$_.Name -ne $preserveFileName -and
-not $preservedRelativePaths.Contains($relativePath) -and
(-not $isInSkippedDirectory -or $isInOmittedDirectory)
}
foreach ($file in $filesToRemove) {
Remove-Item -Path $file.FullName -Force
}
$directoriesToRemove = Get-ChildItem -Path $targetDirectory -Recurse -Force -Directory |
Sort-Object { $_.FullName.Length } -Descending
foreach ($directory in $directoriesToRemove) {
$relativePath = [System.IO.Path]::GetRelativePath($targetDirectory, $directory.FullName)
$isInOmittedDirectory = ($omittedRelativeDirectories.Count -gt 0) -and
(Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $omittedRelativeDirectories)
if ((Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $updatePhaseSkippedDirectories) -and
-not $isInOmittedDirectory) {
continue
}
$remainingItems = Get-ChildItem -Path $directory.FullName -Force -ErrorAction SilentlyContinue
if (-not $remainingItems) {
Remove-Item -Path $directory.FullName -Force
}
}
Write-Log -Level "OK" -Message "Target directory cleaned"
Write-LogStep "Copying refreshed source files..."
$sourceFilesToCopy = Get-ChildItem -Path $clonedSourceDirectory -Recurse -Force -File |
Where-Object {
$relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $_.FullName)
$isInSkippedDirectory = Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $updatePhaseSkippedDirectories
$isInOmittedDirectory = ($omittedRelativeDirectories.Count -gt 0) -and
(Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $omittedRelativeDirectories)
-not $isInSkippedDirectory -and -not $isInOmittedDirectory
}
foreach ($sourceFile in $sourceFilesToCopy) {
$relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $sourceFile.FullName)
$destinationPath = Join-Path $targetDirectory $relativePath
$destinationDirectory = Split-Path -Parent $destinationPath
if (-not (Test-Path -Path $destinationDirectory -PathType Container)) {
New-Item -ItemType Directory -Path $destinationDirectory -Force | Out-Null
}
Copy-Item -Path $sourceFile.FullName -Destination $destinationPath -Force
}
foreach ($skippedDirectory in $updatePhaseSkippedDirectories) {
$skippedSourcePath = Join-Path $clonedSourceDirectory $skippedDirectory
if (Test-Path -Path $skippedSourcePath) {
Write-Log -Level "INFO" -Message "Skipped refresh for $skippedDirectory"
}
}
foreach ($omittedDirectory in $omittedRelativeDirectories) {
Write-Log -Level "INFO" -Message "Omitted (not shipped): $omittedDirectory"
}
Write-Log -Level "OK" -Message "Source files copied"
if ($preservedFiles.Count -gt 0) {
foreach ($preservedFile in $preservedFiles) {
if (-not (Test-Path -Path $preservedFile.BackupPath -PathType Leaf)) {
continue
}
$restorePath = Join-Path $targetDirectory $preservedFile.RelativePath
$restoreDirectory = Split-Path -Parent $restorePath
if (-not (Test-Path -Path $restoreDirectory -PathType Container)) {
New-Item -ItemType Directory -Path $restoreDirectory -Force | Out-Null
}
Copy-Item -Path $preservedFile.BackupPath -Destination $restorePath -Force
}
Write-Log -Level "OK" -Message "Preserved files restored ($($preservedFiles.Count))"
}
Write-Log -Level "OK" -Message "========================================"
Write-Log -Level "OK" -Message "Update completed successfully!"
Write-Log -Level "OK" -Message "========================================"
}
finally {
if ($ownsTemporaryRoot -and (Test-Path -Path $temporaryRoot)) {
Remove-Item -Path $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
#endregion

View File

@ -1,19 +0,0 @@
{
"$schema": "https://json-schema.org/draft-07/schema",
"title": "Update RepoUtils Script Settings",
"description": "Configuration for the Update-RepoUtils utility.",
"dryRun": false,
"repository": {
"url": "https://github.com/MAKS-IT-COM/maksit-repoutils.git",
"sourceSubdirectory": "src",
"preserveFileName": "scriptSettings.json",
"cloneDepth": 1,
"skippedRelativeDirectories": [
"engines/release/custom",
"engines/test/custom"
],
"omittedRelativeDirectories": [
"tests"
]
}
}