# FlintTrade one-line web-app installer (Windows 10/11) # # Installs the FlintTrade web app on a bare machine with no prerequisites at # all: no uv, no Python, no Node, no pnpm, no git and no make. Every tool is # downloaded from its pinned URL, SHA-256 verified against the repository's own # bootstrap tool manifest, and confined to ~\.flinttrade\tools. The build itself # is delegated to the repository's packaged bootstrap entrypoint so this # installer never duplicates the uv/pnpm build behaviour. # # irm https://flinttrade.vercel.app/web-install.ps1 | iex # # Uninstall: # irm https://flinttrade.vercel.app/uninstall.ps1 | iex # # Public-site origin used in help/uninstall examples: # 1. FLINTTRADE_SITE_URL (Hostinger / custom-domain override) # 2. The https origin this script was fetched from, when recoverable # 3. https://flinttrade.vercel.app # # Written for stock Windows PowerShell 5.1: no '&&', no ternary, no '??'. param( [string]$Ref = $env:FLINTTRADE_REF, [string]$SrcDir = "", [switch]$Yes = ($env:FLINTTRADE_YES -eq "1"), [switch]$DryRun = ($env:FLINTTRADE_DRY_RUN -eq "1"), [switch]$NoLaunch = ($env:FLINTTRADE_NO_LAUNCH -eq "1"), [switch]$Help ) $ErrorActionPreference = "Stop" $ProgressPreference = "SilentlyContinue" $RepoSlug = "navaneeshnagarajan/FlintTrade" $RepoUrl = "https://github.com/$RepoSlug.git" $ArchiveBaseUrl = "https://codeload.github.com/$RepoSlug/zip" $DefaultBranch = "main" $PinnedPnpmVersion = "10.34.5" $BackendUrl = "http://127.0.0.1:5100" $CanonicalSiteOrigin = "https://flinttrade.vercel.app" function Get-FlintTradeNormalisedSiteOrigin { param([string]$Raw) if ([string]::IsNullOrWhiteSpace($Raw)) { return $null } $trimmed = $Raw.Trim().TrimEnd('/') if ($trimmed -notmatch '^https://[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?(?::\d{1,5})?$') { return $null } return $trimmed } function Get-FlintTradeSiteOrigin { $fromEnv = Get-FlintTradeNormalisedSiteOrigin $env:FLINTTRADE_SITE_URL if ($fromEnv) { return $fromEnv } $cmd = $null try { $proc = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$PID" -ErrorAction Stop $cmd = $proc.CommandLine if (-not $cmd -and $proc.ParentProcessId) { $parent = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$($proc.ParentProcessId)" -ErrorAction Stop $cmd = $parent.CommandLine } } catch { $cmd = $null } if ($cmd) { $match = [regex]::Match($cmd, 'https://[A-Za-z0-9._:-]+/(?:web-install|install|uninstall)\.ps1') if ($match.Success) { $hostPart = ($match.Value -replace '^https://', '') -replace '/.*$', '' $blocked = @('raw.githubusercontent.com', 'github.com', 'api.github.com', 'codeload.github.com') if ($blocked -notcontains $hostPart) { $inferred = Get-FlintTradeNormalisedSiteOrigin ("https://" + $hostPart) if ($inferred) { return $inferred } } } } return $CanonicalSiteOrigin } $UninstallCommand = "irm $(Get-FlintTradeSiteOrigin)/uninstall.ps1 | iex" $ManagedRoot = Join-Path $HOME ".flinttrade" $ToolsRoot = Join-Path $ManagedRoot "tools" # The Electron desktop shell resolves exactly these two paths # (packages\apps\desktop\electron\paths.ts) and guards every mutation of the # active source with an operation-lease DIRECTORY created by its bootstrap # (packages\apps\desktop\electron\bootstrap.ts, acquireOperationLease in # bootstrap-io.ts). # # That lease is deliberately NOT a mutual-exclusion primitive an outside process # can hold: on acquisition the desktop treats any lease it finds as recoverable # stale evidence, waits only for the process records INSIDE it, then quarantines # and deletes it. A lease carrying nothing but an owner.json is therefore stolen # immediately, and the only records the desktop respects are POSIX # process-group / Windows supervisor files bound to a containment token minted # by the Electron singleton, which this installer cannot mint. # # So this installer never shares the desktop's active source tree. It keeps its # own checkout under $WebSourceRoot and refuses a -SrcDir that would aim it at # the desktop's, which is what makes both installers safe in either order. $DesktopSourceRoot = Join-Path $ManagedRoot "src" $DesktopActiveSource = Join-Path $DesktopSourceRoot "FlintTrade" $DesktopOperationLease = Join-Path $DesktopSourceRoot ".flinttrade-bootstrap-operation.lock" $WebSourceRoot = Join-Path $ManagedRoot "web-src" $WebActiveSource = Join-Path $WebSourceRoot "FlintTrade" $ManifestRelative = "packages\apps\desktop\resources\bootstrap\tool-manifest.json" $BootstrapRelative = "packages\apps\desktop\resources\bootstrap\flinttrade-bootstrap.ps1" $LocalAppDataRoot = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData) $RoamingAppDataRoot = [Environment]::GetFolderPath([Environment+SpecialFolder]::ApplicationData) # %LOCALAPPDATA%\Programs\FlintTrade is the electron-builder per-user install # directory, and flinttrade-install.ps1's Assert-WindowsFreshInstallAdmission # refuses a fresh desktop install whenever that directory exists without exactly # one proven FlintTrade uninstall-registry identity. A web launcher dropped in # there therefore locked the documented desktop installer out of the machine # permanently, so the web launcher gets its own directory and its own name — and # its own Start Menu folder, which the desktop uninstaller's shortcut sweep # would otherwise report as non-empty residue. # # Each is guarded on its root: Join-Path throws a raw binder error on an empty # Path, which would pre-empt the friendly "could not resolve the Windows # application-data folders" failure below on a machine whose profile folders do # not resolve. $ShimDir = if ($LocalAppDataRoot) { Join-Path $LocalAppDataRoot "Programs\FlintTradeWeb" } else { "" } $ShimPath = if ($ShimDir) { Join-Path $ShimDir "flinttrade-web.cmd" } else { "" } $StartMenuRoot = if ($RoamingAppDataRoot) { Join-Path $RoamingAppDataRoot "Microsoft\Windows\Start Menu\Programs" } else { "" } $StartMenuDir = if ($StartMenuRoot) { Join-Path $StartMenuRoot "FlintTrade Web" } else { "" } $StartMenuShortcut = if ($StartMenuDir) { Join-Path $StartMenuDir "FlintTrade Web.lnk" } else { "" } # Where earlier revisions of this installer wrote both. Kept only so a re-run # can retire what its own receipt still proves it created. $LegacyShimDir = if ($LocalAppDataRoot) { Join-Path $LocalAppDataRoot "Programs\FlintTrade" } else { "" } $LegacyShimPath = if ($LegacyShimDir) { Join-Path $LegacyShimDir "flinttrade.cmd" } else { "" } $LegacyStartMenuDir = if ($StartMenuRoot) { Join-Path $StartMenuRoot "FlintTrade" } else { "" } $LegacyStartMenuShortcut = if ($LegacyStartMenuDir) { Join-Path $LegacyStartMenuDir "FlintTrade.lnk" } else { "" } # Owner-private receipt for everything this installer writes outside the managed # root. The uninstaller may only delete what an installer proved it created. $WebReceiptDir = if ($LocalAppDataRoot) { Join-Path $LocalAppDataRoot "flinttrade-web" } else { "" } $WebReceiptPath = if ($WebReceiptDir) { Join-Path $WebReceiptDir "web-install.receipt" } else { "" } # The allow-list constrains every URL this installer requests. GitHub redirects # release assets on to its own object CDN, so integrity is enforced by the # SHA-256 verification below rather than by the redirect target's hostname. $AllowedHosts = @( "codeload.github.com", "github.com", "api.github.com", "nodejs.org", "registry.npmjs.org" ) # param(...) declares $SrcDir in the SCRIPT scope, so the $script:SrcDir reset # below overwrote the operator's -SrcDir before Resolve-WebSourceDir could read # it: the flag was silently ignored and the default managed checkout used # instead. Capture the supplied value first. $SrcDirParameter = $SrcDir $script:Target = "" $script:SrcDir = "" $script:SrcDirSource = "default" $script:HostGitVersion = "" $script:HostUvVersion = "" $script:HostNodeVersion = "" $script:HostPnpmVersion = "" $script:HostPythonVersion = "" $script:ReuseUv = "" $script:ReuseNode = "" $script:ReuseCorepackJs = "" # Set when repository markers - which every contributor clone carries - are the # only identity proof for the managed source. They authorise an in-place # 'git fetch + reset' and never the recursive replacement a git-less refresh # performs. $script:SourceUpdateOnly = $false # Set to the destination when this run moved a legacy web checkout out of the # desktop shell's source root; the receipt keeps naming the old path until it is # rewritten at the end of the run. $script:MigratedWebSource = "" # Set by Get-CanonicalOverlapPath when a path traverses a reparse point whose # target could not be read, so the caller can refuse rather than compare a path # it could not canonicalise. $script:OverlapPathUnresolved = $false function Say([string]$Message) { Write-Host "[flinttrade] $Message" -ForegroundColor Cyan } function Warn([string]$Message) { Write-Host "[flinttrade] $Message" -ForegroundColor Yellow } function Fail([string]$Message) { Write-Host "[flinttrade] ERROR: $Message" -ForegroundColor Red throw "[flinttrade] $Message" } function Have([string]$Command) { return ($null -ne (Get-Command $Command -ErrorAction SilentlyContinue)) } function Test-FlintTradeProcessIsElevated { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() try { $principal = New-Object Security.Principal.WindowsPrincipal($identity) return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } finally { $identity.Dispose() } } function Show-Usage { Write-Host @" FlintTrade one-line web-app installer (Windows 10/11) Provisions a verified uv + Node toolchain, builds FlintTrade from a managed source checkout, and installs a 'flinttrade-web' launcher. No prior tooling needed. The FlintTrade Desktop shell is a separate install with its own launcher, Start Menu entry, source checkout and uninstall receipt; neither one touches the other's files, so the two can be installed in either order. Run this per-user installer from a normal (non-Administrator) PowerShell window. Flags: -Ref Branch, tag or commit to install (default: main) -SrcDir Managed WEB source checkout (default: %USERPROFILE%\.flinttrade\web-src\FlintTrade). This is neither the contributor source-build checkout that flinttrade-install.ps1 manages at %USERPROFILE%\.flinttrade\source-build\FlintTrade nor the desktop shell's active source at %USERPROFILE%\.flinttrade\src\FlintTrade, which is refused. An existing directory is REPLACED only when this installer's own receipt names it. A clean FlintTrade Git checkout it cannot claim is updated in place instead; anything else is refused, so a typo cannot destroy unrelated source. -Yes Answer every confirmation with yes -NoLaunch Do not offer to start FlintTrade after installing -DryRun Report the plan without downloading, building or installing -Help Show this help and exit Environment overrides: FLINTTRADE_REF, FLINTTRADE_WEB_SRC_DIR, FLINTTRADE_YES, FLINTTRADE_DRY_RUN, FLINTTRADE_NO_LAUNCH, FLINTTRADE_SITE_URL FLINTTRADE_SITE_URL is the public https origin used in help/uninstall examples on a custom domain. A piped irm|iex cannot always see the URL it was fetched from; set this on the machine running the installer if the printed uninstall command should use that origin. Otherwise it falls back to $CanonicalSiteOrigin. FLINTTRADE_SRC_DIR is a deprecated fallback for FLINTTRADE_WEB_SRC_DIR here. flinttrade-install.ps1 reads it as the contributor source-build checkout, so this installer warns and asks for confirmation before managing a directory that only that variable supplied. Uninstall: irm $(Get-FlintTradeSiteOrigin)/uninstall.ps1 | iex "@ } function Get-WorkspaceDir { if ($env:FLINTTRADE_WORKSPACE_DIR) { return $env:FLINTTRADE_WORKSPACE_DIR } if ($env:FLINTTRADE_HOME) { return $env:FLINTTRADE_HOME } return (Join-Path $RoamingAppDataRoot "flinttrade") } # --------------------------------------------------------------------------- # 1. Operating system and CPU architecture detection # --------------------------------------------------------------------------- function Get-WindowsArch { try { $processor = Get-CimInstance -ClassName Win32_Processor -ErrorAction Stop | Select-Object -First 1 switch ([int]$processor.Architecture) { 12 { return "arm64" } 9 { return "x64" } 0 { return "x86" } } } catch { # Fall through to environment detection. } if ($env:PROCESSOR_ARCHITEW6432 -eq "ARM64" -or $env:PROCESSOR_ARCHITECTURE -eq "ARM64") { return "arm64" } if ($env:PROCESSOR_ARCHITEW6432 -eq "AMD64" -or $env:PROCESSOR_ARCHITECTURE -eq "AMD64") { return "x64" } if ([Environment]::Is64BitOperatingSystem) { return "x64" } return "x86" } function Get-BootstrapTarget { $arch = Get-WindowsArch if ($arch -eq "x64") { return "win32-x64" } if ($arch -eq "arm64") { Fail ("The bootstrap tool manifest pins no win32-arm64 toolchain. On a Windows on ARM device, " + "run this installer from an x64 PowerShell session so Windows provides x64 emulation, " + "and it will provision the verified win32-x64 tools.") } Fail "Unsupported Windows CPU architecture '$arch'. FlintTrade requires Windows 10/11 on x64." } # --------------------------------------------------------------------------- # Network guards # --------------------------------------------------------------------------- function Assert-TrustedUrl([string]$Url) { $uri = $null if (-not [Uri]::TryCreate($Url, [UriKind]::Absolute, [ref]$uri)) { Fail "Refusing a malformed URL: $Url" } if ($uri.Scheme -ne "https") { Fail "Refusing a non-HTTPS URL: $Url" } if (-not ($AllowedHosts -contains $uri.Host.ToLowerInvariant())) { Fail "Refusing to contact '$($uri.Host)'. This installer only ever contacts: $($AllowedHosts -join ', ')" } } function Assert-Sha256Shape([string]$Value) { if ($Value -notmatch '^[0-9a-f]{64}$') { Fail "The bootstrap tool manifest holds a malformed SHA-256 digest." } } function Assert-ConfinedRelativePath([string]$Value) { if ([string]::IsNullOrWhiteSpace($Value)) { Fail "The bootstrap tool manifest holds an empty executable path." } if ($Value.Contains("..") -or [System.IO.Path]::IsPathRooted($Value)) { Fail "The bootstrap tool manifest holds an unconfined executable path: $Value" } } function Save-TrustedFile([string]$Url, [string]$Destination) { Assert-TrustedUrl $Url $parent = Split-Path -Parent $Destination if (-not (Test-Path -LiteralPath $parent -PathType Container)) { New-Item -ItemType Directory -Force -Path $parent | Out-Null } try { Invoke-WebRequest -Uri $Url -OutFile $Destination -UseBasicParsing } catch { Fail "Download failed for ${Url}: $($_.Exception.Message)" } } function Assert-FileSha256([string]$Path, [string]$Expected) { $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant() if ($actual -ne $Expected.ToLowerInvariant()) { Remove-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue Fail "SHA-256 mismatch for $(Split-Path -Leaf $Path): expected $Expected, got $actual. The download was deleted." } } function Expand-VerifiedArchive([string]$Archive, [string]$Kind, [string]$Destination) { if (-not (Test-Path -LiteralPath $Destination -PathType Container)) { New-Item -ItemType Directory -Force -Path $Destination | Out-Null } if ($Kind -eq "zip") { Expand-Archive -LiteralPath $Archive -DestinationPath $Destination -Force return } if ($Kind -eq "tar.gz") { if (-not (Have tar)) { Fail "tar.exe is required to extract $Archive on this machine." } & tar -xzf $Archive -C $Destination if ($LASTEXITCODE -ne 0) { Fail "Could not extract $Archive." } return } Fail "The bootstrap tool manifest requested an unrecognised archive kind: $Kind" } function Confirm-Step([string]$Prompt) { if ($Yes) { return $true } if (-not [Environment]::UserInteractive) { Fail "Non-interactive session: cannot ask '$Prompt'. Re-run with -Yes (or FLINTTRADE_YES=1) to confirm every step." } $answer = "" try { $answer = Read-Host "[flinttrade] $Prompt [Y/n]" } catch { Fail "Could not read a confirmation for '$Prompt'. Re-run with -Yes (or FLINTTRADE_YES=1) to confirm every step." } if ([string]::IsNullOrWhiteSpace($answer)) { return $true } $normalised = $answer.Trim().ToLowerInvariant() if ($normalised -eq "y" -or $normalised -eq "yes") { return $true } return $false } function Write-OemText([string]$Path, [string]$Content) { $encoding = $null try { $encoding = [System.Text.Encoding]::GetEncoding([System.Globalization.CultureInfo]::CurrentCulture.TextInfo.OEMCodePage) } catch { $encoding = [System.Text.Encoding]::ASCII } [System.IO.File]::WriteAllText($Path, $Content, $encoding) } # --------------------------------------------------------------------------- # 2. Preflight - what is already on this machine? # --------------------------------------------------------------------------- function Get-ToolVersion([string]$Command, [string[]]$VersionArgs) { $resolved = Get-Command $Command -ErrorAction SilentlyContinue if (-not $resolved) { return "" } $source = [string]$resolved.Source if ($source -and $source -like "*\WindowsApps\*") { return "Microsoft Store alias (not a usable interpreter)" } try { $output = & $Command @VersionArgs } catch { return "" } if (-not $output) { return "" } return ([string](@($output)[0])).Trim() } function Write-PreflightReport { $script:HostGitVersion = Get-ToolVersion "git" @("--version") $script:HostUvVersion = Get-ToolVersion "uv" @("--version") $script:HostNodeVersion = Get-ToolVersion "node" @("--version") $script:HostPnpmVersion = Get-ToolVersion "pnpm" @("--version") $script:HostPythonVersion = Get-ToolVersion "python" @("--version") if (-not $script:HostPythonVersion) { $script:HostPythonVersion = Get-ToolVersion "py" @("-3", "--version") } Say "Preflight - tools already present on this machine:" Say (" git : " + $(if ($script:HostGitVersion) { $script:HostGitVersion } else { "not found" })) Say (" uv : " + $(if ($script:HostUvVersion) { $script:HostUvVersion } else { "not found" })) Say (" node : " + $(if ($script:HostNodeVersion) { $script:HostNodeVersion } else { "not found" })) Say (" pnpm : " + $(if ($script:HostPnpmVersion) { $script:HostPnpmVersion } else { "not found" })) Say (" python : " + $(if ($script:HostPythonVersion) { $script:HostPythonVersion } else { "not found" })) Say "A host tool is reused only when it matches the pinned version exactly; anything else is provisioned privately." } function Get-CorepackJsForNode([string]$NodeExecutable) { return (Join-Path (Split-Path -Parent $NodeExecutable) "node_modules\corepack\dist\corepack.js") } function Resolve-HostToolReuse([string]$PinnedUv, [string]$PinnedNode) { $script:ReuseUv = "" $script:ReuseNode = "" $script:ReuseCorepackJs = "" if ($script:HostUvVersion) { $reported = ($script:HostUvVersion -split '\s+')[1] if ($reported -eq $PinnedUv) { $script:ReuseUv = [string](Get-Command uv -ErrorAction SilentlyContinue).Source } } if ($script:HostNodeVersion) { $reported = $script:HostNodeVersion.TrimStart('v') if ($reported -eq $PinnedNode) { $candidate = [string](Get-Command node -ErrorAction SilentlyContinue).Source $corepackJs = Get-CorepackJsForNode $candidate if (Test-Path -LiteralPath $corepackJs -PathType Leaf) { $script:ReuseNode = $candidate $script:ReuseCorepackJs = $corepackJs } else { Warn "The host Node $reported matches the pin but carries no Corepack JavaScript; provisioning the verified Node instead." } } } } # --------------------------------------------------------------------------- # 3. Source acquisition # --------------------------------------------------------------------------- function Resolve-WebSourceDir { # FLINTTRADE_SRC_DIR belongs to flinttrade-install.ps1, where it names the # contributor source-build checkout. This installer fetches, hard-resets and # can replace whatever directory it is given, so it owns # FLINTTRADE_WEB_SRC_DIR instead and only falls back to the older name # behind a loud warning. if ($SrcDirParameter) { $script:SrcDir = $SrcDirParameter $script:SrcDirSource = "-SrcDir" return } if ($env:FLINTTRADE_WEB_SRC_DIR) { $script:SrcDir = $env:FLINTTRADE_WEB_SRC_DIR $script:SrcDirSource = "FLINTTRADE_WEB_SRC_DIR" return } if ($env:FLINTTRADE_SRC_DIR) { $script:SrcDir = $env:FLINTTRADE_SRC_DIR $script:SrcDirSource = "FLINTTRADE_SRC_DIR" return } $script:SrcDir = $WebActiveSource $script:SrcDirSource = "default" } function Confirm-SourceDirProvenance { if ($script:SrcDirSource -ne "FLINTTRADE_SRC_DIR") { return } Warn "FLINTTRADE_SRC_DIR is set, so this installer would manage $($script:SrcDir) as its web source checkout." Warn "flinttrade-install.ps1 reads that same variable as the CONTRIBUTOR source-build checkout" Warn "(default $(Join-Path $ManagedRoot 'source-build\FlintTrade')). This installer fetches, hard-resets" Warn "and can replace the directory it is given - including a multi-gigabyte built checkout." Warn "Set FLINTTRADE_WEB_SRC_DIR, or pass -SrcDir, to choose the web source checkout explicitly." if ($DryRun) { return } if (-not (Confirm-Step "Continue with $($script:SrcDir) as the managed web source checkout?")) { Fail "Cancelled at the source-checkout confirmation; nothing was changed." } } # The desktop's active source may never be this installer's source, at any point # in the run. # # Probing the lease once and then proceeding was not a guard at all: the fetch, # the hard reset and the multi-minute dependency build all run afterwards, and # FlintTrade Desktop launched at any moment in that window acquires its own # lease and mutates the very same checkout. Nor can this installer hold the # lease for the whole build - see the $DesktopOperationLease note at the top of # this file for why a lease written by a script is one the desktop quarantines # rather than respects. The only safe answer is separate trees. # Canonicalise a path whose parents need not exist yet, so the overlap guard # below compares trees rather than spellings: '.', '..', mixed separators and a # trailing separator are normalised, and every existing component that is a # reparse point is replaced by its target. A path whose reparse point cannot be # read leaves $script:OverlapPathUnresolved set, because a path this installer # could not canonicalise is one it cannot prove is not the desktop's own. function Get-CanonicalOverlapPath([string]$Value) { $script:OverlapPathUnresolved = $false if (-not $Value) { return "" } $full = "" try { $full = ([System.IO.Path]::GetFullPath($Value)).TrimEnd('\', '/') } catch { $script:OverlapPathUnresolved = $true return "" } $root = [System.IO.Path]::GetPathRoot($full) if (-not $root) { return $full } $relative = $full.Substring($root.Length).TrimStart('\', '/') $current = $root foreach ($component in @($relative -split '[\\/]' | Where-Object { $_ })) { $current = Join-Path $current $component if (-not (Test-Path -LiteralPath $current)) { continue } $item = $null try { $item = Get-Item -LiteralPath $current -Force -ErrorAction Stop } catch { $script:OverlapPathUnresolved = $true continue } if (-not ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { continue } $target = "" try { $target = [string](@($item.Target) | Select-Object -First 1) } catch { $target = "" } if (-not $target) { $script:OverlapPathUnresolved = $true continue } if (-not [System.IO.Path]::IsPathRooted($target)) { $target = Join-Path (Split-Path -Parent $current) $target } try { $current = ([System.IO.Path]::GetFullPath($target)).TrimEnd('\', '/') } catch { $script:OverlapPathUnresolved = $true } } return ([string]$current).TrimEnd('\', '/') } # Both sides are canonicalised first. Comparing the raw -SrcDir string was no # guard at all: '.\', '..\', a trailing separator, forward slashes and any # junctioned parent all spell the desktop's own tree while matching none of the # string tests below, so the installer went on to fetch, hard-reset and build # inside it. function Assert-DesktopSourceNotShared { $candidate = Get-CanonicalOverlapPath $script:SrcDir $unresolved = $script:OverlapPathUnresolved $root = Get-CanonicalOverlapPath $DesktopSourceRoot if ($script:OverlapPathUnresolved) { $unresolved = $true } if ($unresolved -or -not $candidate -or -not $root) { Fail ("Refusing $($script:SrcDir): it could not be canonicalised - one of its parents is a " + "reparse point this installer cannot read - so it cannot be proved to sit outside the " + "FlintTrade Desktop shell's source tree at $DesktopSourceRoot.") } $shared = $candidate.Equals($root, [StringComparison]::OrdinalIgnoreCase) -or $candidate.StartsWith($root + '\', [StringComparison]::OrdinalIgnoreCase) -or $root.StartsWith($candidate + '\', [StringComparison]::OrdinalIgnoreCase) if (-not $shared) { return } Warn "$($script:SrcDir) overlaps the FlintTrade Desktop shell's active source tree at $DesktopActiveSource." Warn "The desktop guards every mutation of that tree with the bootstrap operation lease at" Warn "$DesktopOperationLease, and only its own singleton can hold one: a lease written by this" Warn "installer would be treated as stale evidence and deleted, so a desktop launched during the" Warn "fetch or the multi-minute build would corrupt the checkout underneath it." Fail "Choose a source checkout outside $DesktopSourceRoot (the default is $WebActiveSource)." } function Test-FileContains([string]$Path, [string]$Needle) { if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $false } try { return ([System.IO.File]::ReadAllText($Path)).Contains($Needle) } catch { return $false } } function Test-WebPathContainsReparsePoint([string]$Target) { try { $full = ([System.IO.Path]::GetFullPath($Target)).TrimEnd('\', '/') $root = [System.IO.Path]::GetPathRoot($full) if (-not $root) { return $true } $relative = $full.Substring($root.Length).TrimStart('\', '/') $current = $root foreach ($component in @($relative -split '[\\/]' | Where-Object { $_ })) { $current = Join-Path $current $component if (-not (Test-Path -LiteralPath $current)) { continue } $item = Get-Item -LiteralPath $current -Force -ErrorAction Stop if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { return $true } } } catch { return $true } return $false } function Test-CurrentUserOnlyWebAcl( [string]$Target, [System.Security.AccessControl.InheritanceFlags]$ExpectedInheritance, [bool]$RequireProtected, [bool]$RequireExplicit ) { try { $acl = Get-Acl -LiteralPath $Target $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() $owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]) if (-not $owner -or $owner.Value -ne $identity.User.Value) { return $false } if ($RequireProtected -and -not $acl.AreAccessRulesProtected) { return $false } $rules = @($acl.Access) if ($rules.Count -ne 1) { return $false } $ruleIdentity = $rules[0].IdentityReference.Translate( [System.Security.Principal.SecurityIdentifier]) if ($RequireExplicit -and $rules[0].IsInherited) { return $false } return ( $ruleIdentity.Value -eq $identity.User.Value -and $rules[0].FileSystemRights -eq [System.Security.AccessControl.FileSystemRights]::FullControl -and $rules[0].InheritanceFlags -eq $ExpectedInheritance -and $rules[0].PropagationFlags -eq [System.Security.AccessControl.PropagationFlags]::None -and $rules[0].AccessControlType -eq [System.Security.AccessControl.AccessControlType]::Allow ) } catch { return $false } } function Test-TrustedWebReceiptAcl { $directoryInheritance = [System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit return ( (Test-CurrentUserOnlyWebAcl $WebReceiptDir $directoryInheritance $true $true) -and (Test-CurrentUserOnlyWebAcl $WebReceiptPath ` ([System.Security.AccessControl.InheritanceFlags]::None) $false $false) ) } # One field from the web-install receipt, and only when the receipt proves # itself exactly as flinttrade-uninstall.ps1's Read-WebInstallReceipt requires: # a current-user-only receipt reached through a path with no reparse alias in # it, and the v1 format line. The receipt authorises destructive replacement, so this is # the single reader every trusting caller goes through - a second, weaker one # would simply be the way round it. function Get-RecordedWebInstallField([string]$Prefix) { if (-not $WebReceiptPath -or -not $WebReceiptDir) { return "" } if (-not (Test-Path -LiteralPath $WebReceiptPath -PathType Leaf)) { return "" } if (Test-WebPathContainsReparsePoint $WebReceiptPath) { return "" } if (-not (Test-TrustedWebReceiptAcl)) { return "" } $lines = @() try { $lines = @(Get-Content -LiteralPath $WebReceiptPath -Encoding UTF8) } catch { return "" } if ($lines.Count -lt 1) { return "" } if (([string]$lines[0]) -cne "format=flinttrade-web-install-v1") { return "" } $value = "" foreach ($line in $lines) { if (([string]$line).StartsWith($Prefix, [StringComparison]::Ordinal)) { $value = ([string]$line).Substring($Prefix.Length) } } return $value } # The installer's own receipt is the ONLY proof that this installer created a # directory: it was written by a previous run and names the exact directory that # run managed. function Test-WebReceiptNamesSource([string]$Target) { $recorded = Get-RecordedWebInstallField "source=" if (-not $recorded) { return $false } $recorded = $recorded.TrimEnd('\') $candidate = ([string]$Target).TrimEnd('\') if ($candidate.Equals($recorded, [StringComparison]::OrdinalIgnoreCase)) { return $true } $candidateFull = Get-CanonicalOverlapPath $candidate if ($script:OverlapPathUnresolved -or -not $candidateFull) { return $false } $recordedFull = Get-CanonicalOverlapPath $recorded if ($script:OverlapPathUnresolved -or -not $recordedFull) { return $false } return $candidateFull.Equals($recordedFull, [StringComparison]::OrdinalIgnoreCase) } # Repository identity, not project shape and NOT ownership. flint.toml is this # repository's own project manifest and names the repository it belongs to; the # workspace member sets name FlintTrade's own packages. Revisions that predate # flint.toml are still recognised through the second pair. # # All of those files are checked into the repository, so every contributor clone # on earth satisfies this - including one holding a week of uncommitted work. # That is why the markers authorise only the in-place update in # Assert-SourceDirSafe and never a recursive replacement. function Test-FlintTradeSourceMarkers([string]$Target) { if (Test-FileContains (Join-Path $Target "flint.toml") $RepoSlug) { return $true } $hasPyproject = Test-FileContains (Join-Path $Target "pyproject.toml") "flinttrade-core" $hasWorkspace = Test-FileContains (Join-Path $Target "pnpm-workspace.yaml") "packages/apps/terminal" if ($hasPyproject -and $hasWorkspace) { return $true } return $false } # A git-less refresh removes the directory recursively before publishing the # downloaded checkout, so this guard decides whether unrelated source is # destroyed. Two separate questions decide it, and conflating them is what put a # contributor's working clone at risk: # # * is this directory FlintTrade's code? Repository markers answer that, and # every clone of this repository carries them; # * did THIS installer create this directory? Only its own receipt answers # that, and only that answer authorises a recursive replacement. # # So a receipt-proved directory may be replaced; a merely marker-proved one may # only be updated in place with 'git fetch' + 'git reset --hard', and not even # that while it holds uncommitted work. An empty or absent directory needs no # proof - there is nothing there to destroy - and an unproven one is refused # rather than emptied. function Assert-SourceDirSafe { if (-not (Test-Path -LiteralPath $script:SrcDir)) { return } $item = Get-Item -LiteralPath $script:SrcDir -Force if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { Fail "Refusing a reparse-point managed source path: $($script:SrcDir)" } if (-not ($item.PSIsContainer)) { Fail "Refusing a non-directory managed source path: $($script:SrcDir)" } $children = @(Get-ChildItem -LiteralPath $script:SrcDir -Force -ErrorAction SilentlyContinue) if ($children.Count -eq 0) { return } if (Test-WebReceiptNamesSource $script:SrcDir) { return } # A tree this run has just moved out of the desktop source root was proved by # that same receipt a moment ago; the receipt keeps naming the old path until # it is rewritten at the end of the run. if ($script:MigratedWebSource -and ([string]$script:MigratedWebSource).TrimEnd('\').Equals( ([string]$script:SrcDir).TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase)) { return } if (Test-FlintTradeSourceMarkers $script:SrcDir) { $markerWarning = @( "Refusing to replace $($script:SrcDir) - its FlintTrade markers prove the CODE is this", "repository's, not that this installer created the directory, and only a web-install", "receipt naming this exact path proves that." ) if (-not (Have git)) { $markerWarning | ForEach-Object { Warn $_ } Fail ("git is not installed here, so this checkout could only be replaced wholesale. " + "Install git so it can be updated in place, or pass -SrcDir (or set " + "FLINTTRADE_WEB_SRC_DIR) to a directory this installer manages.") } if (-not (Test-Path -LiteralPath (Join-Path $script:SrcDir ".git"))) { $markerWarning | ForEach-Object { Warn $_ } Fail ("It is not a Git checkout either, so it could only be replaced wholesale. Remove " + "it yourself if you really meant that path, or pass -SrcDir (or set " + "FLINTTRADE_WEB_SRC_DIR) to a directory this installer manages.") } $status = $null try { $status = @(& git -C $script:SrcDir status --porcelain) } catch { $status = $null } if ($null -eq $status -or $LASTEXITCODE -ne 0) { Warn "Refusing to update $($script:SrcDir) - 'git status' could not report whether it holds" Warn "uncommitted work, and this installer runs 'git reset --hard' on it." Fail ("Check that checkout by hand, or pass -SrcDir (or set FLINTTRADE_WEB_SRC_DIR) to a " + "directory this installer manages.") } $dirty = @($status | Where-Object { ([string]$_).Trim() }) if ($dirty.Count -gt 0) { Warn "Refusing to update $($script:SrcDir) - it is a FlintTrade checkout with uncommitted" Warn "changes, and no web-install receipt names it, so this installer cannot claim it" Warn "created it. The update runs 'git fetch' + 'git reset --hard', which would discard" Warn "that work." Fail ("Commit, stash or remove those changes, or pass -SrcDir (or set " + "FLINTTRADE_WEB_SRC_DIR) to a directory this installer manages.") } $script:SourceUpdateOnly = $true Say "No web-install receipt names $($script:SrcDir), so it will be updated in place (git fetch" Say "+ reset) rather than replaced; its FlintTrade markers prove the code, not this" Say "installer's ownership." return } Warn "Refusing to replace $($script:SrcDir) - nothing there proves it is a FlintTrade checkout." Warn "This installer deletes and republishes the directory it is given, so it replaces only a" Warn "directory a web-install receipt of its own names at this exact path. A clean Git checkout" Warn "carrying FlintTrade's identity - a flint.toml naming $RepoSlug, or FlintTrade's own" Warn "workspace members in pyproject.toml and pnpm-workspace.yaml - is updated in place instead." Fail ("Remove $($script:SrcDir) yourself if you really meant that path, or pass -SrcDir " + "(or set FLINTTRADE_WEB_SRC_DIR) to the intended checkout.") } # Evidence that the Electron desktop shell is installed on this machine: its # electron-builder per-user directory, or an uninstall-registry entry that names # it. %LOCALAPPDATA%\Programs\FlintTradeWeb is deliberately NOT in the list - # that is this installer's own launcher directory. function Test-DesktopShellInstalled { if ($LocalAppDataRoot) { $electronDir = Join-Path $LocalAppDataRoot "Programs\FlintTrade" if (Test-Path -LiteralPath $electronDir) { return $true } } try { $entries = @(Get-ItemProperty "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*" -ErrorAction SilentlyContinue) | Where-Object { $_.DisplayName -ceq "FlintTrade" } if (@($entries).Count -gt 0) { return $true } } catch { return $false } return $false } # --------------------------------------------------------------------------- # Upgrade path for machines installed by the revision that shared the desktop's # source root. # # That revision defaulted the web source to $DesktopActiveSource, so a machine it # installed still has a web-built checkout sitting in the tree the Electron # desktop shell claims - the very overlap Assert-DesktopSourceNotShared now # refuses outright. Retiring only the legacy launcher healed half of that and # left the desktop and this installer fighting over one directory, which is the # same class of failure the separate-trees rule exists to prevent. # # So move that checkout to the new web source root when the receipt proves this # installer created it and nothing else can be claiming it, and otherwise leave # it exactly where it is and say what has to happen and why. A tree that cannot # be proved is never deleted, and never deleted at all: it is only ever moved. # --------------------------------------------------------------------------- function Move-LegacyWebSourceCheckout { $recorded = Get-RecordedWebInstallField "source=" if (-not $recorded) { return } $recordedFull = Get-CanonicalOverlapPath $recorded if ($script:OverlapPathUnresolved -or -not $recordedFull) { return } $desktopRoot = Get-CanonicalOverlapPath $DesktopSourceRoot $insideDesktopTree = $recordedFull.Equals($desktopRoot, [StringComparison]::OrdinalIgnoreCase) -or $recordedFull.StartsWith($desktopRoot + '\', [StringComparison]::OrdinalIgnoreCase) if (-not $insideDesktopTree) { return } $target = Get-CanonicalOverlapPath $script:SrcDir if ($recordedFull.Equals($target, [StringComparison]::OrdinalIgnoreCase)) { return } if (-not (Test-Path -LiteralPath $recorded -PathType Container)) { return } if (Test-WebPathContainsReparsePoint $recorded) { return } Warn "This machine was installed by an earlier revision that built the web app inside the" Warn "FlintTrade Desktop shell's own source root: $recorded" Warn "The desktop claims that tree and guards it with a lease no script can hold, so leaving a" Warn "web checkout there makes the two installs mutate one directory - and can leave the" Warn "desktop shell unable to bootstrap at all." # Every refusal below is evaluated before the dry-run report, so -DryRun # never promises a move this installer would not make. if (Test-Path -LiteralPath $DesktopOperationLease) { Warn "Leaving $recorded - the desktop bootstrap operation lease at $DesktopOperationLease" Warn "says FlintTrade Desktop is working in that tree right now. Quit FlintTrade Desktop and" Warn "re-run this installer to complete the move." return } if (Test-DesktopShellInstalled) { Warn "Leaving $recorded - the FlintTrade Desktop shell is installed on this machine, so that" Warn "checkout may be the desktop's own rather than one this installer created, and this" Warn "installer never moves a tree it cannot prove it owns alone." Warn "Decide which checkout to keep, then either delete $recorded (the desktop rebuilds its" Warn "own on next launch) or move it to $($script:SrcDir) yourself, and re-run this installer." return } $emptyDestination = $false if (Test-Path -LiteralPath $script:SrcDir) { $occupied = $true if (Test-Path -LiteralPath $script:SrcDir -PathType Container) { $existing = @(Get-ChildItem -LiteralPath $script:SrcDir -Force -ErrorAction SilentlyContinue) $occupied = $existing.Count -ne 0 } if ($occupied) { Warn "Leaving $recorded - the new web source root $($script:SrcDir) already holds something," Warn "and this installer will not move one checkout on top of another." Warn "Delete or rename whichever of the two you do not want, then re-run this installer." return } $emptyDestination = $true } if ($DryRun) { Say "DRY-RUN: would move $recorded to $($script:SrcDir) (this installer's receipt names it)." return } if ($emptyDestination) { try { Remove-Item -LiteralPath $script:SrcDir -Force -ErrorAction Stop } catch { Warn "Could not clear the empty $($script:SrcDir): $($_.Exception.Message)" return } } $parent = Split-Path -Parent $script:SrcDir if (-not (Test-Path -LiteralPath $parent -PathType Container)) { New-Item -ItemType Directory -Force -Path $parent | Out-Null } try { Move-Item -LiteralPath $recorded -Destination $script:SrcDir -ErrorAction Stop $script:MigratedWebSource = $script:SrcDir Say "Moved the earlier web checkout out of the desktop's source root: $recorded -> $($script:SrcDir)" Remove-EmptyOwnedWebDirectory (Split-Path -Parent $recorded) } catch { Warn "Could not move $recorded to $($script:SrcDir): $($_.Exception.Message)" Warn "Move it yourself - or delete it, once you no longer need what it holds - so the desktop" Warn "shell owns $DesktopSourceRoot alone, then re-run this installer." } } function Assert-SourceOriginTrusted { $gitDir = Join-Path $script:SrcDir ".git" if (-not (Test-Path -LiteralPath $gitDir)) { return } $origin = "" try { $origin = ([string](& git -C $script:SrcDir remote get-url origin)).Trim() } catch { $origin = "" } if (-not $origin) { return } $trimmed = $RepoUrl if ($trimmed.EndsWith(".git")) { $trimmed = $trimmed.Substring(0, $trimmed.Length - 4) } if ($origin -ne $RepoUrl -and $origin -ne $trimmed) { Fail "Refusing to update $($script:SrcDir) because its origin is not the official HTTPS FlintTrade repository." } } # A branch or tag archive cannot be hash-pinned, but it can be commit-pinned: # resolve the ref to its commit SHA first, then download that exact commit's # archive, so the installed bytes cannot drift between resolution and download # and the install is reproducible from the reported SHA. function Resolve-RefCommitSha([string]$GitRef) { $apiUrl = "https://api.github.com/repos/$RepoSlug/commits/$GitRef" Assert-TrustedUrl $apiUrl $response = $null try { $response = Invoke-RestMethod -Uri $apiUrl -UseBasicParsing } catch { Fail "Could not resolve '$GitRef' to a commit SHA via api.github.com: $($_.Exception.Message)" } $sha = ([string]$response.sha).ToLowerInvariant() if ($sha -notmatch '^[0-9a-f]{40}$') { Fail "api.github.com returned no well-formed commit SHA for '$GitRef'." } return $sha } # -Ref is documented as "branch, tag or commit", and the archive fallback ends by # telling the operator to re-run with -Ref . 'git clone --branch' can honour # only the first two of those: its argument is resolved against the remote's # branches and tags, never against an arbitrary revision, so a commit SHA failed # on every fresh machine and made that advice unfollowable. # # 'git init' + 'git fetch ' + 'git checkout FETCH_HEAD' accepts all three # forms through one code path — a branch name, a tag name and a raw commit SHA # are all valid fetch refspecs, and GitHub serves arbitrary revisions # (uploadpack.allowAnySHA1InWant). It is also exactly what the refresh branch # already does, so a fresh install and an update now resolve -Ref identically # instead of disagreeing about which forms exist. function Invoke-GitSourceAcquisition([string]$GitRef) { Assert-SourceOriginTrusted if (Test-Path -LiteralPath (Join-Path $script:SrcDir ".git")) { Say "Refreshing the managed source checkout at $($script:SrcDir) (git fetch + reset)..." & git -C $script:SrcDir fetch --prune --tags $RepoUrl $GitRef if ($LASTEXITCODE -ne 0) { Fail "Could not fetch '$GitRef' from $RepoUrl." } & git -C $script:SrcDir reset --hard FETCH_HEAD if ($LASTEXITCODE -ne 0) { Fail "Could not reset the managed source checkout to '$GitRef'." } return } if ($script:SourceUpdateOnly) { Fail ("Refusing to replace $($script:SrcDir): only an in-place update is authorised for a " + "checkout no web-install receipt names.") } Say "Fetching FlintTrade ($GitRef) into $($script:SrcDir)..." $parent = Split-Path -Parent $script:SrcDir if (-not (Test-Path -LiteralPath $parent -PathType Container)) { New-Item -ItemType Directory -Force -Path $parent | Out-Null } if (Test-Path -LiteralPath $script:SrcDir) { Remove-Item -LiteralPath $script:SrcDir -Recurse -Force } New-Item -ItemType Directory -Force -Path $script:SrcDir | Out-Null & git -C $script:SrcDir init --quiet if ($LASTEXITCODE -ne 0) { Fail "Could not initialise a Git checkout at $($script:SrcDir)." } # Recorded so every later run's Assert-SourceOriginTrusted has an origin to # prove; a clone used to set this, and dropping it would silently retire the # "is this really the official repository?" guard. & git -C $script:SrcDir remote add origin $RepoUrl if ($LASTEXITCODE -ne 0) { Fail "Could not record the FlintTrade origin in $($script:SrcDir)." } & git -C $script:SrcDir fetch --depth 1 origin $GitRef if ($LASTEXITCODE -ne 0) { Fail "Could not fetch '$GitRef' from $RepoUrl." } & git -C $script:SrcDir checkout --detach --force FETCH_HEAD if ($LASTEXITCODE -ne 0) { Fail "Could not check out '$GitRef' in $($script:SrcDir)." } } function Invoke-ArchiveSourceAcquisition([string]$GitRef) { if ($script:SourceUpdateOnly) { Fail ("Refusing to replace $($script:SrcDir): only an in-place update is authorised for a " + "checkout no web-install receipt names.") } Say "git is not installed; resolving '$GitRef' to an exact commit so the archive install is reproducible..." $sha = Resolve-RefCommitSha $GitRef $url = "$ArchiveBaseUrl/$sha" Assert-TrustedUrl $url Say "Downloading the source archive for commit $sha ($url)..." $staging = Join-Path ([System.IO.Path]::GetTempPath()) ("flinttrade-source-" + [Guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Force -Path $staging | Out-Null try { $archive = Join-Path $staging "source.zip" Save-TrustedFile $url $archive $unpacked = Join-Path $staging "unpacked" Expand-VerifiedArchive $archive "zip" $unpacked $extracted = @(Get-ChildItem -LiteralPath $unpacked -Directory) | Select-Object -First 1 if (-not $extracted) { Fail "The downloaded source archive did not contain a checkout directory." } $extractedPath = [string]$extracted.FullName $hasPackageJson = Test-Path -LiteralPath (Join-Path $extractedPath "package.json") -PathType Leaf $hasPyproject = Test-Path -LiteralPath (Join-Path $extractedPath "pyproject.toml") -PathType Leaf if (-not ($hasPackageJson -and $hasPyproject)) { Fail "The downloaded source archive is not a FlintTrade checkout." } Say "Replacing $($script:SrcDir) with the downloaded checkout (a git-less refresh rebuilds dependencies)." $parent = Split-Path -Parent $script:SrcDir if (-not (Test-Path -LiteralPath $parent -PathType Container)) { New-Item -ItemType Directory -Force -Path $parent | Out-Null } if (Test-Path -LiteralPath $script:SrcDir) { Remove-Item -LiteralPath $script:SrcDir -Recurse -Force } Move-Item -LiteralPath $extractedPath -Destination $script:SrcDir Say "Installed source commit: $sha (re-run with -Ref $sha to reproduce this exact install)." } finally { Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue } } function Invoke-SourceAcquisition { $gitRef = $DefaultBranch if ($Ref) { $gitRef = $Ref } Confirm-SourceDirProvenance Assert-DesktopSourceNotShared # Before the identity guard, so the migrated checkout is judged where it now # lives rather than refused for sitting in the desktop's tree. Move-LegacyWebSourceCheckout Assert-SourceDirSafe if ($DryRun) { if (Have git) { Say "DRY-RUN: would fetch $RepoUrl at '$gitRef' (branch, tag or commit) into $($script:SrcDir) and check it out" } else { Say "DRY-RUN: would resolve '$gitRef' to a commit SHA via api.github.com, then download that commit's archive from $ArchiveBaseUrl/ and extract it into $($script:SrcDir)" } return } if (Have git) { Invoke-GitSourceAcquisition $gitRef } else { Invoke-ArchiveSourceAcquisition $gitRef } if (-not (Test-Path -LiteralPath (Join-Path $script:SrcDir $ManifestRelative) -PathType Leaf)) { Fail "The checkout at $($script:SrcDir) has no $ManifestRelative; it is not a supported FlintTrade revision." } if (-not (Test-Path -LiteralPath (Join-Path $script:SrcDir $BootstrapRelative) -PathType Leaf)) { Fail "The checkout at $($script:SrcDir) has no $BootstrapRelative; it is not a supported FlintTrade revision." } } # --------------------------------------------------------------------------- # 4/5. Manifest-pinned, SHA-256 verified tools laid out exactly like the # desktop bootstrap: \\\ # --------------------------------------------------------------------------- function Read-ToolManifest { $manifestPath = Join-Path $script:SrcDir $ManifestRelative try { return (Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json) } catch { Fail "Could not read $ManifestRelative from the checkout: $($_.Exception.Message)" } } function Get-ManifestAsset($Manifest, [string]$Tool) { $section = $Manifest.$Tool if (-not $section) { Fail "The bootstrap tool manifest has no '$Tool' section." } if (-not $section.version) { Fail "The bootstrap tool manifest has no '$Tool' version." } $asset = $section.assets.$($script:Target) if (-not $asset) { Fail "The bootstrap tool manifest pins no '$Tool' asset for $($script:Target)." } foreach ($field in @("archive", "executable", "sha256", "url")) { if (-not $asset.$field) { Fail "The bootstrap tool manifest '$Tool' asset for $($script:Target) is missing '$field'." } } return $asset } function Get-DesktopMarkerExecutableSha256([string]$MarkerPath) { try { $marker = Get-Content -LiteralPath $MarkerPath -Raw -Encoding UTF8 | ConvertFrom-Json if ($marker.executableSha256) { return ([string]$marker.executableSha256).ToLowerInvariant() } } catch { return "" } return "" } function Install-VerifiedTool([string]$Tool, $Manifest) { $version = [string]$Manifest.$Tool.version $asset = Get-ManifestAsset $Manifest $Tool $url = [string]$asset.url $sha = ([string]$asset.sha256).ToLowerInvariant() $archiveKind = [string]$asset.archive $relativeExecutable = [string]$asset.executable Assert-TrustedUrl $url Assert-Sha256Shape $sha Assert-ConfinedRelativePath $relativeExecutable $versionRoot = Join-Path (Join-Path $ToolsRoot $Tool) $version $installRoot = Join-Path $versionRoot $script:Target $marker = "$installRoot.flinttrade-web-verified" # The Electron bootstrap writes its verification marker INSIDE the install # root (packages\apps\desktop\electron\bootstrap.ts: verifiedMarker = # path.join(installRoot, TOOL_VERIFICATION_MARKER)). The sibling form is only # its legacyVerifiedMarker and nothing writes it any more, so read the real # one first and keep the legacy path as a fallback. $desktopMarker = Join-Path $installRoot ".flinttrade-tool-verified.json" $legacyDesktopMarker = "$installRoot.flinttrade-tool-verified.json" $executable = Join-Path $installRoot ($relativeExecutable -replace '/', '\') if ((Test-Path -LiteralPath $marker -PathType Leaf) -and (Test-Path -LiteralPath $executable -PathType Leaf)) { $recorded = ([string](Get-Content -LiteralPath $marker -Raw)).Trim().ToLowerInvariant() if ($recorded -eq $sha) { Say "Reusing the verified $Tool $version already provisioned at $installRoot." return $executable } } $provenMarker = "" if (Test-Path -LiteralPath $desktopMarker -PathType Leaf) { $provenMarker = $desktopMarker } elseif (Test-Path -LiteralPath $legacyDesktopMarker -PathType Leaf) { $provenMarker = $legacyDesktopMarker } if ($provenMarker) { if (-not (Test-Path -LiteralPath $executable -PathType Leaf)) { Fail "The desktop-provisioned $Tool state at $installRoot carries a verification marker but no usable executable. It was preserved; run the uninstaller or remove it yourself, then re-run." } $recorded = Get-DesktopMarkerExecutableSha256 $provenMarker $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $executable).Hash.ToLowerInvariant() if ($recorded -and $recorded -eq $actual) { Say "Reusing the desktop-provisioned $Tool $version at $installRoot." return $executable } Fail "Existing $Tool state at $installRoot failed verification and was preserved. Remove it or run the uninstaller, then re-run." } if ($DryRun) { Say "DRY-RUN: would download $url" Say "DRY-RUN: would verify sha256 $sha, then extract into $installRoot" return $executable } $downloads = Join-Path $ToolsRoot ".downloads" $archiveName = Split-Path -Leaf ([Uri]$url).AbsolutePath $archive = Join-Path $downloads $archiveName if (Test-Path -LiteralPath $archive) { Remove-Item -LiteralPath $archive -Force } Say "Downloading $Tool $version..." Save-TrustedFile $url $archive Assert-FileSha256 $archive $sha Say "Verified the SHA-256 digest pinned by the bootstrap tool manifest." Unblock-File -LiteralPath $archive -ErrorAction SilentlyContinue $staging = Join-Path $versionRoot (".{0}.extracting-{1}" -f $script:Target, [Guid]::NewGuid().ToString("N")) try { Expand-VerifiedArchive $archive $archiveKind $staging $stagedExecutable = Join-Path $staging ($relativeExecutable -replace '/', '\') if (-not (Test-Path -LiteralPath $stagedExecutable -PathType Leaf)) { Fail "The verified $Tool archive did not contain its expected executable ($relativeExecutable)." } if (Test-Path -LiteralPath $marker) { Remove-Item -LiteralPath $marker -Force } if ((Test-Path -LiteralPath $desktopMarker) -or (Test-Path -LiteralPath $legacyDesktopMarker)) { Fail "A desktop tool-verification marker appeared at $installRoot while this installer was extracting $Tool $version; refusing to delete a verified tool root." } if (Test-Path -LiteralPath $installRoot) { Remove-Item -LiteralPath $installRoot -Recurse -Force } Move-Item -LiteralPath $staging -Destination $installRoot } finally { if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue } } Set-Content -LiteralPath $marker -Value $sha -Encoding ascii Remove-Item -LiteralPath $archive -Force -ErrorAction SilentlyContinue if (-not (Test-Path -LiteralPath $executable -PathType Leaf)) { Fail "The provisioned $Tool $version is missing at $executable." } return $executable } # --------------------------------------------------------------------------- # 7. Delegation to the repository's own bootstrap entrypoint # --------------------------------------------------------------------------- function Get-PowerShellExecutable { foreach ($candidate in @("pwsh.exe", "powershell.exe")) { $path = Join-Path $PSHOME $candidate if (Test-Path -LiteralPath $path -PathType Leaf) { return $path } } Fail "Could not locate the PowerShell executable required to run the repository bootstrap." } function Invoke-RepositoryBootstrap([string]$Uv, [string]$Node, [string]$CorepackJs, [string]$PnpmVersion) { $bootstrap = Join-Path $script:SrcDir $BootstrapRelative if (-not (Test-Path -LiteralPath $bootstrap -PathType Leaf)) { Fail "The checkout has no bootstrap entrypoint at $bootstrap." } $powershell = Get-PowerShellExecutable Say "Building FlintTrade with the repository's own bootstrap entrypoint. This takes a few minutes on first run." & $powershell -NoProfile -ExecutionPolicy Bypass -File $bootstrap ` -Candidate $script:SrcDir ` -Uv $Uv ` -Node $Node ` -CorepackJs $CorepackJs ` -ToolsRoot $ToolsRoot ` -PnpmVersion $PnpmVersion if ($LASTEXITCODE -ne 0) { Fail "The FlintTrade bootstrap build failed; nothing was installed." } } # --------------------------------------------------------------------------- # 8. Launcher shim and Start Menu shortcut # --------------------------------------------------------------------------- # Earlier revisions installed the web launcher and its Start Menu shortcut into # the Electron desktop shell's own locations. Retire them on a re-run, but only # where this installer's own receipt still proves both the path and the # byte-for-byte contents; anything else is left for the operator to judge. The # receipt is read through the strict Get-RecordedWebInstallField above. function Remove-EmptyOwnedWebDirectory([string]$Path) { if (-not $Path) { return } if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return } $item = Get-Item -LiteralPath $Path -Force if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { return } $children = @(Get-ChildItem -LiteralPath $Path -Force -ErrorAction SilentlyContinue) if ($children.Count -ne 0) { return } try { Remove-Item -LiteralPath $Path -Force -ErrorAction Stop } catch { Warn "Could not remove the now-empty $Path; remove it by hand." } } function Remove-LegacyWebLauncher { if (-not $ShimPath -or -not $LegacyShimPath) { return } if ($ShimPath.Equals($LegacyShimPath, [StringComparison]::OrdinalIgnoreCase)) { return } if (-not (Test-Path -LiteralPath $LegacyShimPath -PathType Leaf)) { return } $recorded = Get-RecordedWebInstallField "shim=" if (-not $recorded) { return } try { $recordedFull = [System.IO.Path]::GetFullPath($recorded) $legacyFull = [System.IO.Path]::GetFullPath($LegacyShimPath) } catch { return } if (-not $recordedFull.Equals($legacyFull, [StringComparison]::OrdinalIgnoreCase)) { return } $recordedHash = (Get-RecordedWebInstallField "shim_sha256=").ToLowerInvariant() if ($recordedHash -notmatch '^[0-9a-f]{64}$') { return } $actual = "" try { $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $LegacyShimPath).Hash.ToLowerInvariant() } catch { $actual = "" } if ($actual -ne $recordedHash) { Warn "Leaving $LegacyShimPath - it is no longer the launcher this installer recorded." return } # The shortcut goes first: once the launcher is gone it can no longer be # proved to have pointed at it. # The receipt may only ever aim this at the one location the earlier # revision wrote; it is not a general deletion instruction. $recordedShortcut = Get-RecordedWebInstallField "shortcut=" if ($recordedShortcut) { try { $recordedShortcutFull = [System.IO.Path]::GetFullPath($recordedShortcut) $legacyShortcutFull = [System.IO.Path]::GetFullPath($LegacyStartMenuShortcut) } catch { $recordedShortcutFull = "" $legacyShortcutFull = "-" } if (-not $recordedShortcutFull.Equals($legacyShortcutFull, [StringComparison]::OrdinalIgnoreCase)) { $recordedShortcut = "" } } if ($recordedShortcut -and (Test-Path -LiteralPath $recordedShortcut -PathType Leaf)) { $target = "" try { $target = [string](New-Object -ComObject WScript.Shell).CreateShortcut($recordedShortcut).TargetPath } catch { $target = "" } if ($target -and ([System.IO.Path]::GetFullPath($target)).Equals($legacyFull, [StringComparison]::OrdinalIgnoreCase)) { try { Remove-Item -LiteralPath $recordedShortcut -Force -ErrorAction Stop Say "Retired the earlier Start Menu shortcut at $recordedShortcut." Remove-EmptyOwnedWebDirectory (Split-Path -Parent $recordedShortcut) } catch { Warn "Could not remove the earlier Start Menu shortcut at ${recordedShortcut}: $($_.Exception.Message)" } } else { Warn "Leaving $recordedShortcut - it no longer points at the launcher this installer recorded." } } try { Remove-Item -LiteralPath $LegacyShimPath -Force -ErrorAction Stop Say "Retired the earlier web launcher at $LegacyShimPath (it is now $ShimPath)." Remove-EmptyOwnedWebDirectory $LegacyShimDir } catch { Warn "Could not remove the earlier web launcher at ${LegacyShimPath}: $($_.Exception.Message)" } } function Install-LauncherShim([string]$PythonExecutable) { if (Test-Path -LiteralPath $ShimDir -PathType Leaf) { Fail "Refusing to replace a non-directory launcher path: $ShimDir" } if (-not (Test-Path -LiteralPath $ShimDir -PathType Container)) { New-Item -ItemType Directory -Force -Path $ShimDir | Out-Null } $lines = @( '@echo off', 'rem FlintTrade launcher shim - generated by flinttrade-web-install.ps1. Do not edit.', 'rem Re-run the web installer to regenerate it after moving the managed checkout.', 'setlocal', ('cd /d "{0}"' -f $script:SrcDir), 'if errorlevel 1 exit /b 1', ('"{0}" scripts\ft.py %*' -f $PythonExecutable), 'exit /b %ERRORLEVEL%' ) Write-OemText $ShimPath (($lines -join "`r`n") + "`r`n") Say "Installed the launcher at $ShimPath." $pathEntries = @(($env:PATH -split ';') | Where-Object { $_ }) $onPath = @($pathEntries | Where-Object { $_.TrimEnd('\').Equals($ShimDir.TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase) }) if ($onPath.Count -eq 0) { Warn "$ShimDir is not on PATH; add it, or run the launcher as `"$ShimPath`"." } } function Install-StartMenuShortcut { if (-not (Test-Path -LiteralPath $StartMenuDir -PathType Container)) { New-Item -ItemType Directory -Force -Path $StartMenuDir | Out-Null } try { $shell = New-Object -ComObject WScript.Shell $shortcut = $shell.CreateShortcut($StartMenuShortcut) $shortcut.TargetPath = $ShimPath $shortcut.Arguments = "start" $shortcut.WorkingDirectory = $script:SrcDir $shortcut.Description = "FlintTrade - open-source self-hosted trading software" $shortcut.Save() Say "Added the Start Menu shortcut at $StartMenuShortcut." } catch { Warn "Could not create the Start Menu shortcut: $($_.Exception.Message)" } } # --------------------------------------------------------------------------- # 8b. Owner-private web-install receipt # # The uninstaller may only delete what an installer proved it wrote, so record # the launcher shim (with its SHA-256), the Start Menu shortcut, the managed # source checkout and the tools root. Mirrors the POSIX installer's receipt. # --------------------------------------------------------------------------- function Test-OwnerOnlyWebAcl( [string]$Path, [System.Security.AccessControl.InheritanceFlags]$ExpectedInheritance ) { return Test-CurrentUserOnlyWebAcl $Path $ExpectedInheritance $true $true } function Set-OwnerOnlyWebAcl( [string]$Path, [System.Security.AccessControl.InheritanceFlags]$Inheritance, [bool]$Directory ) { try { $acl = Get-Acl -LiteralPath $Path $acl.SetAccessRuleProtection($true, $false) foreach ($existing in @($acl.Access)) { [void]$acl.RemoveAccessRule($existing) } $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User $acl.SetOwner($identity) if ($Directory) { $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( $identity, [System.Security.AccessControl.FileSystemRights]::FullControl, $Inheritance, [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow) } else { $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( $identity, [System.Security.AccessControl.FileSystemRights]::FullControl, [System.Security.AccessControl.AccessControlType]::Allow) } $acl.AddAccessRule($rule) # PowerShell 7 / .NET Core removed [System.IO.File|Directory]::SetAccessControl. # Set-Acl is the supported cmdlet on both Windows PowerShell 5.1 and pwsh 7. Set-Acl -LiteralPath $Path -AclObject $acl if (-not (Test-OwnerOnlyWebAcl $Path $Inheritance)) { throw "the resulting ACL was not owner-only" } } catch { Fail "Could not restrict $Path to the current user: $($_.Exception.Message)" } } function Protect-WebReceiptDirectory([string]$Path) { $inheritance = [System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit Set-OwnerOnlyWebAcl $Path $inheritance $true } function Protect-WebReceiptFile([string]$Path) { Set-OwnerOnlyWebAcl $Path ([System.Security.AccessControl.InheritanceFlags]::None) $false } function Initialize-WebReceiptStorage { if (-not $WebReceiptDir -or -not $WebReceiptPath) { Fail "Could not resolve the Windows application-data folder required for the web-install receipt." } if (Test-Path -LiteralPath $WebReceiptPath) { if (-not (Test-Path -LiteralPath $WebReceiptPath -PathType Leaf)) { Fail "Refusing a non-file web-install receipt path: $WebReceiptPath" } if (Test-WebPathContainsReparsePoint $WebReceiptPath) { Fail "Refusing a reparse-point web-install receipt: $WebReceiptPath" } if (-not (Test-TrustedWebReceiptAcl)) { Fail "Refusing a web-install receipt that is not current-user-only: $WebReceiptPath" } # Upgrade the safely inherited ACL written by earlier installers to an # explicit protected file ACL before any source or launcher mutation. Protect-WebReceiptDirectory $WebReceiptDir Protect-WebReceiptFile $WebReceiptPath return } if (Test-Path -LiteralPath $WebReceiptDir -PathType Leaf) { Fail "Refusing a non-directory web-install receipt path: $WebReceiptDir" } if (-not (Test-Path -LiteralPath $WebReceiptDir -PathType Container)) { New-Item -ItemType Directory -Force -Path $WebReceiptDir | Out-Null } if (Test-WebPathContainsReparsePoint $WebReceiptDir) { Fail "Refusing a reparse-point web-install receipt directory: $WebReceiptDir" } Protect-WebReceiptDirectory $WebReceiptDir # Prove file ACL publication while no source, launcher, or shortcut has # been changed. The real receipt is written only after its fields exist. $probe = Join-Path $WebReceiptDir (".receipt-acl-probe-" + [Guid]::NewGuid().ToString("N")) try { [System.IO.File]::WriteAllText($probe, "", (New-Object System.Text.UTF8Encoding($false))) Protect-WebReceiptFile $probe } finally { if (Test-Path -LiteralPath $probe -PathType Leaf) { Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue } } } function Get-ReceiptSafeValue([string]$Value) { if ($Value -match "[`r`n]") { Fail "Web-install receipt values must be single-line paths." } return $Value } function Write-WebInstallReceipt { Initialize-WebReceiptStorage if (-not (Test-Path -LiteralPath $ShimPath -PathType Leaf)) { Fail "The installed launcher is not an ordinary web-install receipt candidate." } $shim = [System.IO.Path]::GetFullPath($ShimPath) $shimHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $shim).Hash.ToLowerInvariant() $shortcut = "" if (Test-Path -LiteralPath $StartMenuShortcut -PathType Leaf) { $shortcut = [System.IO.Path]::GetFullPath($StartMenuShortcut) } $source = [System.IO.Path]::GetFullPath($script:SrcDir).TrimEnd('\') $tools = [System.IO.Path]::GetFullPath($ToolsRoot).TrimEnd('\') $lines = @( "format=flinttrade-web-install-v1", "platform=Windows", ("shim=" + (Get-ReceiptSafeValue $shim)), ("shim_sha256=" + $shimHash), ("shortcut=" + (Get-ReceiptSafeValue $shortcut)), ("source=" + (Get-ReceiptSafeValue $source)), ("tools=" + (Get-ReceiptSafeValue $tools)) ) [System.IO.File]::WriteAllText($WebReceiptPath, (($lines -join "`r`n") + "`r`n"), (New-Object System.Text.UTF8Encoding($false))) Protect-WebReceiptFile $WebReceiptPath Say "Recorded the web-install receipt at $WebReceiptPath." } # --------------------------------------------------------------------------- # 9. Report and launch # --------------------------------------------------------------------------- function Write-PathReport { Say "----------------------------------------------------------------" Say "Workspace and data : $(Get-WorkspaceDir)" Say "Verified tools : $ToolsRoot" Say "Managed source : $($script:SrcDir) (the desktop shell keeps its own at $DesktopActiveSource)" Say "Launcher : $ShimPath (command alias after adding its directory to PATH: flinttrade-web )" Say "Install receipt : $WebReceiptPath (the uninstaller removes only what this proves)" Say "Runner : python scripts/ft.py " Say "Open FlintTrade at : $BackendUrl" Say "Uninstall : $UninstallCommand" Say " (add -Purge to delete the workspace, tools and source too)" Say "----------------------------------------------------------------" } function Invoke-OptionalLaunch { if ($NoLaunch) { Say "Not starting FlintTrade (-NoLaunch). Start it later with: & `"$ShimPath`" start" return } if (-not (Confirm-Step "Start FlintTrade now?")) { Say "Not started. Start it later with: & `"$ShimPath`" start" return } Say "Starting FlintTrade - open $BackendUrl in your browser. Press Ctrl-C to stop." & $ShimPath start } # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- function Invoke-FlintTradeWebInstall { if ($Help) { Show-Usage return } if ($Ref) { if ($Ref -notmatch '^[A-Za-z0-9._/-]+$' -or $Ref.Contains("..") -or $Ref.StartsWith("-")) { Fail "-Ref is not a well-formed Git reference: $Ref" } } if (Test-FlintTradeProcessIsElevated) { Fail ( "FlintTrade installs per-user. Close this Administrator PowerShell window " + "and rerun the command in a normal PowerShell window." ) } if (-not $LocalAppDataRoot -or -not $RoamingAppDataRoot) { Fail "Could not resolve the Windows application-data folders required to install FlintTrade." } try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch { Warn "Could not raise the TLS floor to 1.2; downloads may fail on this machine." } Say "FlintTrade web-app installer - no prior tooling required." if ($DryRun) { Say "Dry run: nothing will be downloaded, built or installed." } Resolve-WebSourceDir # Parity with the POSIX installer's '--src must be an absolute path' guard: # a relative (or drive-/root-relative) path would silently resolve against # the current working directory, which is never what the operator meant. if (-not [System.IO.Path]::IsPathRooted($script:SrcDir) -or ($script:SrcDir -match '^[A-Za-z]:($|[^\\/])') -or ($script:SrcDir -match '^[\\/](?![\\/])')) { Fail "-SrcDir must be an absolute path: $($script:SrcDir)" } $script:SrcDir = [System.IO.Path]::GetFullPath($script:SrcDir).TrimEnd('\') # A source checkout that overlaps the desktop shell's is a configuration # error, not a runtime condition, so it is refused before anything is # probed, reported or downloaded - including under -DryRun, where reporting # a plan that could never run safely would be a lie. Assert-DesktopSourceNotShared $script:Target = Get-BootstrapTarget Say "Detected bootstrap target: $($script:Target)" Write-PreflightReport if (-not $DryRun) { Initialize-WebReceiptStorage } Invoke-SourceAcquisition if (-not (Test-Path -LiteralPath (Join-Path $script:SrcDir $ManifestRelative) -PathType Leaf)) { if (-not $DryRun) { Fail "The checkout has no $ManifestRelative." } Say "DRY-RUN: would read the pinned tool versions and digests from $ManifestRelative in the checkout." Say "DRY-RUN: would delegate the build to $BootstrapRelative, then install the launcher at $ShimPath." Write-PathReport return } $manifest = Read-ToolManifest $pinnedUv = [string]$manifest.uv.version $pinnedNode = [string]$manifest.node.version $pinnedPnpm = [string]$manifest.pnpm.version if ($pinnedPnpm -ne $PinnedPnpmVersion) { Fail "The checkout pins pnpm $pinnedPnpm; this installer only supports the $PinnedPnpmVersion bootstrap entrypoint." } $uvAsset = Get-ManifestAsset $manifest "uv" $nodeAsset = Get-ManifestAsset $manifest "node" Resolve-HostToolReuse $pinnedUv $pinnedNode Say "Pinned toolchain for $($script:Target): uv $pinnedUv, Node $pinnedNode, pnpm $pinnedPnpm." if ($script:ReuseUv) { Say " uv : reusing the exactly-matching host uv at $($script:ReuseUv)" } else { Say " uv : will download $($uvAsset.url)" } if ($script:ReuseNode) { Say " node : reusing the exactly-matching host Node at $($script:ReuseNode)" } else { Say " node : will download $($nodeAsset.url)" } Say " pnpm : provided by Corepack from the verified Node install (no separate download)" Say " python 3.14 : installed by uv into $(Join-Path $ToolsRoot 'python')" # A dry run mutates nothing, so it never needs the download confirmation — # this keeps non-interactive -DryRun usable without -Yes. if (-not $DryRun) { if (-not (Confirm-Step "Proceed with the downloads above?")) { Fail "Cancelled at the download confirmation; nothing was changed." } } $uv = $script:ReuseUv if (-not $uv) { $uv = Install-VerifiedTool "uv" $manifest } $node = $script:ReuseNode $corepackJs = $script:ReuseCorepackJs if (-not $node) { $node = Install-VerifiedTool "node" $manifest $corepackJs = Get-CorepackJsForNode $node } if ($DryRun) { Say "DRY-RUN: would run $BootstrapRelative with the six verified bootstrap arguments." Say "DRY-RUN: would install the launcher at $ShimPath and a Start Menu shortcut at $StartMenuShortcut." Write-PathReport return } if (-not (Test-Path -LiteralPath $corepackJs -PathType Leaf)) { Fail "The verified Node install carries no Corepack JavaScript at $corepackJs." } Invoke-RepositoryBootstrap $uv $node $corepackJs $pinnedPnpm $venvPython = Join-Path $script:SrcDir ".venv\Scripts\python.exe" if (-not (Test-Path -LiteralPath $venvPython -PathType Leaf)) { Fail "The bootstrap completed without a managed interpreter at $venvPython." } Remove-LegacyWebLauncher Install-LauncherShim $venvPython Install-StartMenuShortcut Write-WebInstallReceipt Write-PathReport Invoke-OptionalLaunch } Invoke-FlintTradeWebInstall