Compare commits

..

No commits in common. "main" and "v0.4.2" have entirely different histories.
main ... v0.4.2

24 changed files with 541 additions and 405 deletions

View File

@ -4,32 +4,6 @@ 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.5] - 2026-08-12
### Changed
- **Breaking:** `Breadcrumb` now renders links with react-router `Link` directly. Dropped injectable `linkComponent`, `href`, and `linkProps` — use `to` on trail items only. Default link/current/separator colors use slate tones.
### Removed
- `BreadcrumbLinkComponent` export (no longer needed).
## [0.4.4] - 2026-08-12
### Fixed
- `Breadcrumb` / `CookieConsent` `linkComponent` typing: `to` is required on the injected link props (and always passed when rendering links), so react-router `Link` is assignable without a host adapter. Exports: `BreadcrumbLinkComponent`, `CookieConsentLinkComponent`.
## [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 **maksit-repoutils** via local-copy sync (no Update-RepoUtils in product repos). Refresh shared utils from repoutils: **`utils\Update-RepoUtils.bat`**.
## Consume in product repos ## Consume in product repos

View File

@ -1,87 +0,0 @@
import { type FC, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
export interface BreadcrumbItem {
label: ReactNode
/** Omit on the current page (last item). */
to?: string
}
export interface BreadcrumbProps {
items: BreadcrumbItem[]
/** Defaults to `"/"`. */
separator?: ReactNode
className?: string
linkClassName?: string
currentClassName?: string
separatorClassName?: string
/** Accessible name for the nav landmark. Defaults to `"Breadcrumb"`. */
label?: string
}
/**
* Page trail (`nav` + `ol`). Links use react-router `Link`.
* Never uses headings keep a single page `h1` on `FormHeader` or the page title.
*/
const Breadcrumb: FC<BreadcrumbProps> = ({
items,
separator = '/',
className = '',
linkClassName = 'text-slate-500 hover:text-slate-800 hover:underline',
currentClassName = 'text-slate-700',
separatorClassName = 'text-slate-400',
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 isLink = !isLast && Boolean(item.to)
return (
<li
key={index}
className={'inline-flex items-center gap-x-2'}
>
{index > 0 ? (
<span
className={separatorClassName}
aria-hidden={'true'}
>
{separator}
</span>
) : null}
{isLink && item.to ? (
<Link
to={item.to}
className={linkClassName}
>
{item.label}
</Link>
) : (
<span
className={currentClassName}
{...(isLast ? { 'aria-current': 'page' as const } : {})}
>
{item.label}
</span>
)}
</li>
)
})}
</ol>
</nav>
)
}
export {
Breadcrumb
}

View File

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

View File

