<# .SYNOPSIS Runs the Windows developer smoke gates for nana-story. .DESCRIPTION Checks the pinned developer toolchain and adjacent lapp-rs checkout, optionally installs JavaScript dependencies, then runs the repository verification and a non-bundled Tauri production build. With -Launch, it starts `pnpm tauri dev` after all gates pass. This script deliberately has no credential parameters. Git authentication and LAPP profile/Vault setup must be completed outside this process. .PARAMETER InstallDependencies Runs `pnpm install --frozen-lockfile`. Without this switch, the script never installs dependencies and requires an existing pnpm node_modules layout. .PARAMETER Launch Starts the desktop application with `pnpm tauri dev` after verification. .PARAMETER Demo Uses the deterministic demo provider for the optional desktop launch. This switch is only valid together with -Launch. .PARAMETER SmokeDataPath Absolute child path below the Windows temporary directory used as isolated app data for -Launch. Reuse the same path to test restart recovery; choose a new path to start from a fresh database. The script never deletes this directory. #> [CmdletBinding(PositionalBinding = $false)] param( [switch]$InstallDependencies, [switch]$Launch, [switch]$Demo, [string]$SmokeDataPath ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" function Find-NativeCommand { param( [Parameter(Mandatory = $true)] [string[]]$Names, [Parameter(Mandatory = $true)] [string]$DisplayName ) foreach ($name in $Names) { $command = Get-Command -Name $name -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if ($null -ne $command) { return $command.Source } } throw "$DisplayName was not found on PATH. Install it outside this script and retry." } function Invoke-NativeCapture { param( [Parameter(Mandatory = $true)] [string]$FilePath, [string[]]$ArgumentList = @(), [Parameter(Mandatory = $true)] [string]$Label ) $output = @(& $FilePath @ArgumentList 2>&1) $exitCode = $LASTEXITCODE $text = ($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine if ($exitCode -ne 0) { throw "$Label failed with exit code $exitCode. $text" } return $text.Trim() } function Invoke-NativeChecked { param( [Parameter(Mandatory = $true)] [string]$FilePath, [string[]]$ArgumentList = @(), [Parameter(Mandatory = $true)] [string]$Label ) Write-Host ("[run] {0}" -f $Label) & $FilePath @ArgumentList $exitCode = $LASTEXITCODE if ($exitCode -ne 0) { throw "$Label failed with exit code $exitCode." } } function Normalize-GitRemote { param( [Parameter(Mandatory = $true)] [string]$Url ) $normalized = $Url.Trim() if ($normalized -match "^git@([^:]+):(.+)$") { $normalized = "https://$($Matches[1])/$($Matches[2])" } elseif ($normalized -match "^ssh://git@([^/]+)/(.+)$") { $normalized = "https://$($Matches[1])/$($Matches[2])" } $normalized = $normalized.TrimEnd("/") if ($normalized.EndsWith(".git", [System.StringComparison]::OrdinalIgnoreCase)) { $normalized = $normalized.Substring(0, $normalized.Length - 4) } return $normalized.ToLowerInvariant() } function Assert-TemporaryChildWithoutReparsePoint { param( [Parameter(Mandatory = $true)] [string]$TemporaryRoot, [Parameter(Mandatory = $true)] [string]$TargetPath ) $root = [System.IO.Path]::GetFullPath($TemporaryRoot).TrimEnd( [System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar ) $target = [System.IO.Path]::GetFullPath($TargetPath) $rootPrefix = $root + [System.IO.Path]::DirectorySeparatorChar if (-not $target.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { throw "-SmokeDataPath must be a child of the Windows temporary directory." } if ( (Test-Path -LiteralPath $target) -and -not (Test-Path -LiteralPath $target -PathType Container) ) { throw "-SmokeDataPath exists but is not a directory." } $existingAncestor = $target while (-not (Test-Path -LiteralPath $existingAncestor -PathType Container)) { $parent = [System.IO.Directory]::GetParent($existingAncestor) if ($null -eq $parent) { throw "Could not resolve an existing ancestor for -SmokeDataPath." } $existingAncestor = $parent.FullName } if ( -not [string]::Equals( $existingAncestor, $root, [System.StringComparison]::OrdinalIgnoreCase ) -and -not $existingAncestor.StartsWith( $rootPrefix, [System.StringComparison]::OrdinalIgnoreCase ) ) { throw "-SmokeDataPath resolves through an ancestor outside the temporary directory." } if ($existingAncestor.Length -gt $rootPrefix.Length) { $relativeAncestor = $existingAncestor.Substring($rootPrefix.Length) $cursor = $root foreach ($part in ($relativeAncestor -split "[\\/]")) { if ([string]::IsNullOrWhiteSpace($part)) { continue } $cursor = Join-Path -Path $cursor -ChildPath $part $attributes = (Get-Item -LiteralPath $cursor -Force).Attributes if (($attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw "-SmokeDataPath must not traverse a junction, symlink, or other reparse point." } } } return $target } function Import-VisualStudioBuildEnvironment { $originalPath = $env:Path $programFilesX86 = [Environment]::GetFolderPath( [Environment+SpecialFolder]::ProgramFilesX86 ) $vswherePath = Join-Path ` -Path $programFilesX86 ` -ChildPath "Microsoft Visual Studio\Installer\vswhere.exe" if (-not (Test-Path -LiteralPath $vswherePath -PathType Leaf)) { throw "Visual Studio Installer's vswhere.exe was not found. Install Microsoft C++ Build Tools." } $installationPath = ( & $vswherePath ` -latest ` -products "*" ` -requires "Microsoft.VisualStudio.Component.VC.Tools.x86.x64" ` -property installationPath ).Trim() if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($installationPath)) { throw "Microsoft C++ Build Tools were not found." } $devCommandPath = Join-Path ` -Path $installationPath ` -ChildPath "Common7\Tools\VsDevCmd.bat" if (-not (Test-Path -LiteralPath $devCommandPath -PathType Leaf)) { throw "VsDevCmd.bat was not found in the selected Visual Studio installation." } $commandLine = 'call "' + $devCommandPath + '" -arch=x64 -host_arch=x64 >nul && set' $environmentLines = @(& $env:ComSpec /d /c $commandLine) if ($LASTEXITCODE -ne 0) { throw "Visual Studio x64 developer environment initialization failed." } $developerPath = $null foreach ($line in $environmentLines) { $separator = $line.IndexOf("=") if ($separator -le 0) { continue } if ($line.StartsWith("PATH=", [System.StringComparison]::Ordinal)) { $developerPath = $line.Substring($separator + 1) continue } [Environment]::SetEnvironmentVariable( $line.Substring(0, $separator), $line.Substring($separator + 1), "Process" ) } if ([string]::IsNullOrWhiteSpace($developerPath)) { throw "Visual Studio did not publish a developer PATH." } $pathSegments = @($developerPath) $userProfilePath = $env:USERPROFILE if ([string]::IsNullOrWhiteSpace($userProfilePath)) { $userProfilePath = [Environment]::GetFolderPath( [Environment+SpecialFolder]::UserProfile ) } $cargoBinPath = Join-Path ` -Path $userProfilePath ` -ChildPath ".cargo\bin" if (Test-Path -LiteralPath (Join-Path -Path $cargoBinPath -ChildPath "rustup.exe") -PathType Leaf) { $pathSegments += $cargoBinPath } $pathSegments += $originalPath $env:Path = $pathSegments -join [System.IO.Path]::PathSeparator foreach ($requiredTool in @("cl.exe", "link.exe", "rc.exe")) { $tool = Get-Command ` -Name $requiredTool ` -CommandType Application ` -ErrorAction SilentlyContinue | Select-Object -First 1 if ($null -eq $tool) { throw "$requiredTool was not found after loading Visual Studio Build Tools and the Windows SDK." } } Write-Host ("[ok] Visual Studio x64 C++ Build Tools and Windows SDK from {0}" -f $installationPath) } if ($Demo -and -not $Launch) { throw "-Demo is only valid together with -Launch." } if ($Launch -and [string]::IsNullOrWhiteSpace($SmokeDataPath)) { throw "-Launch requires -SmokeDataPath so smoke runs cannot touch the normal app database." } if (-not $Launch -and -not [string]::IsNullOrWhiteSpace($SmokeDataPath)) { throw "-SmokeDataPath is only valid together with -Launch." } Import-VisualStudioBuildEnvironment $projectRoot = [System.IO.Path]::GetFullPath((Join-Path -Path $PSScriptRoot -ChildPath "..")) $packageJsonPath = Join-Path -Path $projectRoot -ChildPath "package.json" $lappLockPath = Join-Path -Path $projectRoot -ChildPath "lapp-rs.lock" $modulesManifestPath = Join-Path -Path $projectRoot -ChildPath "node_modules\.modules.yaml" $installedLockPath = Join-Path -Path $projectRoot -ChildPath "node_modules\.pnpm\lock.yaml" $workspaceLockPath = Join-Path -Path $projectRoot -ChildPath "pnpm-lock.yaml" if (-not (Test-Path -LiteralPath $packageJsonPath -PathType Leaf)) { throw "package.json was not found next to the scripts directory." } if (-not (Test-Path -LiteralPath $lappLockPath -PathType Leaf)) { throw "lapp-rs.lock was not found in the project root." } $gitPath = Find-NativeCommand -Names @("git.exe", "git") -DisplayName "Git" $rustupPath = Find-NativeCommand -Names @("rustup.exe", "rustup") -DisplayName "rustup" $nodePath = Find-NativeCommand -Names @("node.exe", "node") -DisplayName "Node.js" $pnpmPath = Find-NativeCommand -Names @("pnpm.cmd", "pnpm.exe", "pnpm") -DisplayName "pnpm" $gitVersion = Invoke-NativeCapture -FilePath $gitPath -ArgumentList @("--version") -Label "Git version check" Write-Host ("[ok] {0}" -f $gitVersion) $installedToolchains = Invoke-NativeCapture ` -FilePath $rustupPath ` -ArgumentList @("toolchain", "list") ` -Label "rustup toolchain check" if ($installedToolchains -notmatch "(?m)^1\.96\.0(?:-|\s|$)") { throw "Rust 1.96.0 is not installed. Install that toolchain outside this script and retry." } $rustVersion = Invoke-NativeCapture ` -FilePath $rustupPath ` -ArgumentList @("run", "1.96.0", "rustc", "--version") ` -Label "Rust 1.96 version check" if ($rustVersion -notmatch "^rustc 1\.96\.0(?:\s|$)") { throw "Expected rustc 1.96.0, received: $rustVersion" } $rustVerboseVersion = Invoke-NativeCapture ` -FilePath $rustupPath ` -ArgumentList @("run", "1.96.0", "rustc", "-vV") ` -Label "Rust host check" if ($rustVerboseVersion -notmatch "(?m)^host:\s+\S+-pc-windows-msvc\s*$") { throw "Rust 1.96.0 must use a *-pc-windows-msvc host for this Windows smoke." } $installedComponents = Invoke-NativeCapture ` -FilePath $rustupPath ` -ArgumentList @("component", "list", "--toolchain", "1.96.0", "--installed") ` -Label "Rust component check" if ($installedComponents -notmatch "(?m)^rustfmt-") { throw "rustfmt is missing from Rust 1.96.0. Install it outside this script and retry." } if ($installedComponents -notmatch "(?m)^clippy-") { throw "clippy is missing from Rust 1.96.0. Install it outside this script and retry." } Write-Host ("[ok] {0}; rustfmt and clippy are installed" -f $rustVersion) $nodeVersion = Invoke-NativeCapture -FilePath $nodePath -ArgumentList @("--version") -Label "Node.js version check" if ($nodeVersion -notmatch "^v(\d+)\.(\d+)\.(\d+)") { throw "Could not parse the Node.js version: $nodeVersion" } $nodeMajor = [int]$Matches[1] if ($nodeMajor -lt 24) { throw "Node.js 24 or newer is required; received $nodeVersion." } Write-Host ("[ok] Node.js {0}" -f $nodeVersion) $packageManifest = Get-Content -LiteralPath $packageJsonPath -Raw | ConvertFrom-Json $packageManager = [string]$packageManifest.packageManager if ($packageManager -notmatch "^pnpm@(.+)$") { throw "package.json does not declare an exact pnpm packageManager version." } $expectedPnpmVersion = $Matches[1] $pnpmVersion = Invoke-NativeCapture -FilePath $pnpmPath -ArgumentList @("--version") -Label "pnpm version check" if ($pnpmVersion -ne $expectedPnpmVersion) { throw "pnpm $expectedPnpmVersion is required; received $pnpmVersion." } Write-Host ("[ok] pnpm {0}" -f $pnpmVersion) $insideWorkTree = Invoke-NativeCapture ` -FilePath $gitPath ` -ArgumentList @("-C", $projectRoot, "rev-parse", "--is-inside-work-tree") ` -Label "nana-story Git checkout check" if ($insideWorkTree -ne "true") { throw "The project directory is not a Git working tree." } $projectBranch = Invoke-NativeCapture ` -FilePath $gitPath ` -ArgumentList @("-C", $projectRoot, "branch", "--show-current") ` -Label "nana-story branch check" if ($projectBranch -ne "integration/v1") { throw "nana-story must be checked out on integration/v1; received '$projectBranch'." } $projectCommit = Invoke-NativeCapture ` -FilePath $gitPath ` -ArgumentList @("-C", $projectRoot, "rev-parse", "HEAD") ` -Label "nana-story commit check" if ($projectCommit -notmatch "^[0-9a-fA-F]{40}$") { throw "nana-story HEAD is not a valid Git commit." } $projectWorkTreeStatus = Invoke-NativeCapture ` -FilePath $gitPath ` -ArgumentList @("-C", $projectRoot, "status", "--porcelain=v1", "--untracked-files=all") ` -Label "nana-story clean-worktree check" if (-not [string]::IsNullOrWhiteSpace($projectWorkTreeStatus)) { throw "nana-story has tracked, staged, or untracked changes; smoke evidence would be ambiguous." } $projectUpstream = Invoke-NativeCapture ` -FilePath $gitPath ` -ArgumentList @("-C", $projectRoot, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}") ` -Label "nana-story upstream check" if ($projectUpstream -ne "origin/integration/v1") { throw "integration/v1 must track origin/integration/v1; received '$projectUpstream'." } Invoke-NativeChecked ` -FilePath $gitPath ` -ArgumentList @( "-C", $projectRoot, "fetch", "--prune", "origin", "+refs/heads/integration/v1:refs/remotes/origin/integration/v1" ) ` -Label "fetch current origin/integration/v1" $projectUpstreamCommit = Invoke-NativeCapture ` -FilePath $gitPath ` -ArgumentList @("-C", $projectRoot, "rev-parse", $projectUpstream) ` -Label "nana-story upstream commit check" if ($projectUpstreamCommit -ne $projectCommit) { throw "nana-story is ahead of or behind its local origin/integration/v1 reference. Pull the published branch and retry." } Write-Host ( "[ok] nana-story integration/v1 at {0}" -f $projectCommit.Substring(0, 12) ) $lappLock = Get-Content -LiteralPath $lappLockPath -Raw if ($lappLock -notmatch '(?m)^\s*commit\s*=\s*"([0-9a-fA-F]{40})"\s*$') { throw "lapp-rs.lock does not contain a valid pinned commit." } $expectedLappCommit = $Matches[1].ToLowerInvariant() if ($lappLock -notmatch '(?m)^\s*repository\s*=\s*"([^"]+)"\s*$') { throw "lapp-rs.lock does not contain a repository." } $expectedLappRepository = Normalize-GitRemote -Url $Matches[1] if ($lappLock -notmatch '(?m)^\s*development_path\s*=\s*"([^"]+)"\s*$') { throw "lapp-rs.lock does not contain a development_path." } $lappDevelopmentPath = $Matches[1] $lappPath = [System.IO.Path]::GetFullPath( (Join-Path -Path $projectRoot -ChildPath $lappDevelopmentPath) ) if (-not (Test-Path -LiteralPath (Join-Path -Path $lappPath -ChildPath "Cargo.toml") -PathType Leaf)) { throw "The adjacent lapp-rs checkout required by lapp-rs.lock was not found." } $actualLappCommit = Invoke-NativeCapture ` -FilePath $gitPath ` -ArgumentList @("-C", $lappPath, "rev-parse", "HEAD") ` -Label "lapp-rs commit check" $actualLappCommit = $actualLappCommit.ToLowerInvariant() if ($actualLappCommit -ne $expectedLappCommit) { throw "lapp-rs is not at the commit pinned by lapp-rs.lock." } $actualLappRepository = Invoke-NativeCapture ` -FilePath $gitPath ` -ArgumentList @("-C", $lappPath, "remote", "get-url", "origin") ` -Label "lapp-rs origin check" if ((Normalize-GitRemote -Url $actualLappRepository) -ne $expectedLappRepository) { throw "lapp-rs origin does not match the repository pinned by lapp-rs.lock." } $lappWorkTreeStatus = Invoke-NativeCapture ` -FilePath $gitPath ` -ArgumentList @("-C", $lappPath, "status", "--porcelain=v1", "--untracked-files=all") ` -Label "lapp-rs clean-worktree check" if (-not [string]::IsNullOrWhiteSpace($lappWorkTreeStatus)) { throw "lapp-rs has tracked, staged, or untracked changes; Cargo would not be building the exact pin." } Write-Host ("[ok] lapp-rs matches {0}" -f $expectedLappCommit.Substring(0, 12)) # Keep all automated gates deterministic and noninteractive. Visual Studio's # developer shell is imported into this process; environment values are never # printed or persisted by this script. $env:CI = "true" $env:NO_COLOR = "1" $env:CARGO_TERM_COLOR = "never" $env:RUSTUP_TOOLCHAIN = "1.96.0" $env:COREPACK_ENABLE_DOWNLOAD_PROMPT = "0" $previousStoryDataDir = [Environment]::GetEnvironmentVariable( "NANA_STORY_SMOKE_DATA_DIR", "Process" ) $previousStoryProvider = [Environment]::GetEnvironmentVariable("NANA_STORY_PROVIDER", "Process") $resolvedSmokeDataPath = $null if ($Launch) { $temporaryRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()) $resolvedSmokeDataPath = Assert-TemporaryChildWithoutReparsePoint ` -TemporaryRoot $temporaryRoot ` -TargetPath $SmokeDataPath } $locationPushed = $false try { Push-Location -LiteralPath $projectRoot $locationPushed = $true if ($InstallDependencies) { Invoke-NativeChecked ` -FilePath $pnpmPath ` -ArgumentList @("--reporter=append-only", "install", "--frozen-lockfile") ` -Label "pnpm install --frozen-lockfile" } if ( -not (Test-Path -LiteralPath $modulesManifestPath -PathType Leaf) -or -not (Test-Path -LiteralPath $installedLockPath -PathType Leaf) ) { throw "Dependencies are missing. Re-run with -InstallDependencies to install them explicitly." } $modulesManifest = Get-Content -LiteralPath $modulesManifestPath -Raw | ConvertFrom-Json if ( -not ($modulesManifest.PSObject.Properties.Name -contains "packageManager") -or [string]$modulesManifest.packageManager -ne "pnpm@$expectedPnpmVersion" ) { throw "node_modules was installed by a different pnpm version. Re-run with -InstallDependencies." } $workspaceLockHash = (Get-FileHash -LiteralPath $workspaceLockPath -Algorithm SHA256).Hash $installedLockHash = (Get-FileHash -LiteralPath $installedLockPath -Algorithm SHA256).Hash if ($workspaceLockHash -ne $installedLockHash) { throw "node_modules does not match pnpm-lock.yaml. Re-run with -InstallDependencies." } Write-Host "[ok] node_modules matches the pinned pnpm version and lockfile." Invoke-NativeChecked ` -FilePath $pnpmPath ` -ArgumentList @("--reporter=append-only", "verify") ` -Label "pnpm verify" Invoke-NativeChecked ` -FilePath $pnpmPath ` -ArgumentList @("--reporter=append-only", "tauri", "build", "--no-bundle") ` -Label "pnpm tauri build --no-bundle" Write-Host "[ok] Windows developer verification and non-bundled desktop build passed." if ($Launch) { New-Item -ItemType Directory -Path $resolvedSmokeDataPath -Force | Out-Null $null = Assert-TemporaryChildWithoutReparsePoint ` -TemporaryRoot $temporaryRoot ` -TargetPath $resolvedSmokeDataPath $env:NANA_STORY_SMOKE_DATA_DIR = $resolvedSmokeDataPath if ($Demo) { $env:NANA_STORY_PROVIDER = "demo" Write-Host "[run] pnpm tauri dev (deterministic demo provider)" } else { Remove-Item -Path Env:NANA_STORY_PROVIDER -ErrorAction SilentlyContinue Write-Host "[run] pnpm tauri dev (default LAPP provider)" } Invoke-NativeChecked ` -FilePath $pnpmPath ` -ArgumentList @("tauri", "dev") ` -Label "pnpm tauri dev" } else { Write-Host "[done] Re-run with -Launch, optionally with -Demo, to open the desktop app." } } finally { if ($locationPushed) { Pop-Location } if ($null -eq $previousStoryDataDir) { Remove-Item -Path Env:NANA_STORY_SMOKE_DATA_DIR -ErrorAction SilentlyContinue } else { $env:NANA_STORY_SMOKE_DATA_DIR = $previousStoryDataDir } if ($null -eq $previousStoryProvider) { Remove-Item -Path Env:NANA_STORY_PROVIDER -ErrorAction SilentlyContinue } else { $env:NANA_STORY_PROVIDER = $previousStoryProvider } }