← Files NightshiftARCHIVED FILE

runtime/windows/export-support.ps1

10.9 KB · Oct 3, 2026 · 06:31 UTC

↓ Download file

param(
    [string]$Project = [Environment]::CurrentDirectory
)

Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'

$pluginRoot = Resolve-Path (Join-Path $PSScriptRoot '../..')
Import-Module (Join-Path $pluginRoot 'lib/Nightshift.psm1') -Force -DisableNameChecking

try {
    $hostPath = Resolve-NSCanonicalPath $Project
}
catch {
    [Console]::Error.WriteLine("export-support: cannot cd to $Project")
    exit 1
}

$linkState = 'absent'
try {
    $workspace = Resolve-NSWorkspaceRoot $hostPath
    $link = Join-Path $hostPath '.nightshift-link'
    if (Test-NSPathEntry $link) {
        $linkState = 'valid'
    }
}
catch {
    [Console]::Error.WriteLine('export-support: invalid .nightshift-link - Nightshift will not guess a workspace')
    exit 2
}

$ns = Join-Path $workspace '.nightshift'
if (-not (Test-Path -LiteralPath $ns -PathType Container)) {
    [Console]::Error.WriteLine("export-support: no .nightshift/ at $workspace")
    exit 2
}

$homeRoot = ''
if (-not [string]::IsNullOrEmpty($env:USERPROFILE)) {
    try { $homeRoot = Resolve-NSCanonicalPath $env:USERPROFILE } catch { $homeRoot = '' }
}
elseif (-not [string]::IsNullOrEmpty($env:HOME)) {
    try { $homeRoot = Resolve-NSCanonicalPath $env:HOME } catch { $homeRoot = '' }
}

$target = ''
try {
    $resolved = Resolve-NSWorkTarget $workspace
    if (-not [string]::IsNullOrEmpty($resolved)) {
        $target = Resolve-NSCanonicalPath $resolved
    }
}
catch {
    $target = ''
}

$pluginJson = Join-Path $pluginRoot '.claude-plugin/plugin.json'
$pluginVer = 'unknown'
$pluginName = 'nightshift'
if (Test-Path -LiteralPath $pluginJson -PathType Leaf) {
    try {
        $manifest = Get-Content -LiteralPath $pluginJson -Raw | ConvertFrom-Json -ErrorAction Stop
        if ($null -ne $manifest.PSObject.Properties['version'] -and -not [string]::IsNullOrEmpty([string]$manifest.version)) {
            $pluginVer = [string]$manifest.version
        }
        if ($null -ne $manifest.PSObject.Properties['name'] -and -not [string]::IsNullOrEmpty([string]$manifest.name)) {
            $pluginName = [string]$manifest.name
        }
    }
    catch {
    }
}

$stateKind = Get-NSStateKind $workspace
$stateVer = Get-NSStateVersion $workspace
if ($stateKind -notin @('current', 'legacy')) {
    $stateVer = ''
}

$rulesPath = Get-NSLayoutPath $ns 'rules'
$rulesState = 'missing'
$rulesKeys = ''
if (-not (Test-Path -LiteralPath $rulesPath -PathType Leaf)) {
    $rulesState = 'missing'
}
else {
    $rules = Get-NSRulesObject $workspace
    if ($null -eq $rules) {
        $rulesState = 'unreadable'
    }
    else {
        $rulesState = 'valid'
        $rulesKeys = (($rules.PSObject.Properties | ForEach-Object { $_.Name }) -join ' ')
    }
}

# The resolved view, never the policy files themselves. Owner free-form text and
# any value over 80 characters ship as their length: the bundle carries where a
# setting came from and when it expires, never what the owner wrote.
# Known sensitive fields are omitted. This is not a complete sanitization.
$policyFreeForm = @('forbiddenCommands', 'protectedDirs', 'neverCommitPatterns', 'expectedEmail')
$policyLines = New-Object Collections.Generic.List[string]
$policyState = 'unreadable'
try {
    $resolution = Get-NSPolicyResolution $workspace
    $policyState = [string]$resolution['policyState']
    $policySettings = $resolution['settings']
    foreach ($policyName in (Sort-NSOrdinal (@($policySettings.Keys)))) {
        $policyEntry = $policySettings[$policyName]
        $policyValue = Format-NSPolicyValue $policyEntry['value']
        if ($policyValue.Length -gt 0 -and (($policyFreeForm -ccontains $policyName) -or $policyValue.Length -gt 80)) {
            $policyValue = '<redacted ' + $policyValue.Length + ' chars>'
        }
        $null = $policyLines.Add(('{0}={1} ({2}, {3})' -f $policyName, $policyValue, $policyEntry['source'], $policyEntry['expiry']))
    }
}
catch {
    $policyLines.Clear()
}

