scripts/install/flinttrade-web-install.ps1
Rama main · réplica de lectura del repositorio original.
# 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 <git-ref> Branch, tag or commit to install (default: main)
-SrcDir <dir> 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 <sha>. '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 <ref>' + '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/<sha> 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: <tools-root>\<tool>\<version>\<target>
# ---------------------------------------------------------------------------
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 <subcommand>)"
Say "Install receipt : $WebReceiptPath (the uninstaller removes only what this proves)"
Say "Runner : python scripts/ft.py <start|stop|restart|status|dev|setup|test|lint|clean|version|help|desktop-test|desktop-build|desktop-package|desktop-dev>"
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