mirror of
https://github.com/MAKS-IT-COM/maksit-webui.git
synced 2026-08-15 09:58:09 +02:00
(feature): Breadcrumb and RepoUtils update
Some checks failed
Storybook tests / storybook-tests (push) Has been cancelled
Some checks failed
Storybook tests / storybook-tests (push) Has been cancelled
This commit is contained in:
parent
eb9e8b9af0
commit
ecce33e42f
10
CHANGELOG.md
10
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).
|
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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
119
src/components/components/Breadcrumb/Breadcrumb.tsx
Normal file
119
src/components/components/Breadcrumb/Breadcrumb.tsx
Normal 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
|
||||||
|
}
|
||||||
2
src/components/components/Breadcrumb/index.ts
Normal file
2
src/components/components/Breadcrumb/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { Breadcrumb } from './Breadcrumb'
|
||||||
|
export type { BreadcrumbProps, BreadcrumbItem } from './Breadcrumb'
|
||||||
@ -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
4
src/package-lock.json
generated
@ -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"
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
79
src/stories/components/Breadcrumb.stories.tsx
Normal file
79
src/stories/components/Breadcrumb.stories.tsx
Normal 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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
@ -1,3 +0,0 @@
|
|||||||
@echo off
|
|
||||||
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\Update-RepoUtils\Update-RepoUtils.ps1" %*
|
|
||||||
pause
|
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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.
|
||||||
#>
|
#>
|
||||||
|
|
||||||
|
|||||||
21
utils/templates/Dockerfile.ci.dotnet
Normal file
21
utils/templates/Dockerfile.ci.dotnet
Normal 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"]
|
||||||
22
utils/templates/Dockerfile.ci.npm
Normal file
22
utils/templates/Dockerfile.ci.npm
Normal 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"]
|
||||||
11
utils/templates/build/ci-dotnet-pack.sh
Normal file
11
utils/templates/build/ci-dotnet-pack.sh
Normal 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
|
||||||
12
utils/templates/build/ci-npm-pack.sh
Normal file
12
utils/templates/build/ci-npm-pack.sh
Normal 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"
|
||||||
@ -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
|
|
||||||
@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user