$reason = Get-NSReasonCode $ns
$reasonLabel = ''
if (-not [string]::IsNullOrEmpty($reason)) {
    $reasonLabel = Get-NSReasonLabel $reason
}

$leaseState = 'absent'
$leaseHost = ''
$leaseGeneration = ''
$leaseMode = ''
$leasePath = Get-NSLayoutPath $ns 'lease'
if (Test-NSPathEntry $leasePath) {
    $lease = Read-NSLease $ns
    if ($null -ne $lease) {
        $leaseState = 'valid'
        $leaseHost = [string]$lease.HostName
        $leaseGeneration = [string]$lease.Generation
        $leaseMode = if (-not [string]::IsNullOrEmpty([string]$lease.Nonce)) { 'recovered' } else { 'interactive' }
    }
    else {
        $leaseState = 'malformed'
    }
}

$stamp = [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssZ')
$outdir = Get-NSLayoutPath $ns 'support'
try {
    $null = New-Item -ItemType Directory -Path $outdir -Force
}
catch {
    [Console]::Error.WriteLine("export-support: cannot create $outdir")
    exit 2
}
$tmp = Join-Path $outdir ".$stamp.$PID"
$dest = Join-Path $outdir "$stamp.txt"

function Format-NSIdentity {
    param([string]$Value, [string]$Label)
    if ([string]::IsNullOrEmpty($Value)) {
        return "${Label}: omitted"
    }
    $token = Convert-NSTokenizedText $Value $homeRoot $workspace $target
    if ($null -eq $token) {
        return "${Label}: omitted"
    }
    return "${Label}: $token"
}

$lines = New-Object Collections.Generic.List[string]
$null = $lines.Add('Nightshift support bundle')
$null = $lines.Add("Generated: $stamp")
$null = $lines.Add('')
$null = $lines.Add('== plugin ==')
$null = $lines.Add("name: $pluginName")
$null = $lines.Add("version: $pluginVer")
$null = $lines.Add('')
$null = $lines.Add('== host ==')
$null = $lines.Add("uname: $([Environment]::OSVersion.Platform)")
$null = $lines.Add("link: $linkState")
$null = $lines.Add('')
$null = $lines.Add('== state ==')
$null = $lines.Add("kind: $stateKind")
if (-not [string]::IsNullOrEmpty($stateVer)) {
    $null = $lines.Add("version: $stateVer")
}
$null = $lines.Add('')
$null = $lines.Add('== identities ==')
$null = $lines.Add((Format-NSIdentity $hostPath 'task'))
$null = $lines.Add((Format-NSIdentity $workspace 'workspace'))
if ([string]::IsNullOrEmpty($target)) {
    $null = $lines.Add('work_target: unresolved')
}
else {
    $null = $lines.Add((Format-NSIdentity $target 'work_target'))
}
$null = $lines.Add('')
$null = $lines.Add('== markers ==')
$armedPath = Get-NSLayoutPath $ns 'armed'
$armedLabel = if (Test-NSReparsePoint $armedPath) {
    'unusable'
}
elseif (Test-Path -LiteralPath $armedPath -PathType Leaf) {
    'yes'
}
else {
    'no'
}
$null = $lines.Add("armed: $armedLabel")
$endedPath = Get-NSLayoutPath $ns 'ended'
$endedLabel = if (Test-NSReparsePoint $endedPath) {
    'unusable'
}
elseif (Test-Path -LiteralPath $endedPath -PathType Leaf) {
    'yes'
}
else {
    'no'
}
$null = $lines.Add("ended: $endedLabel")
$null = $lines.Add(('stop: {0}' -f $(if (Test-Path -LiteralPath (Get-NSLayoutPath $ns 'stop') -PathType Leaf) { 'yes' } else { 'no' })))
$sessionEndPath = Get-NSLayoutPath $ns 'session-end'
$sessionEndLabel = if (Test-NSReparsePoint $sessionEndPath) {
    'unusable'
}
elseif (Test-Path -LiteralPath $sessionEndPath -PathType Leaf) {
    'yes'
}
else {
    'no'
}
$null = $lines.Add("session_end: $sessionEndLabel")
$pulsePath = Get-NSLayoutPath $ns 'pulse'
$pulseLabel = if (Test-NSReparsePoint $pulsePath) {
    'unusable'
}
elseif (Test-Path -LiteralPath $pulsePath -PathType Leaf) {
    'yes'
}
else {
    'no'
}
$null = $lines.Add("shift_pulse: $pulseLabel")
$sessionPath = Get-NSLayoutPath $ns 'session'
$sessionRecordLabel = if (Test-NSReparsePoint $sessionPath) {
    'unusable'
}
elseif (Test-Path -LiteralPath $sessionPath -PathType Leaf) {
    'present'
}
else {
    'absent'
}
$null = $lines.Add("session_record: $sessionRecordLabel")
$null = $lines.Add("process_lease: $leaseState")
if (-not [string]::IsNullOrEmpty($leaseHost)) { $lines.Add("lease_host: $leaseHost") }
if (-not [string]::IsNullOrEmpty($leaseGeneration)) { $lines.Add("lease_generation: $leaseGeneration") }
if (-not [string]::IsNullOrEmpty($leaseMode)) { $lines.Add("lease_mode: $leaseMode") }
$watchmanPath = Get-NSLayoutPath $ns 'watchman'
$watchmanPidfileLabel = if (Test-NSReparsePoint $watchmanPath) {
    'unusable'
}
elseif (Test-Path -LiteralPath $watchmanPath -PathType Leaf) {
    'present'
}
else {
    'absent'
}
$null = $lines.Add("watchman_pidfile: $watchmanPidfileLabel")
$null = $lines.Add('')
$null = $lines.Add('== rules ==')
$null = $lines.Add("validity: $rulesState")
$null = $lines.Add("keys: $rulesKeys")
$null = $lines.Add('')
$null = $lines.Add('== resolved policy ==')
$null = $lines.Add("shift_policy: $policyState")
foreach ($policyLine in $policyLines) {
    $null = $lines.Add($policyLine)
}
$null = $lines.Add('')
$null = $lines.Add('== evidence summary ==')
$null = $lines.Add((Get-NSEvidenceCountSummary $workspace))
$watchMinutes = 0
try {
    $watchMinutes = [int](Get-NSRule $workspace 'watchMinutes' '')
}
catch {
    $watchMinutes = 0
}
$null = $lines.Add(('liveness: {0}' -f (Get-NSStatusLiveness $workspace $watchMinutes)))
$activity = Get-NSStatusLastActivity $workspace
$null = $lines.Add(('last activity: {0}' -f $(if ($activity.Length -gt 0) { $activity } else { 'none' })))
$null = $lines.Add(('last checkpoint: {0}' -f (Get-NSGateCheckpointToken $workspace)))
$null = $lines.Add(('stall attempts: {0}' -f (Get-NSStatusStallAttempts $workspace)))
$invPath = Get-NSLayoutPath $ns 'capabilities'
if ((Test-Path -LiteralPath $invPath -PathType Leaf) -and -not (Test-NSReparsePoint $invPath)) {
    try {
        $invDoc = Get-Content -LiteralPath $invPath -Raw | ConvertFrom-Json -ErrorAction Stop
        $invCount = 0
        if ($null -ne $invDoc.PSObject.Properties['items'] -and $null -ne $invDoc.items) {
            $invCount = @($invDoc.items).Count
        }
        $null = $lines.Add("inventory items: $invCount")
    }
    catch {
        $null = $lines.Add('inventory items: omitted')
    }
}
else {
    $null = $lines.Add('inventory items: omitted')
}
$null = $lines.Add('')
$null = $lines.Add('== runtime log ==')
$null = $lines.Add('omitted')
$null = $lines.Add('')
$null = $lines.Add('== watchman reason ==')
if (-not [string]::IsNullOrEmpty($reason)) {
    $null = $lines.Add("code: $reason")
    $null = $lines.Add("label: $reasonLabel")
}
else {
    $null = $lines.Add('code: none')
}

try {
    $null = Write-NSAtomicLines -Path $tmp -Lines @($lines) -Private
    Move-Item -LiteralPath $tmp -Destination $dest -Force
}
catch {
    if (Test-Path -LiteralPath $tmp -PathType Leaf) {
        Remove-NSFile $tmp
    }
    [Console]::Error.WriteLine('export-support: failed to write bundle')
    exit 2
}

Write-Output "Support bundle: $dest"
Write-Output 'Included: plugin version, markers, the resolved policy view, counts'
Write-Output 'Omitted: scheduled.log, evidence ledger raw output, known sensitive fields, repository contents, diffs, transcripts, prompts, owner files, credentials, network, session identities, lease capabilities, capability inventory contents, policy files'
Write-Output 'Known sensitive fields are omitted. Inspect the file before sharing. Never uploaded, attached, or opened automatically.'
exit 0

SHA-256: 0c615226a4807f2fb36eff34255faa00c08abde60d107943438d327579efaa41