diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c30b92..7265893 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). +## [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 ### Added diff --git a/README.md b/README.md index f31dff6..53f8e2d 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Configured plugins (see `utils\engines\release\scriptSettings.json`): | `GitHub` | GitHub release (optional; set `GitHub` env var) | | `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 diff --git a/src/components/components/Breadcrumb/Breadcrumb.tsx b/src/components/components/Breadcrumb/Breadcrumb.tsx new file mode 100644 index 0000000..20a2205 --- /dev/null +++ b/src/components/components/Breadcrumb/Breadcrumb.tsx @@ -0,0 +1,119 @@ +import { + type ComponentType, + type FC, + type ReactNode, +} from 'react' + +export interface BreadcrumbItem { + label: ReactNode + /** Used by default `` 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 +} + +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 ``. */ + linkComponent?: BreadcrumbLinkComponent + /** Accessible name for the nav landmark. Defaults to `"Breadcrumb"`. */ + label?: string +} + +const DefaultLink: BreadcrumbLinkComponent = ({ + href, + to, + children, + ...rest +}) => ( + + {children} + +) + +/** + * Presentational page trail. Use links/`span` only — never headings. + * Keep a single page `h1` on `FormHeader` or the page title. + */ +const Breadcrumb: FC = ({ + 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 ( + + ) +} + +export { + Breadcrumb +} diff --git a/src/components/components/Breadcrumb/index.ts b/src/components/components/Breadcrumb/index.ts new file mode 100644 index 0000000..457ce2d --- /dev/null +++ b/src/components/components/Breadcrumb/index.ts @@ -0,0 +1,2 @@ +export { Breadcrumb } from './Breadcrumb' +export type { BreadcrumbProps, BreadcrumbItem } from './Breadcrumb' diff --git a/src/components/index.ts b/src/components/index.ts index d118ed7..d51b9a6 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -3,6 +3,8 @@ export { FieldContainer } from './components/editors/FieldContainer' export { SecretComponent } from './components/editors/SecretComponent' export type { SecretDataSource, SecretComponentProps } from './components/editors/SecretComponent' 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 { Modal, ConfirmDialog } from './components/Modal' export type { ModalProps, ModalSize, ConfirmDialogProps } from './components/Modal' diff --git a/src/package-lock.json b/src/package-lock.json index fcfacc3..a8acf1a 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@maks-it.com/webui", - "version": "0.4.2", + "version": "0.4.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@maks-it.com/webui", - "version": "0.4.2", + "version": "0.4.3", "license": "MIT", "dependencies": { "date-fns": "^4.4.0" diff --git a/src/package.json b/src/package.json index ee52498..b242d64 100644 --- a/src/package.json +++ b/src/package.json @@ -1,6 +1,6 @@ { "name": "@maks-it.com/webui", - "version": "0.4.2", + "version": "0.4.3", "description": "Shared contracts, utilities, and React components for MaksIT WebUI apps", "type": "module", "main": "./dist/index.cjs", diff --git a/src/stories/components/Breadcrumb.stories.tsx b/src/stories/components/Breadcrumb.stories.tsx new file mode 100644 index 0000000..c598f73 --- /dev/null +++ b/src/stories/components/Breadcrumb.stories.tsx @@ -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 + +export default meta +type Story = StoryObj + +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: () => ( +
+
+ +
+ Edit shop item +
Form content
+
+ ), + 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' }, + ], + }, +} diff --git a/utils/Update-RepoUtils.bat b/utils/Update-RepoUtils.bat deleted file mode 100644 index 048e3fb..0000000 --- a/utils/Update-RepoUtils.bat +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\Update-RepoUtils\Update-RepoUtils.ps1" %* -pause diff --git a/utils/engines/release/Invoke-ReleasePackage.ps1 b/utils/engines/release/Invoke-ReleasePackage.ps1 index 4ad9cbc..fde2b43 100644 --- a/utils/engines/release/Invoke-ReleasePackage.ps1 +++ b/utils/engines/release/Invoke-ReleasePackage.ps1 @@ -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) { $releaseHadPluginFailures = $true break diff --git a/utils/engines/test/Invoke-TestEngine.ps1 b/utils/engines/test/Invoke-TestEngine.ps1 index f4da98e..7b3eb90 100644 --- a/utils/engines/test/Invoke-TestEngine.ps1 +++ b/utils/engines/test/Invoke-TestEngine.ps1 @@ -6,6 +6,8 @@ Plugin-driven test and coverage engine entry script. #> +$ErrorActionPreference = 'Stop' + $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $srcDir = (Resolve-Path (Join-Path $scriptDir '..\..')).Path @@ -29,7 +31,7 @@ if ($configuredPlugins.Count -eq 0) { $testHadPluginFailures = $false 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) { $testHadPluginFailures = $true break diff --git a/utils/modules/Engine/PluginSupport.psm1 b/utils/modules/Engine/PluginSupport.psm1 index 8d8c482..e2cf85e 100644 --- a/utils/modules/Engine/PluginSupport.psm1 +++ b/utils/modules/Engine/PluginSupport.psm1 @@ -22,7 +22,7 @@ function Test-IsEngineRuntimeModuleName { [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( [string[]]@( 'ChangelogSupport', @@ -34,11 +34,7 @@ function Test-IsEngineRuntimeModuleName { 'EngineContext', 'PluginSupport', 'ReleaseSupport', - 'TestSupport', - 'DeployConfig', - 'EngineContextSupport', - 'OrchestratorSupport', - 'PluginPathSupport' + 'TestSupport' ), [System.StringComparer]::OrdinalIgnoreCase ) @@ -46,6 +42,34 @@ function Test-IsEngineRuntimeModuleName { 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 { param( [Parameter(Mandatory = $true)] @@ -66,16 +90,16 @@ function Import-PluginDependency { $candidatePaths = [System.Collections.Generic.List[string]]::new() 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 $engineModuleDir "$ModuleName.psm1")) $extensionsDir = Join-Path $modulesDir 'Extensions' $candidatePaths.Add((Join-Path $extensionsDir "$ModuleName.psm1")) } else { - # Plugin helpers: plugins/ only (no modules/ legacy shadow). - foreach ($group in @('Shared', 'Platform', 'DotNet', 'Npm', 'Helm', 'Docker', 'Podman')) { - $candidatePaths.Add((Join-Path (Join-Path $pluginsRoot $group) "$ModuleName.psm1")) + # Plugin helpers: plugins/{Group}/ only (no modules/ legacy shadow). Groups are discovered. + foreach ($groupDir in Get-PluginDependencyGroupDirectories -PluginsRoot $pluginsRoot) { + $candidatePaths.Add((Join-Path $groupDir "$ModuleName.psm1")) } } @@ -344,8 +368,8 @@ function Get-RegistryCredentialsFromRuntime { .DESCRIPTION Looks up the environment variable named by SecretName. The value must be - Base64(UTF8('username:password')). Used by Docker/Podman/Helm registry login - and image-pull secret creation — never pass the password itself as a parameter. + Base64(UTF8('username:password')). Used by registry login and image-pull + secret creation — never pass the password itself as a parameter. .PARAMETER SecretName Logical secret name (environment variable name), not a password or token. @@ -671,10 +695,7 @@ function Invoke-ConfiguredPlugin { [psobject]$SharedSettings, [Parameter(Mandatory = $true)] - [string]$EngineDirectory, - - [Parameter(Mandatory = $false)] - [bool]$ContinueOnError = $false + [string]$EngineDirectory ) if (-not (Test-PluginRunnable -Plugin $Plugin -SharedSettings $SharedSettings -EngineDirectory $EngineDirectory -WriteLogs:$true)) { diff --git a/utils/modules/ExternalCommandSupport.psm1 b/utils/modules/ExternalCommandSupport.psm1 index b6c05f1..6c86899 100644 --- a/utils/modules/ExternalCommandSupport.psm1 +++ b/utils/modules/ExternalCommandSupport.psm1 @@ -1,6 +1,16 @@ #requires -Version 7.0 #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:ExternalCommandAvailability = @{} @@ -40,7 +50,10 @@ function Invoke-ExternalCommand { [string]$InputObject, - [switch]$MergeErrorOutput + [switch]$MergeErrorOutput, + + # Default true: fail fast. Soft callers (tests, nested loggers) pass $false. + [bool]$ThrowOnError = $true ) $previousLocation = $null @@ -51,6 +64,7 @@ function Invoke-ExternalCommand { try { $effectiveWorkingDirectory = (Get-Location).Path + $output = @() if ($null -ne $script:ExternalCommandTestHandler) { $handlerResult = & $script:ExternalCommandTestHandler ` @@ -62,31 +76,46 @@ function Invoke-ExternalCommand { $global:LASTEXITCODE = [int]$handlerResult.ExitCode if ($null -eq $handlerResult.Output) { - return @() + $output = @() } - - if ($handlerResult.Output -is [System.Collections.IEnumerable] -and -not ($handlerResult.Output -is [string])) { - return @($handlerResult.Output) + elseif ($handlerResult.Output -is [System.Collections.IEnumerable] -and -not ($handlerResult.Output -is [string])) { + $output = @($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 { - $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 { if ($null -ne $previousLocation) { diff --git a/utils/modules/GitTools.psm1 b/utils/modules/GitTools.psm1 index e13050c..8677946 100644 --- a/utils/modules/GitTools.psm1 +++ b/utils/modules/GitTools.psm1 @@ -53,21 +53,18 @@ function Invoke-GitInternal { if (-not (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue)) { $srcDir = Split-Path $PSScriptRoot -Parent - $externalCandidates = @( - (Join-Path $PSScriptRoot 'ExternalCommandSupport.psm1'), - (Join-Path $srcDir 'plugins' 'Shared' 'ExternalCommandSupport.psm1') - ) - foreach ($externalModule in $externalCandidates) { - if (Test-Path -LiteralPath $externalModule -PathType Leaf) { - Import-Module $externalModule -Global - break - } + $externalModule = Join-Path $PSScriptRoot 'ExternalCommandSupport.psm1' + if (Test-Path -LiteralPath $externalModule -PathType Leaf) { + Import-Module $externalModule -Global + } + elseif (Test-Path -LiteralPath (Join-Path $srcDir 'modules' 'ExternalCommandSupport.psm1') -PathType Leaf) { + Import-Module (Join-Path $srcDir 'modules' 'ExternalCommandSupport.psm1') -Global } } if ($CaptureOutput) { 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 if ($exitCode -ne 0) { Write-Error "$ErrorMessage (exit code: $exitCode)" @@ -96,7 +93,7 @@ function Invoke-GitInternal { } 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 { & git @Arguments diff --git a/utils/modules/TestRunner.psm1 b/utils/modules/TestRunner.psm1 index b1935e2..aac3308 100644 --- a/utils/modules/TestRunner.psm1 +++ b/utils/modules/TestRunner.psm1 @@ -19,10 +19,8 @@ function Import-ExternalCommandSupportInternal { return } - $srcDir = Split-Path $PSScriptRoot -Parent $candidates = @( - (Join-Path $PSScriptRoot 'ExternalCommandSupport.psm1'), - (Join-Path $srcDir 'plugins' 'Shared' 'ExternalCommandSupport.psm1') + (Join-Path $PSScriptRoot 'ExternalCommandSupport.psm1') ) foreach ($modulePath in $candidates) { if (Test-Path -LiteralPath $modulePath -PathType Leaf) { @@ -175,10 +173,10 @@ function Invoke-TestsWithCoverage { Import-ExternalCommandSupportInternal if ($Silent) { - $null = Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs -MergeErrorOutput + $null = Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs -MergeErrorOutput -ThrowOnError:$false } else { - Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs | Out-Default + Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs -ThrowOnError:$false | Out-Default } $testExitCode = $LASTEXITCODE @@ -356,10 +354,10 @@ function Invoke-NpmJestTestsWithCoverage { $npmArgs = @('run', $TestScript, '--', '--coverage', '--coverageReporters=json-summary', '--coverageReporters=text') Import-ExternalCommandSupportInternal if ($Silent) { - $null = Invoke-ExternalCommand -Name npm -ArgumentList $npmArgs -MergeErrorOutput + $null = Invoke-ExternalCommand -Name npm -ArgumentList $npmArgs -MergeErrorOutput -ThrowOnError:$false } else { - Invoke-ExternalCommand -Name npm -ArgumentList $npmArgs | Out-Default + Invoke-ExternalCommand -Name npm -ArgumentList $npmArgs -ThrowOnError:$false | Out-Default } if ($LASTEXITCODE -ne 0) { diff --git a/utils/plugins/Platform/PesterTest.psm1 b/utils/plugins/Platform/PesterTest.psm1 index 1dbc1e4..1fa1942 100644 --- a/utils/plugins/Platform/PesterTest.psm1 +++ b/utils/plugins/Platform/PesterTest.psm1 @@ -6,7 +6,7 @@ Pester test plugin for the RepoUtils test engine. .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. #> diff --git a/utils/templates/Dockerfile.ci.dotnet b/utils/templates/Dockerfile.ci.dotnet new file mode 100644 index 0000000..78bb6a1 --- /dev/null +++ b/utils/templates/Dockerfile.ci.dotnet @@ -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"] diff --git a/utils/templates/Dockerfile.ci.npm b/utils/templates/Dockerfile.ci.npm new file mode 100644 index 0000000..c5525aa --- /dev/null +++ b/utils/templates/Dockerfile.ci.npm @@ -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"] diff --git a/utils/templates/build/ci-dotnet-pack.sh b/utils/templates/build/ci-dotnet-pack.sh new file mode 100644 index 0000000..d5f6934 --- /dev/null +++ b/utils/templates/build/ci-dotnet-pack.sh @@ -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 diff --git a/utils/templates/build/ci-npm-pack.sh b/utils/templates/build/ci-npm-pack.sh new file mode 100644 index 0000000..c0bccf7 --- /dev/null +++ b/utils/templates/build/ci-npm-pack.sh @@ -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" diff --git a/utils/tools/Update-RepoUtils/Update-RepoUtils.ps1 b/utils/tools/Update-RepoUtils/Update-RepoUtils.ps1 deleted file mode 100644 index 00b3ce0..0000000 --- a/utils/tools/Update-RepoUtils/Update-RepoUtils.ps1 +++ /dev/null @@ -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 diff --git a/utils/tools/Update-RepoUtils/scriptSettings.json b/utils/tools/Update-RepoUtils/scriptSettings.json deleted file mode 100644 index ca20f5c..0000000 --- a/utils/tools/Update-RepoUtils/scriptSettings.json +++ /dev/null @@ -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" - ] - } -}