@ -17,15 +17,12 @@ export interface CookieConsentLink {
linkProps?: Record<string, unknown> linkProps?: Record<string, unknown>
} }
/** type ConsentLinkComponent = ComponentType<{
* Injectable link surface for SPA routers.
* `to` is always provided when CookieConsent renders a link, so react-router `Link` is assignable.
*/
export type CookieConsentLinkComponent = ComponentType<{
to: string
href?: string href?: string
to?: string
className?: string className?: string
children?: ReactNode children?: ReactNode
[key: string]: unknown
}> }>
export interface CookieConsentProps { export interface CookieConsentProps {
@ -36,13 +33,13 @@ export interface CookieConsentProps {
cookieName?: string cookieName?: string
cookieDays?: number cookieDays?: number
/** Host injects `Link` from react-router (or any anchor-like component). Defaults to `<a>`. */ /** Host injects `Link` from react-router (or any anchor-like component). Defaults to `<a>`. */
linkComponent?: CookieConsentLinkComponent linkComponent?: ConsentLinkComponent
onAccept?: () => void onAccept?: () => void
onDismiss?: () => void onDismiss?: () => void
className?: string className?: string
} }
const DefaultLink: CookieConsentLinkComponent = ({ const DefaultLink: ConsentLinkComponent = ({
href, href,
to, to,
children, children,
@ -111,24 +108,18 @@ const CookieConsent: FC<CookieConsentProps> = ({
{message} {message}
{links.length > 0 ? ( {links.length > 0 ? (
<ul className={'mt-2 flex flex-wrap gap-x-3 gap-y-1'}> <ul className={'mt-2 flex flex-wrap gap-x-3 gap-y-1'}>
{links.map((link, index) => { {links.map((link, index) => (
const target = link.to ?? link.href <li key={index}>
if (!target) <LinkComponent
return null href={link.href ?? (typeof link.to === 'string' ? link.to : undefined)}
to={link.to ?? link.href}
return ( className={'text-sky-700 underline hover:text-sky-900'}
<li key={index}> {...(link.linkProps ?? {})}
<LinkComponent >
href={link.href ?? (typeof link.to === 'string' ? link.to : undefined)} {link.label}
to={target} </LinkComponent>
className={'text-sky-700 underline hover:text-sky-900'} </li>
{...(link.linkProps ?? {})} ))}
>
{link.label}
</LinkComponent>
</li>
)
})}
</ul> </ul>
) : null} ) : null}
</div> </div>

View File

@ -1,7 +1,3 @@
export { CookieConsent } from './CookieConsent' export { CookieConsent } from './CookieConsent'
export type { export type { CookieConsentProps, CookieConsentLink } from './CookieConsent'
CookieConsentProps,
CookieConsentLink,
CookieConsentLinkComponent,
} from './CookieConsent'
export { getCookie, setCookie } from './cookies' export { getCookie, setCookie } from './cookies'

View File

@ -3,8 +3,6 @@ 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'
@ -22,11 +20,7 @@ export type { MasonryProps } from './components/Masonry'
export { LightBox } from './components/LightBox' export { LightBox } from './components/LightBox'
export type { LightBoxProps, LightBoxSlide } from './components/LightBox' export type { LightBoxProps, LightBoxSlide } from './components/LightBox'
export { CookieConsent, getCookie, setCookie } from './components/CookieConsent' export { CookieConsent, getCookie, setCookie } from './components/CookieConsent'
export type { export type { CookieConsentProps, CookieConsentLink } from './components/CookieConsent'
CookieConsentProps,
CookieConsentLink,
CookieConsentLinkComponent,
} from './components/CookieConsent'
export { export {
WhatsAppButton, WhatsAppButton,
buildWhatsAppHref, buildWhatsAppHref,

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.3", "version": "0.4.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@maks-it.com/webui", "name": "@maks-it.com/webui",
"version": "0.4.3", "version": "0.4.2",
"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.5", "version": "0.4.2",
"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

@ -1,78 +0,0 @@
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:
'Page trail (`nav` + `ol`) using react-router `Link`. Never uses headings — keep a single page `h1` on `FormHeader` or the page title.',
},
},
},
} 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">
<Breadcrumb
className="bg-gray-50 px-4 py-2"
items={[
{ label: 'Admin', to: '/admin' },
{ label: 'Shop', to: '/admin/shop' },
{ label: 'Edit item' },
]}
/>
<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

@ -0,0 +1,3 @@
@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 $pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $PSScriptRoot -ContinueOnError:$false
if (-not $pluginSucceeded) { if (-not $pluginSucceeded) {
$releaseHadPluginFailures = $true $releaseHadPluginFailures = $true
break break

View File

@ -6,8 +6,6 @@
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
@ -31,7 +29,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 $pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $engineContext -EngineDirectory $scriptDir -ContinueOnError:$false
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
) )
# Host engine runtime under modules/ (and optional modules/Extensions/) — never dual-homed under plugins/. # Engine runtime under modules/ only — never dual-homed under plugins/.
$engineNames = [System.Collections.Generic.HashSet[string]]::new( $engineNames = [System.Collections.Generic.HashSet[string]]::new(
[string[]]@( [string[]]@(
'ChangelogSupport', 'ChangelogSupport',
@ -34,7 +34,11 @@ function Test-IsEngineRuntimeModuleName {
'EngineContext', 'EngineContext',
'PluginSupport', 'PluginSupport',
'ReleaseSupport', 'ReleaseSupport',
'TestSupport' 'TestSupport',
'DeployConfig',
'EngineContextSupport',
'OrchestratorSupport',
'PluginPathSupport'
), ),
[System.StringComparer]::OrdinalIgnoreCase [System.StringComparer]::OrdinalIgnoreCase
) )
@ -42,34 +46,6 @@ 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)]
@ -90,16 +66,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). Optional Extensions/ for layered hosts. # Engine runtime: modules/ only (no plugins/ fallback).
$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/{Group}/ only (no modules/ legacy shadow). Groups are discovered. # Plugin helpers: plugins/ only (no modules/ legacy shadow).
foreach ($groupDir in Get-PluginDependencyGroupDirectories -PluginsRoot $pluginsRoot) { foreach ($group in @('Shared', 'Platform', 'DotNet', 'Npm', 'Helm', 'Docker', 'Podman')) {
$candidatePaths.Add((Join-Path $groupDir "$ModuleName.psm1")) $candidatePaths.Add((Join-Path (Join-Path $pluginsRoot $group) "$ModuleName.psm1"))
} }
} }
@ -368,8 +344,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 registry login and image-pull Base64(UTF8('username:password')). Used by Docker/Podman/Helm registry login
secret creation never pass the password itself as a parameter. and image-pull 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.
@ -695,7 +671,10 @@ 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,16 +1,6 @@
#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 = @{}
@ -50,10 +40,7 @@ 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
@ -64,7 +51,6 @@ 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 `
@ -76,46 +62,31 @@ function Invoke-ExternalCommand {
$global:LASTEXITCODE = [int]$handlerResult.ExitCode $global:LASTEXITCODE = [int]$handlerResult.ExitCode
if ($null -eq $handlerResult.Output) { if ($null -eq $handlerResult.Output) {
$output = @() return @()
} }
elseif ($handlerResult.Output -is [System.Collections.IEnumerable] -and -not ($handlerResult.Output -is [string])) {
$output = @($handlerResult.Output) if ($handlerResult.Output -is [System.Collections.IEnumerable] -and -not ($handlerResult.Output -is [string])) {
} 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 {
if ($script:ExternalCommandAvailability.ContainsKey($Name) -and -not $script:ExternalCommandAvailability[$Name]) { $output = & $Name @ArgumentList
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)
} }
$exitCode = [int]$global:LASTEXITCODE return @($output)
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,18 +53,21 @@ 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
$externalModule = Join-Path $PSScriptRoot 'ExternalCommandSupport.psm1' $externalCandidates = @(
if (Test-Path -LiteralPath $externalModule -PathType Leaf) { (Join-Path $PSScriptRoot 'ExternalCommandSupport.psm1'),
Import-Module $externalModule -Global (Join-Path $srcDir 'plugins' 'Shared' 'ExternalCommandSupport.psm1')
} )
elseif (Test-Path -LiteralPath (Join-Path $srcDir 'modules' 'ExternalCommandSupport.psm1') -PathType Leaf) { foreach ($externalModule in $externalCandidates) {
Import-Module (Join-Path $srcDir 'modules' 'ExternalCommandSupport.psm1') -Global if (Test-Path -LiteralPath $externalModule -PathType Leaf) {
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 -ThrowOnError:$false $output = Invoke-ExternalCommand -Name git -ArgumentList $Arguments -MergeErrorOutput
$exitCode = $LASTEXITCODE $exitCode = $LASTEXITCODE
if ($exitCode -ne 0) { if ($exitCode -ne 0) {
Write-Error "$ErrorMessage (exit code: $exitCode)" Write-Error "$ErrorMessage (exit code: $exitCode)"
@ -93,7 +96,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 -ThrowOnError:$false | Out-Null Invoke-ExternalCommand -Name git -ArgumentList $Arguments -MergeErrorOutput | Out-Null
} }
else { else {
& git @Arguments & git @Arguments

View File

@ -19,8 +19,10 @@ 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) {
@ -173,10 +175,10 @@ function Invoke-TestsWithCoverage {
Import-ExternalCommandSupportInternal Import-ExternalCommandSupportInternal
if ($Silent) { if ($Silent) {
$null = Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs -MergeErrorOutput -ThrowOnError:$false $null = Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs -MergeErrorOutput
} }
else { else {
Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs -ThrowOnError:$false | Out-Default Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs | Out-Default
} }
$testExitCode = $LASTEXITCODE $testExitCode = $LASTEXITCODE
@ -354,10 +356,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 -ThrowOnError:$false $null = Invoke-ExternalCommand -Name npm -ArgumentList $npmArgs -MergeErrorOutput
} }
else { else {
Invoke-ExternalCommand -Name npm -ArgumentList $npmArgs -ThrowOnError:$false | Out-Default Invoke-ExternalCommand -Name npm -ArgumentList $npmArgs | 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 RepoUtils Pester suite and publishes normalized coverage metrics on the Runs the community Pester suite and publishes normalized coverage metrics on the
shared engine context for QualityGate. shared engine context for QualityGate.
#> #>

View File

@ -1,21 +0,0 @@
# 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

@ -1,22 +0,0 @@
# 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

@ -1,11 +0,0 @@
#!/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

@ -1,12 +0,0 @@
#!/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

@ -0,0 +1,439 @@
#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

@ -0,0 +1,19 @@
{
"$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"
]
}
}