← Files NightshiftARCHIVED FILE
runtime/windows/apply-profile.ps1
20.5 KB · Oct 4, 2026 · 12:30 UTC
param(
[string]$Project = [Environment]::CurrentDirectory,
[string]$Profile = '',
[ValidateSet('', 'replace', 'fill')][string]$Mode = '',
[switch]$List,
[switch]$Apply
)
Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'
$pluginRoot = Resolve-Path (Join-Path $PSScriptRoot '../..')
Import-Module (Join-Path $pluginRoot 'lib/Nightshift.psm1') -Force -DisableNameChecking
$profilesDir = Join-Path $pluginRoot 'skills/nightshift/references/profiles'
$schemaPath = Join-Path $pluginRoot 'skills/nightshift/references/nightshift-rules.schema.json'
$templatePath = Join-Path $pluginRoot 'skills/nightshift/references/nightshift-rules-template.json'
try {
$hostPath = Resolve-NSCanonicalPath $Project
}
catch {
[Console]::Error.WriteLine("apply-profile: cannot cd to $Project")
exit 1
}
try {
$workspace = Resolve-NSWorkspaceRoot $hostPath
}
catch {
[Console]::Error.WriteLine('apply-profile: invalid .nightshift-link')
exit 2
}
$ns = Join-Path $workspace '.nightshift'
$rulesPath = Get-NSLayoutPath $ns 'rules'
$defaultsPath = Get-NSLayoutPath $ns 'shift-defaults'
$punchListPath = Get-NSLayoutPath $ns 'punch-list'
if ($List) {
Write-Output 'Nightshift rule profiles (local copies, not a subscription)'
foreach ($file in @(Get-ChildItem -LiteralPath $profilesDir -Filter '*.json' -File -ErrorAction SilentlyContinue)) {
try {
$parsed = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json -ErrorAction Stop
Write-Output (" {0} risk={1} v{2} {3}" -f $parsed.name, $parsed.risk, $parsed.version, $parsed.use)
}
catch {
Write-Output (" {0}" -f $file.Name)
}
}
exit 0
}
if ($Mode -notin @('replace', 'fill')) {
[Console]::Error.WriteLine('apply-profile: -Mode must be replace or fill')
exit 1
}
if ([string]::IsNullOrEmpty($Profile) -or $Profile -notmatch '^[A-Za-z0-9_-]+$') {
[Console]::Error.WriteLine("apply-profile: unknown profile $Profile")
exit 1
}
$src = Join-Path $profilesDir "$Profile.json"
if (-not (Test-Path -LiteralPath $src -PathType Leaf)) {
[Console]::Error.WriteLine("apply-profile: unknown profile $Profile")
exit 1
}
try {
$profileObj = Get-Content -LiteralPath $src -Raw | ConvertFrom-Json -ErrorAction Stop
$schema = Get-Content -LiteralPath $schemaPath -Raw | ConvertFrom-Json -ErrorAction Stop
$template = Get-Content -LiteralPath $templatePath -Raw | ConvertFrom-Json -ErrorAction Stop
}
catch {
[Console]::Error.WriteLine('apply-profile: profile is malformed or not version 1 or 2')
exit 2
}
$nameProp = $profileObj.PSObject.Properties['name']
$versionProp = $profileObj.PSObject.Properties['version']
$rulesProp = $profileObj.PSObject.Properties['rules']
if ($null -eq $nameProp -or $null -eq $versionProp `
-or ([string]$versionProp.Value -ne '1' -and [string]$versionProp.Value -ne '2') `
-or $null -eq $rulesProp -or $null -eq $rulesProp.Value `
-or $rulesProp.Value -is [Array] -or $rulesProp.Value -is [string] -or $rulesProp.Value -is [ValueType]) {
[Console]::Error.WriteLine('apply-profile: profile is malformed or not version 1 or 2')
exit 2
}
$version = [string]$versionProp.Value
$schemaKeys = @{}
foreach ($property in $schema.properties.PSObject.Properties) {
$schemaKeys[$property.Name] = $true
}
$unknown = New-Object Collections.Generic.List[string]
foreach ($property in $profileObj.rules.PSObject.Properties) {
if (-not $schemaKeys.ContainsKey($property.Name) -or $property.Name -eq '$schema') {
$null = $unknown.Add($property.Name)
}
}
if ($unknown.Count -gt 0) {
[Console]::Error.WriteLine("apply-profile: profile has unsupported keys: $($unknown -join ' ')")
exit 2
}
# type/kind classification for a value already parsed by ConvertFrom-Json (mirrors jq's `type`).
function Get-NSJsonKind {
param($Value)
if ($null -eq $Value) { return 'null' }
if ($Value -is [Array]) { return 'array' }
if ($Value -is [string]) { return 'string' }
if ($Value -is [bool]) { return 'boolean' }
if ($Value -is [ValueType]) { return 'number' }
return 'object'
}
$sdType = ''
$gatesType = ''
$sdValue = $null
$gatesValue = $null
if ($version -eq '2') {
$sdProp = $profileObj.PSObject.Properties['shiftDefaults']
if ($null -ne $sdProp) { $sdValue = $sdProp.Value }
$sdType = Get-NSJsonKind $sdValue
if ($sdType -ne 'null' -and $sdType -ne 'object') {
[Console]::Error.WriteLine('apply-profile: profile shiftDefaults must be null or an object')
exit 2
}
$gatesProp = $profileObj.PSObject.Properties['gates']
if ($null -ne $gatesProp) { $gatesValue = $gatesProp.Value }
$gatesType = Get-NSJsonKind $gatesValue
if ($gatesType -ne 'null' -and $gatesType -ne 'object') {
[Console]::Error.WriteLine('apply-profile: profile gates must be null or an object')
exit 2
}
if ($sdType -eq 'object') {
$knownSd = @('verificationProfile', 'hours', 'toolingPolicy', 'execution')
$unknownSd = New-Object Collections.Generic.List[string]
foreach ($property in $sdValue.PSObject.Properties) {
if ($knownSd -notcontains $property.Name) {
$null = $unknownSd.Add($property.Name)
}
}
if ($unknownSd.Count -gt 0) {
[Console]::Error.WriteLine("apply-profile: profile shiftDefaults has unsupported keys: $($unknownSd -join ' ')")
exit 2
}
$vpProp = $sdValue.PSObject.Properties['verificationProfile']
if ($null -ne $vpProp -and (@('fast', 'balanced', 'strict', 'custom') -notcontains [string]$vpProp.Value)) {
[Console]::Error.WriteLine('apply-profile: profile shiftDefaults.verificationProfile must be fast, balanced, strict, or custom')
exit 2
}
$hoursProp = $sdValue.PSObject.Properties['hours']
if ($null -ne $hoursProp) {
$hv = $hoursProp.Value
if ($null -ne $hv -and -not ($hv -is [ValueType] -and $hv -isnot [bool])) {
[Console]::Error.WriteLine('apply-profile: profile shiftDefaults.hours must be an integer or null')
exit 2
}
}
$tpProp = $sdValue.PSObject.Properties['toolingPolicy']
if ($null -ne $tpProp -and (@('existing-tools', 'review-missing', 'auto-add') -notcontains [string]$tpProp.Value)) {
[Console]::Error.WriteLine('apply-profile: profile shiftDefaults.toolingPolicy must be existing-tools, review-missing, or auto-add')
exit 2
}
$execProp = $sdValue.PSObject.Properties['execution']
if ($null -ne $execProp -and (@('review-first', 'run-direct') -notcontains [string]$execProp.Value)) {
[Console]::Error.WriteLine('apply-profile: profile shiftDefaults.execution must be review-first or run-direct')
exit 2
}
}
if ($gatesType -eq 'object') {
$itemGateProp = $gatesValue.PSObject.Properties['itemGate']
$itemGateOk = $false
if ($null -ne $itemGateProp -and $itemGateProp.Value -is [Array]) {
$itemGateOk = $true
foreach ($cmd in $itemGateProp.Value) {
if ($cmd -isnot [string]) { $itemGateOk = $false; break }
}
}
if (-not $itemGateOk) {
[Console]::Error.WriteLine('apply-profile: profile gates.itemGate must be an array of command strings')
exit 2
}
$siProp = $gatesValue.PSObject.Properties['siteInspection']
if ($null -ne $siProp) {
$siValue = $siProp.Value
if ((Get-NSJsonKind $siValue) -ne 'object') {
[Console]::Error.WriteLine('apply-profile: profile gates.siteInspection must be an object')
exit 2
}
$everyProp = $siValue.PSObject.Properties['every']
$every = if ($null -ne $everyProp) { [string]$everyProp.Value } else { '' }
if ($every -notmatch '^[0-9]+ (items|hours)$') {
[Console]::Error.WriteLine('apply-profile: profile gates.siteInspection.every must be "N items" or "H hours"')
exit 2
}
$cmdsProp = $siValue.PSObject.Properties['commands']
$cmdsOk = $false
if ($null -ne $cmdsProp -and $cmdsProp.Value -is [Array]) {
$cmdsOk = $true
foreach ($cmd in $cmdsProp.Value) {
if ($cmd -isnot [string]) { $cmdsOk = $false; break }
}
}
if (-not $cmdsOk) {
[Console]::Error.WriteLine('apply-profile: profile gates.siteInspection.commands must be an array of command strings')
exit 2
}
}
}
}
if (-not (Test-Path -LiteralPath $ns -PathType Container)) {
[Console]::Error.WriteLine('apply-profile: no .nightshift/ - run setup first')
exit 2
}
if ($Apply -and (Test-Path -LiteralPath (Get-NSLayoutPath $ns 'armed') -PathType Leaf)) {
[Console]::Error.WriteLine('apply-profile: refuse to write rules while the shift is armed')
exit 2
}
if ($Apply -and $gatesType -eq 'object') {
if (-not (Test-Path -LiteralPath $punchListPath -PathType Leaf)) {
[Console]::Error.WriteLine('apply-profile: no punch-list.md - run setup first')
exit 2
}
$plProbe = Get-Content -LiteralPath $punchListPath
if (-not (@($plProbe) -contains '## Gates')) {
[Console]::Error.WriteLine('apply-profile: punch-list.md has no "## Gates" heading')
exit 2
}
}
$current = [pscustomobject]@{}
if (Test-Path -LiteralPath $rulesPath -PathType Leaf) {
try {
$parsedCurrent = Get-Content -LiteralPath $rulesPath -Raw | ConvertFrom-Json -ErrorAction Stop
if ($null -ne $parsedCurrent -and $parsedCurrent -isnot [Array] -and $parsedCurrent -isnot [string] -and $parsedCurrent -isnot [ValueType]) {
$current = $parsedCurrent
}
}
catch {
$current = [pscustomobject]@{}
}
}
function Copy-NSJsonObject {
param($Object)
if ($null -eq $Object) {
return [pscustomobject]@{}
}
return ($Object | ConvertTo-Json -Depth 20 | ConvertFrom-Json)
}
function Get-NSJsonPropertyValue {
param($Object, [string]$Name)
if ($null -eq $Object) {
return $null
}
$property = $Object.PSObject.Properties[$Name]
if ($null -eq $property) {
return $null
}
return $property.Value
}
if ($Mode -eq 'fill') {
$proposed = Copy-NSJsonObject $current
foreach ($property in $profileObj.rules.PSObject.Properties) {
if ($null -eq $proposed.PSObject.Properties[$property.Name]) {
$proposed | Add-Member -NotePropertyName $property.Name -NotePropertyValue $property.Value
}
}
}
else {
$proposed = Copy-NSJsonObject $template
foreach ($property in $profileObj.rules.PSObject.Properties) {
if ($null -eq $proposed.PSObject.Properties[$property.Name]) {
$proposed | Add-Member -NotePropertyName $property.Name -NotePropertyValue $property.Value
}
else {
$proposed.($property.Name) = $property.Value
}
}
$schemaValue = Get-NSJsonPropertyValue $current '$schema'
if ($schemaValue -is [string] -and -not [string]::IsNullOrEmpty($schemaValue)) {
if ($null -eq $proposed.PSObject.Properties['$schema']) {
$proposed | Add-Member -NotePropertyName '$schema' -NotePropertyValue $schemaValue
}
else {
$proposed.'$schema' = $schemaValue
}
}
}
$toolDeny = Get-NSJsonPropertyValue $proposed 'toolDeny'
$ask = Get-NSJsonPropertyValue $toolDeny 'AskUserQuestion'
$request = Get-NSJsonPropertyValue $toolDeny 'request_user_input'
$cursorAsk = Get-NSJsonPropertyValue $toolDeny 'AskQuestion'
if ($null -eq $toolDeny -or $toolDeny -is [Array] -or $toolDeny -is [string] -or $toolDeny -is [ValueType] `
-or $null -eq $toolDeny.PSObject.Properties['AskUserQuestion'] `
-or $null -eq $toolDeny.PSObject.Properties['request_user_input'] `
-or $null -eq $toolDeny.PSObject.Properties['AskQuestion'] `
-or $ask -isnot [string] -or $request -isnot [string] -or $cursorAsk -isnot [string]) {
[Console]::Error.WriteLine('apply-profile: proposed rules lack an explicit native question policy - re-run setup first')
exit 2
}
# shift-defaults.json base: the current file when it parses and matches the shape, else the
# built-in defaults (a missing or malformed file decides nothing).
function Get-NSShiftDefaultsBase {
param([string]$Path)
$builtin = [pscustomobject]@{
schemaVersion = 1
verificationProfile = 'fast'
hours = $null
toolingPolicy = 'existing-tools'
execution = 'review-first'
}
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
return $builtin
}
try {
$parsed = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json -ErrorAction Stop
}
catch {
return $builtin
}
if ($null -eq $parsed -or $parsed -is [Array] -or $parsed -is [string] -or $parsed -is [ValueType]) {
return $builtin
}
$svProp = $parsed.PSObject.Properties['schemaVersion']
if ($null -eq $svProp -or [string]$svProp.Value -ne '1') {
return $builtin
}
$vpProp = $parsed.PSObject.Properties['verificationProfile']
if ($null -eq $vpProp -or (@('fast', 'balanced', 'strict', 'custom') -notcontains [string]$vpProp.Value)) {
return $builtin
}
$hoursProp = $parsed.PSObject.Properties['hours']
if ($null -eq $hoursProp) {
return $builtin
}
$hv = $hoursProp.Value
if ($null -ne $hv -and -not ($hv -is [ValueType] -and $hv -isnot [bool])) {
return $builtin
}
$tpProp = $parsed.PSObject.Properties['toolingPolicy']
if ($null -eq $tpProp -or (@('existing-tools', 'review-missing', 'auto-add') -notcontains [string]$tpProp.Value)) {
return $builtin
}
$execProp = $parsed.PSObject.Properties['execution']
if ($null -eq $execProp -or (@('review-first', 'run-direct') -notcontains [string]$execProp.Value)) {
return $builtin
}
return $parsed
}
# Merge the profile's shiftDefaults over the base - only the keys the profile sets change.
function Merge-NSShiftDefaults {
param($Base, $ProfileObj)
$merged = Copy-NSJsonObject $Base
$sdProp = $ProfileObj.PSObject.Properties['shiftDefaults']
if ($null -ne $sdProp -and $null -ne $sdProp.Value) {
$sd = $sdProp.Value
foreach ($name in @('verificationProfile', 'hours', 'toolingPolicy', 'execution')) {
$prop = $sd.PSObject.Properties[$name]
if ($null -ne $prop) {
if ($null -eq $merged.PSObject.Properties[$name]) {
$merged | Add-Member -NotePropertyName $name -NotePropertyValue $prop.Value
}
else {
$merged.$name = $prop.Value
}
}
}
}
$now = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
if ($null -eq $merged.PSObject.Properties['schemaVersion']) {
$merged | Add-Member -NotePropertyName schemaVersion -NotePropertyValue 1
}
else {
$merged.schemaVersion = 1
}
if ($null -eq $merged.PSObject.Properties['updatedAt']) {
$merged | Add-Member -NotePropertyName updatedAt -NotePropertyValue $now
}
else {
$merged.updatedAt = $now
}
return $merged
}
# The rendered `## Gates` block body: the placeholder for an empty itemGate, else the item gate
# commands in the template's phrasing, plus the site inspection sentence when that key is
# present (a profile that omits siteInspection gets no site-inspection sentence at all).
function Get-NSGatesBody {
param($ProfileObj)
$itemGate = @($ProfileObj.gates.itemGate)
if ($itemGate.Count -eq 0) {
return '_None configured._'
}
$lines = New-Object Collections.Generic.List[string]
[void]$lines.Add('**Item gate** - runs every item, right before its commit or artifact receipt:')
[void]$lines.Add('')
foreach ($cmd in $itemGate) {
[void]$lines.Add('- `' + $cmd + '`')
}
$siProp = $ProfileObj.gates.PSObject.Properties['siteInspection']
if ($null -ne $siProp) {
$si = $siProp.Value
[void]$lines.Add('')
[void]$lines.Add(('**Site inspection** - the heavier batch, every {0}:' -f $si.every))
$siCommands = @($si.commands)
if ($siCommands.Count -eq 0) {
[void]$lines.Add('')
[void]$lines.Add('_None configured._')
}
else {
[void]$lines.Add('')
foreach ($cmd in $siCommands) {
[void]$lines.Add('- `' + $cmd + '`')
}
}
}
return ($lines -join "`n")
}
Write-Output ("Profile: {0}" -f $profileObj.name)
Write-Output ("Risk: {0}" -f $profileObj.risk)
Write-Output ("Use: {0}" -f $profileObj.use)
Write-Output ("Mode: {0}" -f $Mode)
Write-Output 'Rules the profile sets:'
foreach ($property in $profileObj.rules.PSObject.Properties) {
$json = $property.Value | ConvertTo-Json -Compress -Depth 5
Write-Output (" {0}={1}" -f $property.Name, $json)
}
Write-Output ''
Write-Output 'Proposed rules.json'
Write-Output ($proposed | ConvertTo-Json -Depth 20)
if ($sdType -eq 'object') {
$sdBase = Get-NSShiftDefaultsBase $defaultsPath
$sdMerged = Merge-NSShiftDefaults $sdBase $profileObj
Write-Output ''
Write-Output 'Proposed shift-defaults.json'
Write-Output ($sdMerged | ConvertTo-Json -Depth 20)
}
if ($gatesType -eq 'object') {
Write-Output ''
Write-Output 'Proposed ## Gates block'
Write-Output (Get-NSGatesBody $profileObj)
}
if (-not $Apply) {
Write-Output ''
Write-Output 'Dry run. Re-run with -Apply after explicit confirmation.'
exit 0
}
$json = ($proposed | ConvertTo-Json -Depth 20)
if (-not $json.EndsWith("`n")) {
$json += [Environment]::NewLine
}
$tmp = Join-Path $ns ('.rules.json.{0}' -f $PID)
try {
[IO.File]::WriteAllText($tmp, $json, (New-Object Text.UTF8Encoding $false))
Move-Item -LiteralPath $tmp -Destination $rulesPath -Force
}
catch {
if (Test-Path -LiteralPath $tmp -PathType Leaf) {
Remove-NSFile $tmp
}
exit 2
}
Write-Output "Wrote $rulesPath"
if ($sdType -eq 'object') {
$sdBase = Get-NSShiftDefaultsBase $defaultsPath
$sdMerged = Merge-NSShiftDefaults $sdBase $profileObj
$sdJson = ($sdMerged | ConvertTo-Json -Depth 20) -replace "`r`n", "`n"
if (-not $sdJson.EndsWith("`n")) {
$sdJson += "`n"
}
$tmpSd = Join-Path $ns ('.shift-defaults.json.{0}' -f $PID)
try {
[IO.File]::WriteAllText($tmpSd, $sdJson, (New-Object Text.UTF8Encoding $false))
Move-Item -LiteralPath $tmpSd -Destination $defaultsPath -Force
}
catch {
if (Test-Path -LiteralPath $tmpSd -PathType Leaf) {
Remove-NSFile $tmpSd
}
exit 2
}
Write-Output "Wrote $defaultsPath"
}
if ($gatesType -eq 'object') {
$plLines = @(Get-Content -LiteralPath $punchListPath)
$gatesIdx = -1
for ($i = 0; $i -lt $plLines.Count; $i++) {
if ($plLines[$i] -eq '## Gates') { $gatesIdx = $i; break }
}
if ($gatesIdx -lt 0) {
[Console]::Error.WriteLine('apply-profile: punch-list.md has no "## Gates" heading')
exit 2
}
$nextIdx = $plLines.Count
for ($i = $gatesIdx + 1; $i -lt $plLines.Count; $i++) {
if ($plLines[$i] -like '## *') { $nextIdx = $i; break }
}
$body = Get-NSGatesBody $profileObj
$newLines = New-Object Collections.Generic.List[string]
for ($i = 0; $i -le $gatesIdx; $i++) { [void]$newLines.Add($plLines[$i]) }
[void]$newLines.Add('')
[void]$newLines.Add('<!-- Nightshift Setup fills this from your stack, or leaves it empty (no automated checks).')
[void]$newLines.Add(' Item gate: runs every item, right before its commit or artifact receipt - must be green to tick.')
[void]$newLines.Add(' Site inspection: the heavier batch (coverage, dead code, Sonar), every N items or H hours. -->')
[void]$newLines.Add('')
foreach ($bodyLine in ($body -split "`n")) { [void]$newLines.Add($bodyLine) }
[void]$newLines.Add('')
for ($i = $nextIdx; $i -lt $plLines.Count; $i++) { [void]$newLines.Add($plLines[$i]) }
$plText = ($newLines -join "`n") + "`n"
$tmpPl = Join-Path $ns ('.punch-list.md.{0}' -f $PID)
try {
[IO.File]::WriteAllText($tmpPl, $plText, (New-Object Text.UTF8Encoding $false))
Move-Item -LiteralPath $tmpPl -Destination $punchListPath -Force
}
catch {
if (Test-Path -LiteralPath $tmpPl -PathType Leaf) {
Remove-NSFile $tmpPl
}
exit 2
}
Write-Output "Wrote $punchListPath"
}
exit 0
SHA-256: 1fb275a080386f1da42591099a2fa1b683b6fb6e3388e8d818e1566a4be4342e