From 2d84294cbe9922ec907fe718e9dd06e9944e0ebc Mon Sep 17 00:00:00 2001 From: Jim Scott Date: Sat, 8 Aug 2026 21:02:33 -0700 Subject: [PATCH 1/5] Delegate framework installation to published installer --- PSScriptAnalyzerSettings.psd1 | 15 + README.md | 68 +- docs/windows-bootstrap.md | 162 ++++ install.ps1 | 46 + pyproject.toml | 2 +- scripts/install-ai-flywheel.ps1 | 896 ++++++++++++++++++ src/ai_flywheel_cli/cli.py | 107 +-- .../framework_compatibility.py | 119 +++ src/ai_flywheel_cli/operations.py | 240 +---- src/ai_flywheel_cli/upgrade.py | 68 -- tests/test_cli.py | 186 +--- tests/test_framework_compatibility.py | 61 ++ tests/test_operations.py | 139 +-- tools/test-install-launcher.ps1 | 58 ++ tools/test-windows-bootstrap.ps1 | 174 ++++ tools/validate-powershell.ps1 | 96 ++ 16 files changed, 1721 insertions(+), 716 deletions(-) create mode 100644 PSScriptAnalyzerSettings.psd1 create mode 100644 docs/windows-bootstrap.md create mode 100644 install.ps1 create mode 100644 scripts/install-ai-flywheel.ps1 create mode 100644 src/ai_flywheel_cli/framework_compatibility.py delete mode 100644 src/ai_flywheel_cli/upgrade.py create mode 100644 tests/test_framework_compatibility.py create mode 100644 tools/test-install-launcher.ps1 create mode 100644 tools/test-windows-bootstrap.ps1 create mode 100644 tools/validate-powershell.ps1 diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..5f48c55 --- /dev/null +++ b/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,15 @@ +@{ + Severity = @('Error', 'Warning') + + # The bootstrap intentionally owns a terminal wizard experience. Write-Host + # is used only for presentation; operational data and diagnostics are logged + # separately and CLI commands use structured output where available. + ExcludeRules = @('PSAvoidUsingWriteHost') + + Rules = @{ + PSUseCompatibleSyntax = @{ + Enable = $true + TargetVersions = @('5.1', '7.0') + } + } +} diff --git a/README.md b/README.md index 33f46c4..6971163 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # AI Flywheel CLI for Python -A cross-platform command-line application for inspecting, installing, validating, upgrading, and safely operating AI Flywheel artifacts in a repository. +A cross-platform command-line application for inspecting, validating, and safely operating AI Flywheel artifacts in a repository. ## Requirements - Python 3.11 or newer - A local repository directory -- A verified AI Flywheel framework ZIP archive and its published SHA-256 checksum for installation or upgrade +- A compatible AI Flywheel framework installed by the official framework installer Hosted execution is not enabled. All validation is performed locally. @@ -127,42 +127,20 @@ flywheel complete-execution \ These commands enforce schema validation, active-stage boundaries, reference integrity, and atomic state updates. -### Install +### Framework installation -Installation is plan-first. Omitting `--apply` makes no repository changes: +The Python CLI does not install or upgrade `.flywheel`. Framework installation is +owned by the published AI Flywheel Framework installer. On Windows, use the +repository bootstrap to ensure framework `2026.08.08` is present before preparing +the managed Python CLI: -```text -flywheel install . \ - --archive ai-flywheel-framework.zip \ - --checksum \ - --framework-version 0.1.0 -``` - -After inspecting the plan, apply it explicitly: - -```text -flywheel install . \ - --archive ai-flywheel-framework.zip \ - --checksum \ - --framework-version 0.1.0 \ - --source-identity github-release-v0.1.0 \ - --apply -``` - -Installation refuses to overwrite an existing `.flywheel` directory. The archive checksum is verified before extraction, archive paths are inspected, a repository mutation lock is acquired, changes are staged, and installation metadata is written only after the operation succeeds. - -### Upgrade - -Upgrade is also plan-first: - -```text -flywheel upgrade . \ - --archive ai-flywheel-framework.zip \ - --checksum \ - --framework-version 0.2.0 +```powershell +.\scripts\install-ai-flywheel.ps1 ``` -Use `--apply` after reviewing the requested target. Upgrade refuses to overwrite locally modified framework-owned files and blocks unsupported major-version transitions. Mutable operating content such as state, missions, goals, executions, evidence, approvals, and knowledge is not treated as framework-owned upgrade content. +The bootstrap invokes the official framework installer when `.flywheel` is absent, +leaves a compatible installation intact, and stops without overwriting older, +newer, malformed, legacy, or untracked installations. ## Exit code contract @@ -177,29 +155,30 @@ Flywheel-defined failures are sequential and single-purpose: - `5`: operation lock contention (`category=lock-contention`, `reason=repository-lock-active`) - `6`: governed AI fallback required (`category=ai-fallback-required`, `reason=governed-ai-step-required`) - `7`: other expected operation failure (`category=operation-failed`, `reason=mutation-rejected` or `operation-error`) +- `8`: framework absent or incompatible during `doctor` For automation, rely on the numeric exit code for coarse control flow and use structured JSON `category` and `reason` fields for stable, finer-grained branching. Runtime and shell statuses observed outside explicit Flywheel exits (for example signal termination or shell-specific interruption codes) are platform-dependent and should not be treated as part of the Flywheel-defined contract. -## Installation metadata +## Framework installation metadata -Successful installation and upgrade write: +Successful official framework installation writes: ```text .flywheel/installation.yaml ``` -The metadata records the framework version, archive checksum, source identity, installation time, and SHA-256 checksum for each framework-owned file. +The framework installer owns this metadata. The CLI reads `framework_version` only +to determine compatibility; it does not regenerate or independently prove the +installer's checksum and provenance contract. ## Safety model -- No silent overwrite of an existing installation -- No silent overwrite of locally modified framework-owned files -- SHA-256 verification before extraction -- Rejection of path traversal, absolute paths, symbolic links, duplicate destinations, and content outside `.flywheel` +- Framework installation and archive safety remain owned by the official framework installer +- No Python-side extraction, checksum verification, provenance generation, or `.flywheel` publication +- No automatic framework upgrade until the framework publishes an upgrade contract - Atomic lock-file acquisition under `.flywheel/.runtime` -- Staged writes with rollback for write failures - No automatic deletion of ambiguous stale locks - No GitHub Actions or other hosted execution without separate approval @@ -209,8 +188,9 @@ The metadata records the framework version, archive checksum, source identity, i ## Current limitations -- Release discovery and download are not performed implicitly; the first implementation accepts an already downloaded immutable archive and expected checksum. - Offline release bundles and standalone executable distribution remain deferred. +- The CLI currently supports framework `2026.08.08` exactly. +- Automatic framework upgrade remains deferred until an official framework upgrade contract is published. - Mission and goal creation, editing, listing, and broader administrative management remain deferred; execution lifecycle transitions are supported. - A dedicated stale-lock recovery command remains deferred. - Release-candidate proof has been completed on Windows with Python 3.13.14; other supported platforms require their own execution evidence. @@ -227,7 +207,7 @@ python -m venv .release-proof .release-proof\Scripts\python -m pip install dist\ai_flywheel_cli-0.1.0-py3-none-any.whl .release-proof\Scripts\flywheel --version .release-proof\Scripts\python -m ai_flywheel_cli --version -.release-proof\Scripts\flywheel doctor . +.release-proof\Scripts\flywheel --help .release-proof\Scripts\flywheel status . .release-proof\Scripts\flywheel validate . ``` diff --git a/docs/windows-bootstrap.md b/docs/windows-bootstrap.md new file mode 100644 index 0000000..5cfccc4 --- /dev/null +++ b/docs/windows-bootstrap.md @@ -0,0 +1,162 @@ +# Windows Python Bootstrap Contract + +## Purpose + +The Windows bootstrap ensures a compatible published AI Flywheel framework is +present, configures an isolated Python CLI, and verifies runtime health. It never +starts onboarding or lifecycle work. + +Dependency direction is: + +```text +AI Flywheel Specification + ↓ +AI Flywheel Framework + ↓ +Python runtime implementation +``` + +## Responsibility boundary + +- **AI Flywheel framework installer** owns release acquisition, checksum + verification, archive safety, provenance, staging, atomic `.flywheel` + publication, rollback, and refusal to overwrite. +- **Windows Python bootstrap** detects framework compatibility, invokes the official + installer only when the framework is absent, prepares Python and the managed CLI, + and runs health checks. +- **Python CLI** validates and operates an installed framework. It does not install, + extract, checksum, publish, or upgrade framework artifacts. + +The supported framework identity for this milestone is: + +```text +Framework version: 2026.08.08 +Release tag: v2026.08.08 +Package: ai-flywheel-framework-2026.08.08.zip +Checksum asset: ai-flywheel-framework-2026.08.08.zip.sha256 +Installer commit: fe11b801b5dfeef812377a978558fd563b67fa9e +``` + +## Invocation + +```powershell +.\scripts\install-ai-flywheel.ps1 +``` + +Supported inputs: + +- `-Repository `: target Git repository or a path inside it. +- `-CliRef `: CLI source ref; the normal default is immutable. +- `-CliPath `: local CLI source/package for development testing. +- `-NonInteractive`: disables prompts. +- `-Apply`: required with `-NonInteractive` when framework installation is needed. +- `-ValidateOnly`: checks an existing installation without installing one. +- Common `-WhatIf` and `-Confirm` semantics are passed to the official installer. + +Framework source parameters are intentionally absent. The Python bootstrap cannot +select a local framework, development ref, archive, checksum, or source identity. + +## Setup sequence + +1. Resolve the Git root and report repository/Git-operation conditions. +2. Classify the existing framework without mutation. +3. If absent, download and invoke the official installer pinned to + `fe11b801b5dfeef812377a978558fd563b67fa9e`. +4. Re-detect the framework. Cancellation or unsuccessful installation stops before + Python setup. +5. Reject older, newer, malformed, inconsistent, legacy, or untracked frameworks + without overwriting them. +6. Detect Python 3.11+ and offer explicit `winget` remediation when appropriate. +7. Create or reuse a managed CLI environment under + `%LOCALAPPDATA%\AI-Flywheel\environments`. +8. Run `flywheel doctor`, which verifies CLI version, framework identity, + compatibility, and repository validation. +9. Stop without invoking onboarding or lifecycle commands. + +## Framework compatibility + +The classifier reports one of: + +- `not-installed` +- `compatible` +- `older-unsupported` +- `newer-unsupported` +- `untracked-or-legacy` +- `malformed` +- `invalid` + +It reads `.flywheel/installation.yaml` `framework_version` and +`.flywheel/manifest.yaml` `framework.version`. These values must agree and equal +`2026.08.08`. + +Compatibility detection does not recompute installer-owned checksums or regenerate +provenance. A compatible framework is left intact. Existing incompatible content is +never routed through the initial installer because that installer correctly refuses +to overwrite `.flywheel`. + +No automatic upgrade path is offered until the framework publishes an official +upgrade contract. + +## CLI health contract + +`flywheel doctor --json` reports: + +- CLI version; +- supported framework version; +- installed framework version; +- compatibility status and reason; +- repository validation status and issues; +- overall status. + +It exits successfully only when the framework is compatible and repository +validation passes. Framework incompatibility and validation failure have distinct +exit codes. + +The CLI no longer exposes `flywheel install` or `flywheel upgrade`. Repository +locking remains available to lifecycle and persistence operations. + +## Diagnostics + +Expected operational conditions use concise messages and remediation, including: + +- installation cancellation; +- missing framework in validation-only mode; +- older or newer unsupported framework; +- missing provenance; +- malformed or inconsistent framework identity; +- missing non-interactive `-Apply` authority. + +Unexpected exceptions retain exception type, message, source location, failing +statement, stack trace, inner exceptions, native-command output, and diagnostic-log +locations. + +## Storage and safety + +Managed Python assets remain outside the target repository: + +```text +%LOCALAPPDATA%\AI-Flywheel\ +├── cache\cli\ +├── environments\ +└── logs\ +``` + +Temporary CLI-source extraction occurs under `%TEMP%\AIFW\` and is +removed after the run. The bootstrap never commits, pushes, merges, changes +application source, enables application missions, or begins lifecycle execution. + +## Validation + +The full gate includes: + +```powershell +.\tools\validate-powershell.ps1 +.\tools\test-install-launcher.ps1 +.\tools\test-windows-bootstrap.ps1 +``` + +Regression coverage verifies compatibility classifications, immutable official +installer identity, framework-before-Python ordering, preservation of compatible +framework files, absence of Python-owned framework installation logic, CLI source +archive safety, and removal of lifecycle invocation from bootstrap. Python tests +cover the same compatibility policy and deterministic `doctor` output. diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..e2a413f --- /dev/null +++ b/install.ps1 @@ -0,0 +1,46 @@ +#Requires -Version 5.1 + +<# +.SYNOPSIS +Starts the AI Flywheel Windows installer from a stable public one-liner. + +.DESCRIPTION +This lightweight launcher is safe to execute through Invoke-Expression. It runs in +an isolated child scope, downloads the reviewed canonical installer to a temporary +.ps1 file, executes that file in its own script scope, and removes the temporary +launcher artifact afterward. + +The canonical Python bootstrap detects framework compatibility and delegates an +absent framework to the official published framework installer. It then prepares +the Python CLI and performs compatibility and health checks. +#> + +& { + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + $ProgressPreference = 'SilentlyContinue' + + $installerCommit = 'a8cbeb6796ea0725cb179de4d289bb78d9707d5f' + $installerUri = "https://raw.githubusercontent.com/Infoconex/ai-flywheel-cli-python/$installerCommit/scripts/install-ai-flywheel.ps1" + $installerPath = Join-Path ([System.IO.Path]::GetTempPath()) ('ai-flywheel-installer-{0}.ps1' -f [guid]::NewGuid().ToString('N').Substring(0, 8)) + + try { + $requestParameters = @{ + Uri = $installerUri + OutFile = $installerPath + Headers = @{ 'User-Agent' = 'ai-flywheel-installer' } + ErrorAction = 'Stop' + } + + if ($PSVersionTable.PSEdition -eq 'Desktop') { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + $requestParameters['UseBasicParsing'] = $true + } + + Invoke-WebRequest @requestParameters + & $installerPath + } + finally { + Remove-Item -LiteralPath $installerPath -Force -ErrorAction SilentlyContinue + } +} diff --git a/pyproject.toml b/pyproject.toml index fef7b0d..6e74c98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "ai-flywheel-cli" version = "0.1.0" -description = "Repository-local CLI for installing, validating, inspecting, and upgrading AI Flywheel operating artifacts." +description = "Repository-local CLI for validating, inspecting, and operating AI Flywheel artifacts." readme = "README.md" requires-python = ">=3.11" license = { text = "MIT" } diff --git a/scripts/install-ai-flywheel.ps1 b/scripts/install-ai-flywheel.ps1 new file mode 100644 index 0000000..48fff95 --- /dev/null +++ b/scripts/install-ai-flywheel.ps1 @@ -0,0 +1,896 @@ +#Requires -Version 5.1 + +<# +.SYNOPSIS +Prepares a Windows Git repository for AI Flywheel onboarding. + +.DESCRIPTION +Ensures the official AI Flywheel framework is installed, then installs and +validates the Python CLI without starting onboarding. The bootstrap delegates all +framework acquisition, verification, provenance, archive safety, and repository +mutation to the published framework installer. + +The bootstrap never commits, pushes, merges, enables application missions, or +starts an onboarding execution. + +.PARAMETER Repository +Path within the target Git repository. Defaults to the current directory. When a +subdirectory is supplied, the Git repository root is resolved automatically. + +.PARAMETER CliRef +CLI Git branch, tag, or commit. Defaults to an approved immutable CLI commit. + +.PARAMETER CliPath +Local CLI source directory, wheel, or sdist for development/testing. + +.PARAMETER NonInteractive +Disables prompts. Repository mutation additionally requires -Apply. + +.PARAMETER Apply +Explicitly authorizes repository mutation in non-interactive mode. + +.PARAMETER ValidateOnly +Validates prerequisites and an existing installation without installing. + +.EXAMPLE +.\install-ai-flywheel.ps1 -Repository D:\code\my-project + +Ensures framework 2026.08.08 is present and configures the Python CLI. + +.NOTES +Minimum PowerShell: Windows PowerShell 5.1. PowerShell 7+ is preferred. +Minimum Python: 3.11. +Managed data: %LOCALAPPDATA%\AI-Flywheel\{cache,environments,logs}. +Temporary extraction data uses the operating-system temporary directory and is +removed at the end of the run. +Exit codes: 0 success; 1 cancelled; 2 prerequisite; 3 acquisition; 4 integrity; +5 repository conflict; 6 installation; 7 validation/readiness; 8 recovery. + +Unexpected exceptions produce a concise console error, a full text diagnostic log, +and a structured .error.json report containing exception and script stack details. +#> + +[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] +param( + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$Repository = '.', + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$CliRef = 'e766886acf35b145023292b954ff097b63e95b29', + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$CliPath, + + [Parameter()] + [switch]$NonInteractive, + + [Parameter()] + [switch]$Apply, + + [Parameter()] + [switch]$ValidateOnly +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +$script:ExitCode = @{ + Success = 0 + Cancelled = 1 + Prerequisite = 2 + Acquisition = 3 + Integrity = 4 + RepositoryConflict = 5 + Installation = 6 + Validation = 7 + Recovery = 8 +} +$script:CliRepository = 'Infoconex/ai-flywheel-cli-python' +$script:FrameworkVersion = '2026.08.08' +$script:FrameworkInstallerCommit = 'fe11b801b5dfeef812377a978558fd563b67fa9e' +$script:FrameworkInstallerUri = "https://raw.githubusercontent.com/Infoconex/ai-flywheel-framework/$($script:FrameworkInstallerCommit)/scripts/install-framework.ps1" +$script:MinimumPythonVersion = [version]'3.11.0' +$script:RunId = [guid]::NewGuid().ToString('N') +$script:Warnings = [System.Collections.Generic.List[string]]::new() +$script:LogPath = $null +$script:ErrorReportPath = $null +$script:CurrentStage = 'Initialization' +$script:TemporaryRoot = $null +$script:InvocationBoundParameters = @{} + $PSBoundParameters +$script:BootstrapContext = [ordered]@{ + RunId = $script:RunId + RepositoryRoot = $null + PowerShellVersion = $PSVersionTable.PSVersion.ToString() + PowerShellEdition = $PSVersionTable.PSEdition + PythonVersion = $null + PythonExecutable = $null + CliVersion = $null + CliExecutable = $null + CliResolvedCommit = $null + FrameworkVersion = $null + FrameworkCompatibility = $null + FrameworkInstallerCommit = $script:FrameworkInstallerCommit + OnboardingReady = $false +} + +function Write-BootstrapSection { + [CmdletBinding()] + param([Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Title) + Write-Host '' + Write-Host $Title -ForegroundColor Cyan + Write-Host ('-' * [Math]::Min([Math]::Max($Title.Length, 24), 64)) -ForegroundColor DarkGray +} + +function Write-BootstrapSuccess { + [CmdletBinding()] + param([Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Message) + Write-Host "[OK] $Message" -ForegroundColor Green +} + +function Write-BootstrapWarning { + [CmdletBinding()] + param([Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Message) + $script:Warnings.Add($Message) + Write-Host "[WARN] $Message" -ForegroundColor Yellow +} + +function Write-BootstrapFailure { + [CmdletBinding()] + param([Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Message) + Write-Host "[FAIL] $Message" -ForegroundColor Red +} + +function Write-BootstrapLog { + [CmdletBinding()] + param([Parameter(Mandatory)][AllowEmptyString()][string]$Message) + try { + if ($script:LogPath) { + $line = '{0} [{1}] {2}' -f ([DateTimeOffset]::Now.ToString('o')), $script:CurrentStage, $Message + Add-Content -LiteralPath $script:LogPath -Value $line -Encoding UTF8 -ErrorAction Stop + } + } + catch { + Write-Verbose "Bootstrap log write failed: $($_.Exception.Message)" + } + Write-Verbose $Message +} + +function Get-InnerExceptionDetail { + [CmdletBinding()] + param([Parameter(Mandatory)][System.Exception]$Exception) + $items = [System.Collections.Generic.List[object]]::new() + $current = $Exception + $depth = 0 + while ($null -ne $current) { + $items.Add([ordered]@{ + Depth = $depth + Type = $current.GetType().FullName + Message = $current.Message + HResult = $current.HResult + StackTrace = $current.StackTrace + }) + $current = $current.InnerException + $depth++ + } + return $items +} + +function Write-UnexpectedBootstrapError { + [CmdletBinding()] + param([Parameter(Mandatory)][System.Management.Automation.ErrorRecord]$ErrorRecord) + + $timestamp = [DateTimeOffset]::Now + if (-not $script:LogPath) { + try { + $script:LogPath = Join-Path ([System.IO.Path]::GetTempPath()) ('ai-flywheel-bootstrap-{0}.log' -f $timestamp.ToString('yyyyMMdd-HHmmss')) + New-Item -ItemType File -Path $script:LogPath -Force -ErrorAction Stop | Out-Null + } + catch { $script:LogPath = $null } + } + + $baseDirectory = if ($script:LogPath) { Split-Path -Parent $script:LogPath } else { [System.IO.Path]::GetTempPath() } + $script:ErrorReportPath = Join-Path $baseDirectory ('bootstrap-{0}-{1}.error.json' -f $timestamp.ToString('yyyyMMdd-HHmmss'), $script:RunId.Substring(0, 8)) + $invocation = $ErrorRecord.InvocationInfo + $diagnostic = [ordered]@{ + SchemaVersion = 1 + Timestamp = $timestamp.ToString('o') + RunId = $script:RunId + Stage = $script:CurrentStage + ExitCategory = 'unexpected-exception' + PowerShell = [ordered]@{ + Version = $PSVersionTable.PSVersion.ToString() + Edition = $PSVersionTable.PSEdition + Host = $Host.Name + ProcessId = $PID + } + BootstrapContext = $script:BootstrapContext + ErrorRecord = [ordered]@{ + ExceptionType = $ErrorRecord.Exception.GetType().FullName + Message = $ErrorRecord.Exception.Message + FullyQualifiedErrorId = $ErrorRecord.FullyQualifiedErrorId + CategoryInfo = $ErrorRecord.CategoryInfo.ToString() + ErrorDetails = if ($ErrorRecord.ErrorDetails) { $ErrorRecord.ErrorDetails.Message } else { $null } + ScriptStackTrace = $ErrorRecord.ScriptStackTrace + PositionMessage = if ($invocation) { $invocation.PositionMessage } else { $null } + InvocationName = if ($invocation) { $invocation.InvocationName } else { $null } + ScriptName = if ($invocation) { $invocation.ScriptName } else { $null } + ScriptLineNumber = if ($invocation) { $invocation.ScriptLineNumber } else { $null } + OffsetInLine = if ($invocation) { $invocation.OffsetInLine } else { $null } + Line = if ($invocation) { $invocation.Line } else { $null } + ExceptionToString = $ErrorRecord.Exception.ToString() + InnerExceptions = @(Get-InnerExceptionDetail -Exception $ErrorRecord.Exception) + } + } + + try { $diagnostic | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $script:ErrorReportPath -Encoding UTF8 -ErrorAction Stop } + catch { Write-BootstrapLog -Message "Unable to write structured error report: $($_.Exception.Message)" } + + Write-BootstrapLog -Message ('UNEXPECTED ERROR RECORD:{0}{1}' -f [Environment]::NewLine, ($ErrorRecord | Format-List * -Force | Out-String)) + Write-BootstrapLog -Message ('EXCEPTION:{0}{1}' -f [Environment]::NewLine, $ErrorRecord.Exception.ToString()) + Write-BootstrapLog -Message ('SCRIPT STACK:{0}{1}' -f [Environment]::NewLine, $ErrorRecord.ScriptStackTrace) + + Write-BootstrapFailure -Message 'AI Flywheel setup encountered an unexpected error.' + Write-Host "Stage: $script:CurrentStage" + Write-Host "Error type: $($ErrorRecord.Exception.GetType().FullName)" + Write-Host "Message: $($ErrorRecord.Exception.Message)" + if ($invocation -and $invocation.ScriptLineNumber) { Write-Host "Location: $($invocation.ScriptName):$($invocation.ScriptLineNumber)" } + if ($script:LogPath) { Write-Host "Diagnostic log: $script:LogPath" -ForegroundColor DarkGray } + if ($script:ErrorReportPath) { Write-Host "Structured error report: $script:ErrorReportPath" -ForegroundColor DarkGray } +} + +function Invoke-BootstrapFailure { + [CmdletBinding()] + param( + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Message, + [Parameter(Mandatory)][ValidateRange(1, 255)][int]$Code, + [string]$Remediation + ) + Write-BootstrapFailure -Message $Message + if ($Remediation) { Write-Host "Remediation: $Remediation" -ForegroundColor Yellow } + Write-BootstrapLog -Message "EXPECTED FAILURE exit=$Code message=$Message remediation=$Remediation" + if ($script:LogPath) { Write-Host "Diagnostic log: $script:LogPath" -ForegroundColor DarkGray } + $exception = [System.InvalidOperationException]::new($Message) + $exception.Data['BootstrapExitCode'] = $Code + $exception.Data['BootstrapExpected'] = $true + throw $exception +} + +function Confirm-BootstrapAction { + [CmdletBinding()] + param([Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Prompt, [bool]$DefaultYes = $true) + if ($NonInteractive) { return $false } + $suffix = if ($DefaultYes) { '[Y/n]' } else { '[y/N]' } + $answer = Read-Host "$Prompt $suffix" + if ([string]::IsNullOrWhiteSpace($answer)) { return $DefaultYes } + return $answer.Trim().StartsWith('y', [System.StringComparison]::OrdinalIgnoreCase) +} + +function New-BootstrapDirectory { + [CmdletBinding(SupportsShouldProcess = $true)] + param([Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Path) + if ((-not (Test-Path -LiteralPath $Path)) -and $PSCmdlet.ShouldProcess($Path, 'Create directory')) { + New-Item -ItemType Directory -Path $Path -Force -ErrorAction Stop | Out-Null + } +} + +function Sync-BootstrapProcessPath { + [CmdletBinding()] + param() + $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $env:Path = @($machinePath, $userPath) -join ';' +} + +function Invoke-BootstrapNativeCommand { + [CmdletBinding()] + param( + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$FilePath, + [Parameter()][AllowEmptyCollection()][string[]]$ArgumentList = @(), + [switch]$AllowFailure + ) + Write-BootstrapLog -Message ('RUN {0} {1}' -f $FilePath, ($ArgumentList -join ' ')) + $previousNativePreference = $null + $hasNativePreference = Test-Path variable:PSNativeCommandUseErrorActionPreference + if ($hasNativePreference) { + $previousNativePreference = $PSNativeCommandUseErrorActionPreference + $PSNativeCommandUseErrorActionPreference = $false + } + $previousErrorActionPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $output = @(& $FilePath @ArgumentList 2>&1) + $nativeExitCode = $LASTEXITCODE + } + finally { + $ErrorActionPreference = $previousErrorActionPreference + if ($hasNativePreference) { $PSNativeCommandUseErrorActionPreference = $previousNativePreference } + } + if ($output.Count -gt 0) { Write-BootstrapLog -Message (($output | Out-String).TrimEnd()) } + if (($nativeExitCode -ne 0) -and -not $AllowFailure) { + $rendered = ($output | Out-String).TrimEnd() + throw [System.InvalidOperationException]::new(('Native command failed with exit code {0}: {1} {2}{3}{4}' -f $nativeExitCode, $FilePath, ($ArgumentList -join ' '), [Environment]::NewLine, $rendered)) + } + return [pscustomobject]@{ ExitCode = $nativeExitCode; Output = $output } +} + +function Install-BootstrapWingetPackage { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$PackageId, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$DisplayName + ) + $winget = Get-Command winget -ErrorAction SilentlyContinue + if (-not $winget -or $NonInteractive) { return $false } + if (-not (Confirm-BootstrapAction -Prompt "$DisplayName is required. Install it using winget?" -DefaultYes $true)) { return $false } + if ($PSCmdlet.ShouldProcess($DisplayName, "Install using winget package $PackageId")) { + Invoke-BootstrapNativeCommand -FilePath $winget.Source -ArgumentList @('install', '--id', $PackageId, '--exact', '--source', 'winget', '--accept-source-agreements', '--accept-package-agreements') | Out-Null + Sync-BootstrapProcessPath + return $true + } + return $false +} + +function Get-GitCommand { + [CmdletBinding()] + param() + $git = Get-Command git -ErrorAction SilentlyContinue + if ($git) { return $git.Source } + Write-BootstrapWarning -Message 'Git was not found on PATH.' + if (Install-BootstrapWingetPackage -PackageId 'Git.Git' -DisplayName 'Git') { $git = Get-Command git -ErrorAction SilentlyContinue } + if (-not $git) { Invoke-BootstrapFailure -Message 'Git is required but is not available.' -Code $script:ExitCode.Prerequisite -Remediation 'Install Git for Windows, open a new terminal if needed, and retry.' } + return $git.Source +} + +function Get-GitRepositoryRoot { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$RequestedPath, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$GitExecutable + ) + $resolvedPath = (Resolve-Path -LiteralPath $RequestedPath -ErrorAction Stop).Path + Push-Location $resolvedPath + try { + $rootResult = Invoke-BootstrapNativeCommand -FilePath $GitExecutable -ArgumentList @('rev-parse', '--show-toplevel') -AllowFailure + if ($rootResult.ExitCode -ne 0) { + if ($NonInteractive) { Invoke-BootstrapFailure -Message 'The target directory is not a Git repository.' -Code $script:ExitCode.Prerequisite -Remediation 'Initialize the repository with git init before non-interactive setup.' } + Write-BootstrapWarning -Message "No Git repository was found at $resolvedPath." + if (-not (Confirm-BootstrapAction -Prompt 'Initialize this directory as a Git repository?' -DefaultYes $true)) { Invoke-BootstrapFailure -Message 'Setup cancelled before Git initialization.' -Code $script:ExitCode.Cancelled } + if ($PSCmdlet.ShouldProcess($resolvedPath, 'Initialize Git repository')) { Invoke-BootstrapNativeCommand -FilePath $GitExecutable -ArgumentList @('init') | Out-Null } + $rootResult = Invoke-BootstrapNativeCommand -FilePath $GitExecutable -ArgumentList @('rev-parse', '--show-toplevel') + } + return (($rootResult.Output | Select-Object -Last 1).ToString().Trim()) + } + finally { Pop-Location } +} + +function Test-BootstrapRepositoryWritable { + [CmdletBinding()] + param([Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Root) + $probe = Join-Path $Root ('.flywheel-bootstrap-write-{0}.tmp' -f $script:RunId) + try { + Set-Content -LiteralPath $probe -Value 'probe' -Encoding ASCII -ErrorAction Stop + Remove-Item -LiteralPath $probe -Force -ErrorAction Stop + return $true + } + catch { + Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue + Write-BootstrapLog -Message "Repository writability probe failed: $($_.Exception.Message)" + return $false + } +} + +function Get-GitOperationState { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$Root, [Parameter(Mandatory)][string]$GitExecutable) + $result = Invoke-BootstrapNativeCommand -FilePath $GitExecutable -ArgumentList @('-C', $Root, 'rev-parse', '--git-dir') + $gitDirectory = $result.Output[-1].ToString().Trim() + if (-not [System.IO.Path]::IsPathRooted($gitDirectory)) { $gitDirectory = Join-Path $Root $gitDirectory } + $markers = [ordered]@{ Merge = 'MERGE_HEAD'; RebaseMerge = 'rebase-merge'; RebaseApply = 'rebase-apply'; CherryPick = 'CHERRY_PICK_HEAD'; Revert = 'REVERT_HEAD'; Bisect = 'BISECT_LOG' } + foreach ($entry in $markers.GetEnumerator()) { + if (Test-Path -LiteralPath (Join-Path $gitDirectory $entry.Value)) { return $entry.Key } + } + return 'Normal' +} + +function Test-PythonCandidate { + [CmdletBinding()] + param( + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Command, + [Parameter()][AllowEmptyCollection()][string[]]$Prefix = @() + ) + $code = "import sys; print('.'.join(map(str, sys.version_info[:3]))); print(sys.executable); print(sys.base_prefix); print('1' if sys.prefix != sys.base_prefix else '0')" + $result = Invoke-BootstrapNativeCommand -FilePath $Command -ArgumentList (@($Prefix) + @('-c', $code)) -AllowFailure + if (($result.ExitCode -ne 0) -or ($result.Output.Count -lt 4)) { return $null } + try { $version = [version]$result.Output[0].ToString().Trim() } catch { return $null } + return [pscustomobject]@{ + Command = $Command + Prefix = @($Prefix) + Version = $version + Executable = $result.Output[1].ToString().Trim() + BasePrefix = $result.Output[2].ToString().Trim() + IsVirtualEnvironment = $result.Output[3].ToString().Trim() -eq '1' + } +} + +function Get-PythonRuntime { + [CmdletBinding()] + param() + $tested = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $candidates = [System.Collections.Generic.List[object]]::new() + $launcher = Get-Command py -ErrorAction SilentlyContinue + if ($launcher) { + foreach ($selector in @('-3.13', '-3.12', '-3.11')) { $candidates.Add([pscustomobject]@{ Command = $launcher.Source; Prefix = @($selector) }) } + } + foreach ($name in @('python', 'python3')) { + $command = Get-Command $name -ErrorAction SilentlyContinue + if ($command) { $candidates.Add([pscustomobject]@{ Command = $command.Source; Prefix = @() }) } + } + + foreach ($candidate in $candidates) { + $identity = $candidate.Command + '|' + ($candidate.Prefix -join ' ') + if (-not $tested.Add($identity)) { continue } + try { + $runtime = Test-PythonCandidate -Command $candidate.Command -Prefix $candidate.Prefix + if (-not $runtime) { continue } + if ($runtime.Version -lt $script:MinimumPythonVersion) { continue } + if (-not $runtime.IsVirtualEnvironment) { return $runtime } + + Write-BootstrapLog -Message "Ignoring active virtual environment as bootstrap base runtime: $($runtime.Executable)" + $baseExecutable = Join-Path $runtime.BasePrefix 'python.exe' + if (Test-Path -LiteralPath $baseExecutable -PathType Leaf) { + $baseRuntime = Test-PythonCandidate -Command $baseExecutable + if ($baseRuntime -and -not $baseRuntime.IsVirtualEnvironment -and $baseRuntime.Version -ge $script:MinimumPythonVersion) { return $baseRuntime } + } + } + catch { Write-BootstrapLog -Message "Python candidate failed: $($_.Exception.Message)" } + } + return $null +} + +function Get-OrInstallPythonRuntime { + [CmdletBinding()] + param() + $python = Get-PythonRuntime + if ($python) { return $python } + Write-BootstrapWarning -Message 'A base Python 3.11 or later runtime was not found.' + if (Install-BootstrapWingetPackage -PackageId 'Python.Python.3.13' -DisplayName 'Python 3.13') { $python = Get-PythonRuntime } + if (-not $python) { Invoke-BootstrapFailure -Message 'Python 3.11 or later is required but is not available.' -Code $script:ExitCode.Prerequisite -Remediation 'Install a base Python 3.11 or later runtime with venv support, then retry.' } + return $python +} + +function Invoke-BootstrapPython { + [CmdletBinding()] + param([Parameter(Mandatory)]$Python, [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$ArgumentList) + return Invoke-BootstrapNativeCommand -FilePath $Python.Command -ArgumentList (@($Python.Prefix) + $ArgumentList) +} + +function Invoke-BootstrapWebRequest { + [CmdletBinding()] + param([Parameter(Mandatory)][uri]$Uri, [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$OutFile) + if ($PSVersionTable.PSEdition -eq 'Desktop') { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + } + $parameters = @{ Uri = $Uri; OutFile = $OutFile; Headers = @{ 'User-Agent' = 'ai-flywheel-bootstrap' }; ErrorAction = 'Stop' } + if ($PSVersionTable.PSEdition -eq 'Desktop') { $parameters['UseBasicParsing'] = $true } + Invoke-WebRequest @parameters +} + +function Resolve-GitHubCommit { + [CmdletBinding()] + param( + [Parameter(Mandatory)][ValidatePattern('^[A-Za-z0-9_.\/-]+$')][string]$RepositoryName, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Ref + ) + $uri = "https://api.github.com/repos/$RepositoryName/commits/$([uri]::EscapeDataString($Ref))" + Write-BootstrapLog -Message "Resolving Git ref $RepositoryName@$Ref" + try { $response = Invoke-RestMethod -Uri $uri -Headers @{ 'User-Agent' = 'ai-flywheel-bootstrap' } -ErrorAction Stop } + catch { Invoke-BootstrapFailure -Message "Unable to resolve Git ref '$Ref' in $RepositoryName." -Code $script:ExitCode.Acquisition -Remediation $_.Exception.Message } + if (-not $response.sha) { Invoke-BootstrapFailure -Message "GitHub did not return an immutable commit for '$Ref'." -Code $script:ExitCode.Acquisition } + return $response.sha.ToString() +} + +function Save-GitHubArchive { + [CmdletBinding()] + param( + [Parameter(Mandatory)][ValidatePattern('^[A-Za-z0-9_.\/-]+$')][string]$RepositoryName, + [Parameter(Mandatory)][ValidatePattern('^[0-9a-fA-F]{40}$')][string]$CommitSha, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Destination + ) + if (Test-Path -LiteralPath $Destination) { return } + $uri = "https://github.com/$RepositoryName/archive/$CommitSha.zip" + Write-BootstrapLog -Message "Downloading $uri" + try { Invoke-BootstrapWebRequest -Uri $uri -OutFile $Destination } + catch { + Remove-Item -LiteralPath $Destination -Force -ErrorAction SilentlyContinue + Invoke-BootstrapFailure -Message "Download failed for $RepositoryName@$CommitSha." -Code $script:ExitCode.Acquisition -Remediation $_.Exception.Message + } +} + +function ConvertTo-BootstrapExtendedPath { + [CmdletBinding()] + param([Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Path) + + $fullPath = [System.IO.Path]::GetFullPath($Path) + if ($env:OS -ne 'Windows_NT') { return $fullPath } + if ($fullPath.StartsWith('\\?\', [System.StringComparison]::Ordinal)) { return $fullPath } + if ($fullPath.StartsWith('\\', [System.StringComparison]::Ordinal)) { + return '\\?\UNC\' + $fullPath.Substring(2) + } + return '\\?\' + $fullPath +} + +function Test-CliArchiveEntryExcluded { + [CmdletBinding()] + param([Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$EntryName) + + $parts = @($EntryName.Replace('\', '/').Split('/') | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($parts.Count -lt 2) { return $false } + $rootChild = $parts[1] + return $rootChild -in @('.flywheel', '.gitignore', '.release-proof', 'tests', 'tools') +} + +function Expand-BootstrapArchive { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Archive, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Destination + ) + + Add-Type -AssemblyName System.IO.Compression -ErrorAction Stop + Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop + + if (Test-Path -LiteralPath $Destination) { + Remove-Item -LiteralPath $Destination -Recurse -Force -ErrorAction Stop + } + [System.IO.Directory]::CreateDirectory((ConvertTo-BootstrapExtendedPath -Path $Destination)) | Out-Null + + $destinationRoot = [System.IO.Path]::GetFullPath($Destination).TrimEnd( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar + ) + $destinationPrefix = $destinationRoot + [System.IO.Path]::DirectorySeparatorChar + $isCliExtraction = ([System.IO.Path]::GetFileName($destinationRoot) -eq 'cli') + + $zip = [System.IO.Compression.ZipFile]::OpenRead($Archive) + try { + foreach ($entry in $zip.Entries) { + if ([string]::IsNullOrWhiteSpace($entry.FullName)) { continue } + if ($isCliExtraction -and (Test-CliArchiveEntryExcluded -EntryName $entry.FullName)) { continue } + + $relativeName = $entry.FullName.Replace('/', [System.IO.Path]::DirectorySeparatorChar) + $targetPath = [System.IO.Path]::GetFullPath((Join-Path $destinationRoot $relativeName)) + if (-not $targetPath.StartsWith($destinationPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + Invoke-BootstrapFailure -Message "Archive entry escapes the extraction root: $($entry.FullName)" -Code $script:ExitCode.Integrity + } + + $extendedTargetPath = ConvertTo-BootstrapExtendedPath -Path $targetPath + if ([string]::IsNullOrEmpty($entry.Name)) { + [System.IO.Directory]::CreateDirectory($extendedTargetPath) | Out-Null + continue + } + + $parent = [System.IO.Path]::GetDirectoryName($targetPath) + [System.IO.Directory]::CreateDirectory((ConvertTo-BootstrapExtendedPath -Path $parent)) | Out-Null + if ($PSCmdlet.ShouldProcess($targetPath, 'Extract ZIP entry')) { + $sourceStream = $entry.Open() + try { + $targetStream = [System.IO.File]::Open( + $extendedTargetPath, + [System.IO.FileMode]::Create, + [System.IO.FileAccess]::Write, + [System.IO.FileShare]::None + ) + try { $sourceStream.CopyTo($targetStream) } + finally { $targetStream.Dispose() } + } + finally { $sourceStream.Dispose() } + } + } + } + finally { $zip.Dispose() } + + $roots = @(Get-ChildItem -LiteralPath $Destination -Directory -ErrorAction Stop) + if ($roots.Count -ne 1) { + Invoke-BootstrapFailure -Message "Expected one source root in archive but found $($roots.Count)." -Code $script:ExitCode.Integrity + } + return $roots[0].FullName +} + +function Initialize-FlywheelCliEnvironment { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Python, + [Parameter(Mandatory)][string]$FlywheelHome, + [Parameter(Mandatory)][string]$TemporaryRoot + ) + $resolvedCommit = $null + if ($script:InvocationBoundParameters.ContainsKey('CliPath')) { + $sourcePath = (Resolve-Path -LiteralPath $CliPath -ErrorAction Stop).Path + $sourceIdentity = "local:$sourcePath" + $environmentName = 'cli-local' + } + else { + $resolvedCommit = Resolve-GitHubCommit -RepositoryName $script:CliRepository -Ref $CliRef + $cacheDirectory = Join-Path $FlywheelHome 'cache\cli' + New-BootstrapDirectory -Path $cacheDirectory -Confirm:$false + $archive = Join-Path $cacheDirectory ("$resolvedCommit.zip") + Save-GitHubArchive -RepositoryName $script:CliRepository -CommitSha $resolvedCommit -Destination $archive + $sourcePath = Expand-BootstrapArchive -Archive $archive -Destination (Join-Path $TemporaryRoot 'cli') -Confirm:$false + $sourceIdentity = "github-ref:$script:CliRepository@$resolvedCommit" + $environmentName = "cli-$($resolvedCommit.Substring(0, 12))" + } + + $environment = Join-Path $FlywheelHome ("environments\$environmentName") + $venvPython = Join-Path $environment 'Scripts\python.exe' + $flywheel = Join-Path $environment 'Scripts\flywheel.exe' + $healthy = $false + if ((Test-Path -LiteralPath $venvPython) -and (Test-Path -LiteralPath $flywheel)) { + $healthy = (Invoke-BootstrapNativeCommand -FilePath $flywheel -ArgumentList @('--version') -AllowFailure).ExitCode -eq 0 + } + if (-not $healthy) { + if (Test-Path -LiteralPath $environment) { + Write-BootstrapWarning -Message 'Existing managed CLI environment is unhealthy.' + if ($NonInteractive -or (Confirm-BootstrapAction -Prompt 'Rebuild the Flywheel-owned CLI environment?' -DefaultYes $true)) { Remove-Item -LiteralPath $environment -Recurse -Force -ErrorAction Stop } + else { Invoke-BootstrapFailure -Message 'A healthy AI Flywheel CLI environment is required.' -Code $script:ExitCode.Cancelled } + } + New-BootstrapDirectory -Path (Split-Path -Parent $environment) -Confirm:$false + Write-Host 'Preparing managed AI Flywheel CLI environment...' -ForegroundColor DarkGray + Invoke-BootstrapPython -Python $Python -ArgumentList @('-m', 'venv', $environment) | Out-Null + Invoke-BootstrapNativeCommand -FilePath $venvPython -ArgumentList @('-m', 'pip', 'install', '--disable-pip-version-check', $sourcePath) | Out-Null + } + $versionResult = Invoke-BootstrapNativeCommand -FilePath $flywheel -ArgumentList @('--version') + return [pscustomobject]@{ + Executable = $flywheel + Python = $venvPython + Version = (($versionResult.Output | Select-Object -Last 1).ToString().Trim()) + Environment = $environment + SourceIdentity = $sourceIdentity + ResolvedCommit = $resolvedCommit + } +} + +function Get-TopLevelYamlValue { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Key + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } + foreach ($line in Get-Content -LiteralPath $Path -ErrorAction Stop) { + $match = [regex]::Match($line, ('^' + [regex]::Escape($Key) + ':\s*["'']?([^"'']+?)["'']?\s*$')) + if ($match.Success) { return $match.Groups[1].Value.Trim() } + } + return $null +} + +function Get-ManifestFrameworkVersion { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$Path) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } + $insideFramework = $false + foreach ($line in Get-Content -LiteralPath $Path -ErrorAction Stop) { + if (-not $insideFramework) { + if ($line -match '^framework:\s*$') { $insideFramework = $true } + continue + } + if ($line -match '^\S') { break } + $match = [regex]::Match($line, '^\s+version:\s*["'']?([^"'']+?)["'']?\s*$') + if ($match.Success) { return $match.Groups[1].Value.Trim() } + } + return $null +} + +function Get-FlywheelFrameworkCompatibility { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$Root) + + $flywheel = Join-Path $Root '.flywheel' + if (-not (Test-Path -LiteralPath $flywheel)) { + return [pscustomobject]@{ Status = 'not-installed'; Version = $null; Reason = 'No .flywheel directory is installed.' } + } + if (-not (Test-Path -LiteralPath $flywheel -PathType Container)) { + return [pscustomobject]@{ Status = 'malformed'; Version = $null; Reason = '.flywheel exists but is not a directory.' } + } + + $installation = Join-Path $flywheel 'installation.yaml' + if (-not (Test-Path -LiteralPath $installation -PathType Leaf)) { + return [pscustomobject]@{ Status = 'untracked-or-legacy'; Version = $null; Reason = 'Installation provenance is missing.' } + } + $installedVersion = Get-TopLevelYamlValue -Path $installation -Key 'framework_version' + $manifestVersion = Get-ManifestFrameworkVersion -Path (Join-Path $flywheel 'manifest.yaml') + $calverPattern = '^\d{4}\.\d{2}\.\d{2}$' + if (-not $installedVersion -or $installedVersion -notmatch $calverPattern) { + return [pscustomobject]@{ Status = 'malformed'; Version = $installedVersion; Reason = 'installation.yaml does not contain a valid CalVer framework_version.' } + } + if (-not $manifestVersion -or $manifestVersion -notmatch $calverPattern) { + return [pscustomobject]@{ Status = 'malformed'; Version = $installedVersion; Reason = 'manifest.yaml does not contain a valid framework.version CalVer.' } + } + if ($manifestVersion -ne $installedVersion) { + return [pscustomobject]@{ Status = 'invalid'; Version = $installedVersion; Reason = 'Installation and manifest framework versions disagree.' } + } + + $installed = [version]$installedVersion + $supported = [version]$script:FrameworkVersion + if ($installed -eq $supported) { + return [pscustomobject]@{ Status = 'compatible'; Version = $installedVersion; Reason = 'The installed framework is compatible.' } + } + if ($installed -lt $supported) { + return [pscustomobject]@{ Status = 'older-unsupported'; Version = $installedVersion; Reason = 'The installed framework is older than the supported framework.' } + } + return [pscustomobject]@{ Status = 'newer-unsupported'; Version = $installedVersion; Reason = 'The installed framework is newer than the supported framework.' } +} + +function Invoke-OfficialFrameworkInstaller { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory)][string]$Root, + [Parameter(Mandatory)][string]$TemporaryRoot + ) + + $installerPath = Join-Path $TemporaryRoot 'install-framework.ps1' + Invoke-BootstrapWebRequest -Uri $script:FrameworkInstallerUri -OutFile $installerPath + $arguments = @{ Repository = $Root } + if ($NonInteractive) { + $arguments['NonInteractive'] = $true + $arguments['Apply'] = $true + $arguments['Confirm'] = $false + } + if ($WhatIfPreference) { $arguments['WhatIf'] = $true } + + Write-BootstrapLog -Message "Invoking official framework installer $($script:FrameworkInstallerCommit)" + & $installerPath @arguments +} + +function Remove-BootstrapTemporaryWork { + [CmdletBinding(SupportsShouldProcess = $true)] + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path) -and $PSCmdlet.ShouldProcess($Path, 'Remove temporary bootstrap working directory')) { + Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop + } +} + +function Invoke-AIFlywheelBootstrap { + [CmdletBinding(SupportsShouldProcess = $true)] + param() + try { + if ($env:OS -ne 'Windows_NT') { Invoke-BootstrapFailure -Message 'This bootstrap is intended for Windows.' -Code $script:ExitCode.Prerequisite } + + $flywheelHome = Join-Path $env:LOCALAPPDATA 'AI-Flywheel' + $logDirectory = Join-Path $flywheelHome 'logs' + New-BootstrapDirectory -Path $logDirectory -Confirm:$false + $script:LogPath = Join-Path $logDirectory ('bootstrap-{0}-{1}.log' -f ([DateTimeOffset]::Now.ToString('yyyyMMdd-HHmmss')), $script:RunId.Substring(0, 8)) + New-Item -ItemType File -Path $script:LogPath -Force -ErrorAction Stop | Out-Null + $temporaryBase = Join-Path ([System.IO.Path]::GetTempPath()) 'AIFW' + New-BootstrapDirectory -Path $temporaryBase -Confirm:$false + $script:TemporaryRoot = Join-Path $temporaryBase $script:RunId.Substring(0, 8) + New-BootstrapDirectory -Path $script:TemporaryRoot -Confirm:$false + + Write-Host 'AI Flywheel Setup' -ForegroundColor Cyan + Write-Host 'Preparing this repository for AI Flywheel onboarding.' -ForegroundColor DarkGray + + $script:CurrentStage = 'Repository' + Write-BootstrapSection -Title 'Repository' + $gitExecutable = Get-GitCommand + $root = Get-GitRepositoryRoot -RequestedPath $Repository -GitExecutable $gitExecutable -Confirm:$false + $script:BootstrapContext.RepositoryRoot = $root + Write-BootstrapSuccess -Message 'Git repository detected' + Write-Host "Repository root: $root" + if (-not (Test-BootstrapRepositoryWritable -Root $root)) { Invoke-BootstrapFailure -Message "Repository root is not writable: $root" -Code $script:ExitCode.Prerequisite -Remediation 'Correct repository permissions and retry.' } + Write-BootstrapSuccess -Message 'Repository is writable' + $gitVersion = ((Invoke-BootstrapNativeCommand -FilePath $gitExecutable -ArgumentList @('--version')).Output | Select-Object -Last 1).ToString().Trim() + Write-BootstrapSuccess -Message $gitVersion + $operationState = Get-GitOperationState -Root $root -GitExecutable $gitExecutable + if ($operationState -ne 'Normal') { + Write-BootstrapWarning -Message "Git operation in progress: $operationState" + if ($NonInteractive) { Invoke-BootstrapFailure -Message "Git operation in progress: $operationState" -Code $script:ExitCode.RepositoryConflict -Remediation 'Complete or abort the Git operation before non-interactive setup.' } + if (-not (Confirm-BootstrapAction -Prompt 'Continue setup without changing the existing Git operation?' -DefaultYes $false)) { Invoke-BootstrapFailure -Message 'Setup cancelled while a Git operation is in progress.' -Code $script:ExitCode.Cancelled } + } + $workingStatus = Invoke-BootstrapNativeCommand -FilePath $gitExecutable -ArgumentList @('-C', $root, 'status', '--porcelain') + if ($workingStatus.Output.Count -gt 0) { Write-BootstrapWarning -Message 'Existing Git working changes detected; they will be preserved.' } + + $script:CurrentStage = 'Environment' + Write-BootstrapSection -Title 'Environment' + Write-BootstrapSuccess -Message 'Windows detected' + Write-Host "PowerShell: $($PSVersionTable.PSVersion) ($($PSVersionTable.PSEdition))" + if ($PSVersionTable.PSVersion.Major -lt 7) { Write-BootstrapWarning -Message 'PowerShell 7+ is preferred; Windows PowerShell 5.1 compatibility mode is active.' } + if (Get-Command winget -ErrorAction SilentlyContinue) { Write-BootstrapSuccess -Message 'winget available for prerequisite remediation' } + else { Write-BootstrapWarning -Message 'winget unavailable; automatic prerequisite remediation is limited.' } + + $script:CurrentStage = 'Framework' + Write-BootstrapSection -Title 'AI Flywheel Framework' + $frameworkInstalled = $false + $compatibility = Get-FlywheelFrameworkCompatibility -Root $root + if ($compatibility.Status -eq 'not-installed') { + if ($ValidateOnly) { + Invoke-BootstrapFailure -Message 'AI Flywheel Framework is not installed.' -Code $script:ExitCode.Validation -Remediation 'Run bootstrap without -ValidateOnly to invoke the official framework installer.' + } + if ($NonInteractive -and -not $Apply) { + Invoke-BootstrapFailure -Message 'Non-interactive framework installation requires explicit -Apply authorization.' -Code $script:ExitCode.Cancelled + } + Write-Host "Framework $($script:FrameworkVersion) is not installed." + Write-Host "Using official installer commit: $($script:FrameworkInstallerCommit)" + Invoke-OfficialFrameworkInstaller -Root $root -TemporaryRoot $script:TemporaryRoot + $compatibility = Get-FlywheelFrameworkCompatibility -Root $root + if ($WhatIfPreference -and $compatibility.Status -eq 'not-installed') { + Write-BootstrapWarning -Message 'WhatIf mode: the official framework installer was evaluated without installation.' + return $script:ExitCode.Success + } + if ($compatibility.Status -eq 'not-installed') { + Invoke-BootstrapFailure -Message 'The official framework installer completed without installing a framework.' -Code $script:ExitCode.Cancelled -Remediation 'Installation may have been cancelled. Run bootstrap again when ready.' + } + $frameworkInstalled = $true + } + if ($compatibility.Status -ne 'compatible') { + $versionText = if ($compatibility.Version) { $compatibility.Version } else { 'unknown' } + Invoke-BootstrapFailure -Message "Existing framework is not compatible: $($compatibility.Status) (version $versionText)." -Code $script:ExitCode.RepositoryConflict -Remediation "$($compatibility.Reason) Bootstrap will not overwrite .flywheel. No automatic framework upgrade contract is currently published." + } + $script:BootstrapContext.FrameworkVersion = $compatibility.Version + $script:BootstrapContext.FrameworkCompatibility = $compatibility.Status + if ($frameworkInstalled) { Write-BootstrapSuccess -Message "Official framework $($compatibility.Version) installed" } + else { Write-BootstrapSuccess -Message "Compatible framework $($compatibility.Version) already installed and left intact" } + + $script:CurrentStage = 'Python' + Write-BootstrapSection -Title 'Python' + $python = Get-OrInstallPythonRuntime + $script:BootstrapContext.PythonVersion = $python.Version.ToString() + $script:BootstrapContext.PythonExecutable = $python.Executable + Write-BootstrapSuccess -Message "Python $($python.Version) detected" + Write-Host "Python: $($python.Executable)" + + $script:CurrentStage = 'CLI' + Write-BootstrapSection -Title 'AI Flywheel CLI' + $cli = Initialize-FlywheelCliEnvironment -Python $python -FlywheelHome $flywheelHome -TemporaryRoot $script:TemporaryRoot + $script:BootstrapContext.CliVersion = $cli.Version + $script:BootstrapContext.CliExecutable = $cli.Executable + $script:BootstrapContext.CliResolvedCommit = $cli.ResolvedCommit + Write-BootstrapSuccess -Message "AI Flywheel CLI $($cli.Version) ready" + + $script:CurrentStage = 'Validation' + Write-BootstrapSection -Title 'Compatibility and Health' + $doctorResult = Invoke-BootstrapNativeCommand -FilePath $cli.Executable -ArgumentList @('doctor', $root, '--json') -AllowFailure + if ($doctorResult.ExitCode -ne 0) { + $doctorDetail = ($doctorResult.Output -join [Environment]::NewLine).Trim() + Invoke-BootstrapFailure -Message 'CLI health and framework compatibility checks failed.' -Code $script:ExitCode.Validation -Remediation $doctorDetail + } + Write-BootstrapSuccess -Message 'CLI health and framework compatibility checks passed' + $script:BootstrapContext.OnboardingReady = $true + + $script:CurrentStage = 'Complete' + Write-BootstrapSection -Title 'AI Flywheel Setup Complete' + Write-Host "Repository: $root" + Write-Host "Framework: $($compatibility.Version)" + Write-Host "Framework installer commit: $($script:FrameworkInstallerCommit)" + Write-Host "CLI: $($cli.Version)" + Write-Host 'Compatibility: Passed' + Write-Host 'Repository validation: Passed' + Write-Host 'No onboarding or lifecycle operation was started.' + if ($script:Warnings.Count -gt 0) { Write-Host "Warnings: $($script:Warnings.Count)" -ForegroundColor Yellow } + Write-Host "Diagnostic log: $script:LogPath" -ForegroundColor DarkGray + Write-Host '' + Write-Host 'Next: Begin or continue AI Flywheel operation in this repository.' -ForegroundColor Cyan + return $script:ExitCode.Success + } + catch { + if ($_.Exception.Data['BootstrapExpected']) { return [int]$_.Exception.Data['BootstrapExitCode'] } + Write-UnexpectedBootstrapError -ErrorRecord $_ + return $script:ExitCode.Installation + } + finally { + $script:CurrentStage = 'Cleanup' + if ($script:TemporaryRoot -and (Test-Path -LiteralPath $script:TemporaryRoot)) { + try { Remove-BootstrapTemporaryWork -Path $script:TemporaryRoot -Confirm:$false } + catch { Write-BootstrapWarning -Message "Temporary bootstrap work could not be fully removed: $script:TemporaryRoot"; Write-BootstrapLog -Message "Cleanup failure: $($_.Exception.ToString())" } + } + } +} + +if ($MyInvocation.InvocationName -ne '.') { + $resultCode = Invoke-AIFlywheelBootstrap + exit $resultCode +} diff --git a/src/ai_flywheel_cli/cli.py b/src/ai_flywheel_cli/cli.py index 1891ed4..a6b6438 100644 --- a/src/ai_flywheel_cli/cli.py +++ b/src/ai_flywheel_cli/cli.py @@ -12,21 +12,22 @@ advance_lifecycle, start_execution, ) +from ai_flywheel_cli.framework_compatibility import ( + SUPPORTED_FRAMEWORK_VERSION, + classify_framework, +) from ai_flywheel_cli.mutation import MutationRejectedError, load_yaml_mapping from ai_flywheel_cli.operations import ( LockContentionError, OperationError, RepositoryConflictError, - install_from_archive, - plan_install, ) from ai_flywheel_cli.persistence import persist_execution -from ai_flywheel_cli.upgrade import upgrade_from_archive from ai_flywheel_cli.validation import validate_repository app = typer.Typer( name="flywheel", - help="Install, inspect, validate, upgrade, and safely operate AI Flywheel artifacts.", + help="Inspect, validate, and safely operate AI Flywheel artifacts.", no_args_is_help=True, invoke_without_command=True, ) @@ -39,6 +40,7 @@ EXIT_LOCK_CONTENTION = 5 EXIT_AI_FALLBACK_REQUIRED = 6 EXIT_OPERATION_FAILED = 7 +EXIT_FRAMEWORK_INCOMPATIBLE = 8 def _emit(payload: dict[str, object], *, as_json: bool) -> None: @@ -98,18 +100,37 @@ def doctor( repository: Path = typer.Argument(Path.cwd(), exists=True, file_okay=False), json_output: bool = typer.Option(False, "--json", help="Emit deterministic JSON output."), ) -> None: - """Inspect local prerequisites without modifying the repository.""" - flywheel_path = repository / ".flywheel" + """Report CLI, framework-compatibility, and repository health without mutation.""" + compatibility = classify_framework(repository) + validation_status = "not-run" + validation_issues: list[dict[str, str]] = [] + status_value = "framework-incompatible" + exit_code = EXIT_FRAMEWORK_INCOMPATIBLE + if compatibility.compatible: + validation = validate_repository(repository) + validation_issues = [issue.as_dict() for issue in validation.issues] + validation_status = "passed" if validation.passed else "failed" + status_value = "ok" if validation.passed else "validation-failed" + exit_code = EXIT_SUCCESS if validation.passed else EXIT_VALIDATION_FAILED + _emit( { "command": "doctor", "repository": str(repository.resolve()), - "flywheel_exists": flywheel_path.is_dir(), - "repository_writable": repository.exists() and repository.is_dir(), - "status": "ok", + "cli_version": __version__, + "supported_framework_version": SUPPORTED_FRAMEWORK_VERSION, + "installed_framework_version": compatibility.installed_version, + "framework_status": compatibility.status, + "framework_reason": compatibility.reason, + "repository_validation_status": validation_status, + "repository_issue_count": len(validation_issues), + "repository_issues": validation_issues, + "status": status_value, }, as_json=json_output, ) + if exit_code != EXIT_SUCCESS: + raise typer.Exit(code=exit_code) @app.command() @@ -260,71 +281,3 @@ def complete_execution_command( _operation_exit(error, command="complete-execution", as_json=json_output) return _emit(result.as_dict(), as_json=json_output) - - -@app.command() -def install( - repository: Path = typer.Argument(Path.cwd(), exists=True, file_okay=False), - archive: Path = typer.Option(..., "--archive", exists=True, dir_okay=False), - checksum: str = typer.Option(..., "--checksum", help="Expected SHA-256 checksum."), - framework_version: str = typer.Option(..., "--framework-version"), - source_identity: str = typer.Option("local-archive", "--source-identity"), - apply: bool = typer.Option(False, "--apply", "--yes", help="Apply the displayed plan."), - json_output: bool = typer.Option(False, "--json", help="Emit deterministic JSON output."), -) -> None: - """Install verified Flywheel artifacts transactionally from an immutable archive.""" - try: - plan = plan_install(archive, framework_version) - if not apply: - _emit( - {**plan.as_dict(), "status": "planned", "apply_required": True}, - as_json=json_output, - ) - return - result = install_from_archive( - repository, - archive, - checksum, - framework_version, - source_identity, - ) - except OperationError as error: - _operation_exit(error, command="install", as_json=json_output) - return - _emit(result.as_dict(), as_json=json_output) - - -@app.command() -def upgrade( - repository: Path = typer.Argument(Path.cwd(), exists=True, file_okay=False), - archive: Path = typer.Option(..., "--archive", exists=True, dir_okay=False), - checksum: str = typer.Option(..., "--checksum", help="Expected SHA-256 checksum."), - framework_version: str = typer.Option(..., "--framework-version"), - source_identity: str = typer.Option("local-archive", "--source-identity"), - apply: bool = typer.Option(False, "--apply", "--yes", help="Apply the displayed upgrade."), - json_output: bool = typer.Option(False, "--json", help="Emit deterministic JSON output."), -) -> None: - """Upgrade verified Flywheel artifacts with conflict detection and rollback.""" - if not apply: - _emit( - { - "command": "upgrade", - "status": "planned", - "framework_version": framework_version, - "apply_required": True, - }, - as_json=json_output, - ) - return - try: - result = upgrade_from_archive( - repository, - archive, - checksum, - framework_version, - source_identity, - ) - except OperationError as error: - _operation_exit(error, command="upgrade", as_json=json_output) - return - _emit(result.as_dict(), as_json=json_output) diff --git a/src/ai_flywheel_cli/framework_compatibility.py b/src/ai_flywheel_cli/framework_compatibility.py new file mode 100644 index 0000000..94dff48 --- /dev/null +++ b/src/ai_flywheel_cli/framework_compatibility.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +SUPPORTED_FRAMEWORK_VERSION = "2026.08.08" + + +@dataclass(frozen=True) +class FrameworkCompatibility: + status: str + installed_version: str | None + reason: str + + @property + def compatible(self) -> bool: + return self.status == "compatible" + + +def _mapping(path: Path) -> dict[str, Any] | None: + try: + value = yaml.safe_load(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, yaml.YAMLError): + return None + return value if isinstance(value, dict) else None + + +def _calver(value: object) -> tuple[int, int, int] | None: + if not isinstance(value, str): + return None + parts = value.split(".") + if len(parts) != 3 or any(not part.isdigit() for part in parts): + return None + return int(parts[0]), int(parts[1]), int(parts[2]) + + +def classify_framework(repository: Path) -> FrameworkCompatibility: + flywheel = repository.resolve() / ".flywheel" + if not flywheel.exists(): + return FrameworkCompatibility( + "not-installed", + None, + "No .flywheel directory is installed.", + ) + if not flywheel.is_dir(): + return FrameworkCompatibility( + "malformed", + None, + ".flywheel exists but is not a directory.", + ) + + installation_path = flywheel / "installation.yaml" + if not installation_path.is_file(): + return FrameworkCompatibility( + "untracked-or-legacy", + None, + "Installation provenance is missing; the framework will not be overwritten.", + ) + installation = _mapping(installation_path) + if installation is None: + return FrameworkCompatibility( + "malformed", + None, + "installation.yaml is not a readable YAML mapping.", + ) + installed_version = installation.get("framework_version") + installed_calver = _calver(installed_version) + if installed_calver is None: + return FrameworkCompatibility( + "malformed", + str(installed_version) if installed_version is not None else None, + "installation.yaml does not contain a valid CalVer framework_version.", + ) + + manifest = _mapping(flywheel / "manifest.yaml") + if manifest is None: + return FrameworkCompatibility( + "malformed", + str(installed_version), + "manifest.yaml is missing or is not a readable YAML mapping.", + ) + framework = manifest.get("framework") + manifest_version = framework.get("version") if isinstance(framework, dict) else None + if _calver(manifest_version) is None: + return FrameworkCompatibility( + "malformed", + str(installed_version), + "manifest.yaml does not contain a valid framework.version CalVer.", + ) + if manifest_version != installed_version: + return FrameworkCompatibility( + "invalid", + str(installed_version), + "installation.yaml and manifest.yaml report different framework versions.", + ) + + supported_calver = _calver(SUPPORTED_FRAMEWORK_VERSION) + if installed_calver == supported_calver: + return FrameworkCompatibility( + "compatible", + str(installed_version), + "The installed framework is compatible with this CLI.", + ) + if supported_calver is not None and installed_calver < supported_calver: + return FrameworkCompatibility( + "older-unsupported", + str(installed_version), + "The installed framework is older than the supported framework; " + "no automatic upgrade contract is available.", + ) + return FrameworkCompatibility( + "newer-unsupported", + str(installed_version), + "The installed framework is newer than the supported framework and cannot be " + "assumed compatible.", + ) diff --git a/src/ai_flywheel_cli/operations.py b/src/ai_flywheel_cli/operations.py index d400940..1491dfe 100644 --- a/src/ai_flywheel_cli/operations.py +++ b/src/ai_flywheel_cli/operations.py @@ -1,27 +1,14 @@ from __future__ import annotations -import hashlib import json import os -import shutil import socket -import tempfile import uuid -import zipfile from contextlib import AbstractContextManager -from dataclasses import dataclass from datetime import UTC, datetime -from pathlib import Path, PurePosixPath -from typing import Any - -import yaml +from pathlib import Path RUNTIME_DIRECTORY = ".flywheel/.runtime" -INSTALLATION_METADATA = ".flywheel/installation.yaml" -MUTABLE_PREFIXES = ( - ".flywheel/state.yaml", - ".flywheel/operations/", -) class OperationError(RuntimeError): @@ -36,110 +23,6 @@ class LockContentionError(OperationError): """Raised when another repository mutation owns the operation lock.""" -class ArchiveSafetyError(OperationError): - """Raised when an archive contains unsafe or ambiguous paths.""" - - -class ChecksumMismatchError(OperationError): - """Raised when downloaded or supplied content fails checksum verification.""" - - -@dataclass(frozen=True) -class ChangePlan: - command: str - files: tuple[str, ...] - framework_version: str - - def as_dict(self) -> dict[str, object]: - return { - "command": self.command, - "files": list(self.files), - "framework_version": self.framework_version, - } - - -@dataclass(frozen=True) -class OperationResult: - command: str - status: str - framework_version: str - files_changed: tuple[str, ...] - - def as_dict(self) -> dict[str, object]: - return { - "command": self.command, - "status": self.status, - "framework_version": self.framework_version, - "files_changed": list(self.files_changed), - } - - -def sha256_file(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as stream: - for block in iter(lambda: stream.read(1024 * 1024), b""): - digest.update(block) - return digest.hexdigest() - - -def verify_checksum(path: Path, expected: str) -> None: - normalized = expected.strip().lower() - actual = sha256_file(path) - if actual != normalized: - raise ChecksumMismatchError( - f"SHA-256 mismatch for {path.name}: expected {normalized}, found {actual}." - ) - - -def _safe_relative_path(name: str) -> Path: - normalized_name = name.replace("\\", "/") - pure = PurePosixPath(normalized_name) - if pure.is_absolute() or ".." in pure.parts: - raise ArchiveSafetyError(f"Unsafe archive path: {name}") - if not pure.parts or pure.parts[0] != ".flywheel": - raise ArchiveSafetyError(f"Archive content must be rooted under .flywheel: {name}") - return Path(*pure.parts) - - -def inspect_archive(archive_path: Path) -> tuple[str, ...]: - destinations: set[str] = set() - files: list[str] = [] - try: - with zipfile.ZipFile(archive_path) as archive: - for info in archive.infolist(): - relative = _safe_relative_path(info.filename) - destination = relative.as_posix().rstrip("/") - if not destination: - continue - if destination in destinations: - raise ArchiveSafetyError(f"Duplicate archive destination: {destination}") - destinations.add(destination) - mode = info.external_attr >> 16 - if mode & 0o170000 == 0o120000: - raise ArchiveSafetyError(f"Symbolic links are not permitted: {destination}") - if not info.is_dir(): - files.append(destination) - except zipfile.BadZipFile as error: - raise ArchiveSafetyError(f"Invalid ZIP archive: {archive_path}") from error - return tuple(sorted(files)) - - -def extract_archive(archive_path: Path, destination: Path) -> tuple[str, ...]: - files = inspect_archive(archive_path) - destination.mkdir(parents=True, exist_ok=True) - with zipfile.ZipFile(archive_path) as archive: - for info in archive.infolist(): - relative = _safe_relative_path(info.filename) - target = destination / relative - if info.is_dir(): - target.mkdir(parents=True, exist_ok=True) - continue - target.parent.mkdir(parents=True, exist_ok=True) - with archive.open(info) as source, target.open("wb") as output: - shutil.copyfileobj(source, output) - return files - - class RepositoryLock(AbstractContextManager["RepositoryLock"]): def __init__(self, repository: Path, command: str) -> None: self.repository = repository.resolve() @@ -174,124 +57,3 @@ def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: if self._owned: self.lock_path.unlink(missing_ok=True) self._owned = False - - -def _owned_file(path: str) -> bool: - return not any(path == prefix or path.startswith(prefix) for prefix in MUTABLE_PREFIXES) - - -def _write_metadata( - repository: Path, - framework_version: str, - archive_checksum: str, - source_identity: str, - files: tuple[str, ...], -) -> None: - owned = { - path: sha256_file(repository / path) - for path in files - if _owned_file(path) and (repository / path).is_file() - } - metadata = { - "schema_version": 1, - "framework_version": framework_version, - "archive_sha256": archive_checksum, - "source_identity": source_identity, - "installed_at": datetime.now(UTC).isoformat(), - "owned_files": dict(sorted(owned.items())), - } - target = repository / INSTALLATION_METADATA - target.parent.mkdir(parents=True, exist_ok=True) - temporary = target.with_suffix(".yaml.tmp") - temporary.write_text(yaml.safe_dump(metadata, sort_keys=False), encoding="utf-8") - os.replace(temporary, target) - - -def load_installation_metadata(repository: Path) -> dict[str, Any]: - path = repository / INSTALLATION_METADATA - if not path.is_file(): - raise RepositoryConflictError(f"Missing installation metadata: {INSTALLATION_METADATA}") - value = yaml.safe_load(path.read_text(encoding="utf-8")) - if not isinstance(value, dict): - raise RepositoryConflictError("Installation metadata must be a YAML mapping.") - return value - - -def detect_upgrade_conflicts(repository: Path, metadata: dict[str, Any]) -> tuple[str, ...]: - owned_files = metadata.get("owned_files") - if not isinstance(owned_files, dict): - raise RepositoryConflictError("Installation metadata owned_files must be a mapping.") - conflicts: list[str] = [] - for path, baseline in owned_files.items(): - if not isinstance(path, str) or not isinstance(baseline, str): - raise RepositoryConflictError( - "Installation metadata contains an invalid checksum entry." - ) - target = repository / path - if not target.is_file() or sha256_file(target) != baseline: - conflicts.append(path) - return tuple(sorted(conflicts)) - - -def _apply_staged_tree(repository: Path, staged_root: Path, files: tuple[str, ...]) -> None: - backup_root = Path(tempfile.mkdtemp(prefix="flywheel-backup-", dir=repository)) - changed: list[str] = [] - try: - for path in files: - source = staged_root / path - target = repository / path - backup = backup_root / path - if target.exists(): - backup.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(target, backup) - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(source, target) - changed.append(path) - except Exception: - for path in reversed(changed): - target = repository / path - backup = backup_root / path - if backup.exists(): - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(backup, target) - else: - target.unlink(missing_ok=True) - raise - finally: - shutil.rmtree(backup_root, ignore_errors=True) - - -def plan_install(archive_path: Path, framework_version: str) -> ChangePlan: - return ChangePlan("install", inspect_archive(archive_path), framework_version) - - -def install_from_archive( - repository: Path, - archive_path: Path, - expected_checksum: str, - framework_version: str, - source_identity: str, -) -> OperationResult: - repository = repository.resolve() - if (repository / ".flywheel").exists(): - raise RepositoryConflictError("Refusing to install because .flywheel already exists.") - verify_checksum(archive_path, expected_checksum) - files = inspect_archive(archive_path) - with RepositoryLock(repository, "install"): - staging = Path(tempfile.mkdtemp(prefix="flywheel-stage-", dir=repository)) - try: - extract_archive(archive_path, staging) - _apply_staged_tree(repository, staging, files) - _write_metadata( - repository, - framework_version, - sha256_file(archive_path), - source_identity, - files, - ) - except Exception: - shutil.rmtree(repository / ".flywheel", ignore_errors=True) - raise - finally: - shutil.rmtree(staging, ignore_errors=True) - return OperationResult("install", "installed", framework_version, files) diff --git a/src/ai_flywheel_cli/upgrade.py b/src/ai_flywheel_cli/upgrade.py deleted file mode 100644 index d0310ee..0000000 --- a/src/ai_flywheel_cli/upgrade.py +++ /dev/null @@ -1,68 +0,0 @@ -from __future__ import annotations - -import shutil -import tempfile -from pathlib import Path - -from ai_flywheel_cli.operations import ( - MUTABLE_PREFIXES, - OperationResult, - RepositoryConflictError, - RepositoryLock, - _apply_staged_tree, - _write_metadata, - detect_upgrade_conflicts, - extract_archive, - inspect_archive, - load_installation_metadata, - sha256_file, - verify_checksum, -) - - -def _mutable(path: str) -> bool: - return any(path == prefix or path.startswith(prefix) for prefix in MUTABLE_PREFIXES) - - -def upgrade_from_archive( - repository: Path, - archive_path: Path, - expected_checksum: str, - framework_version: str, - source_identity: str, -) -> OperationResult: - repository = repository.resolve() - if not (repository / ".flywheel").is_dir(): - raise RepositoryConflictError("Cannot upgrade because .flywheel is not installed.") - - metadata = load_installation_metadata(repository) - conflicts = detect_upgrade_conflicts(repository, metadata) - if conflicts: - raise RepositoryConflictError( - "Locally modified framework-owned files prevent upgrade: " + ", ".join(conflicts) - ) - - current_version = str(metadata.get("framework_version", "0.0.0")) - if current_version.split(".", 1)[0] != framework_version.split(".", 1)[0]: - raise RepositoryConflictError("Major-version upgrades require an explicit migration path.") - - verify_checksum(archive_path, expected_checksum) - archive_files = inspect_archive(archive_path) - changed_files = tuple(path for path in archive_files if not _mutable(path)) - - with RepositoryLock(repository, "upgrade"): - staging = Path(tempfile.mkdtemp(prefix="flywheel-stage-", dir=repository)) - try: - extract_archive(archive_path, staging) - _apply_staged_tree(repository, staging, changed_files) - _write_metadata( - repository, - framework_version, - sha256_file(archive_path), - source_identity, - archive_files, - ) - finally: - shutil.rmtree(staging, ignore_errors=True) - - return OperationResult("upgrade", "upgraded", framework_version, changed_files) diff --git a/tests/test_cli.py b/tests/test_cli.py index 58e26d1..0320f8e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,26 +1,19 @@ from __future__ import annotations -import hashlib import json -import zipfile from pathlib import Path from typer.testing import CliRunner import ai_flywheel_cli.cli as cli from ai_flywheel_cli.deterministic_operations import UnsupportedDeterministicOperationError -from ai_flywheel_cli.operations import LockContentionError, OperationError +from ai_flywheel_cli.framework_compatibility import FrameworkCompatibility +from ai_flywheel_cli.operations import OperationError +from ai_flywheel_cli.validation import ValidationResult runner = CliRunner() -def _archive(path: Path, files: dict[str, str]) -> Path: - with zipfile.ZipFile(path, "w") as archive: - for name, content in files.items(): - archive.writestr(name, content) - return path - - def test_version_is_available() -> None: result = runner.invoke(cli.app, ["--version"]) @@ -31,13 +24,34 @@ def test_version_is_available() -> None: def test_doctor_reports_repository_without_modifying_it(tmp_path: Path) -> None: result = runner.invoke(cli.app, ["doctor", str(tmp_path), "--json"]) - assert result.exit_code == 0 + assert result.exit_code == 8 payload = json.loads(result.stdout) assert payload["command"] == "doctor" - assert payload["flywheel_exists"] is False + assert payload["framework_status"] == "not-installed" + assert payload["supported_framework_version"] == "2026.08.08" + assert payload["repository_validation_status"] == "not-run" assert list(tmp_path.iterdir()) == [] +def test_doctor_reports_compatible_valid_repository(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr( + cli, + "classify_framework", + lambda _: FrameworkCompatibility("compatible", "2026.08.08", "Compatible."), + ) + monkeypatch.setattr(cli, "validate_repository", lambda _: ValidationResult(issues=())) + + result = runner.invoke(cli.app, ["doctor", str(tmp_path), "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["status"] == "ok" + assert payload["cli_version"] == "0.1.0" + assert payload["framework_status"] == "compatible" + assert payload["installed_framework_version"] == "2026.08.08" + assert payload["repository_validation_status"] == "passed" + + def test_status_reports_not_installed_when_state_is_missing(tmp_path: Path) -> None: result = runner.invoke(cli.app, ["status", str(tmp_path), "--json"]) @@ -77,94 +91,11 @@ def test_usage_errors_keep_typer_exit_code_2() -> None: assert result.exit_code == 2 -def test_install_displays_plan_without_apply(tmp_path: Path) -> None: - archive = _archive( - tmp_path / "framework.zip", - {".flywheel/manifest.yaml": "schema_version: 1\n"}, - ) - - result = runner.invoke( - cli.app, - [ - "install", - str(tmp_path), - "--archive", - str(archive), - "--checksum", - hashlib.sha256(archive.read_bytes()).hexdigest(), - "--framework-version", - "0.1.0", - "--json", - ], - ) - - assert result.exit_code == 0 - payload = json.loads(result.stdout) - assert payload["status"] == "planned" - assert payload["apply_required"] is True - - -def test_install_reports_repository_conflict(tmp_path: Path) -> None: - (tmp_path / ".flywheel").mkdir() - archive = _archive( - tmp_path / "framework.zip", - {".flywheel/manifest.yaml": "schema_version: 1\n"}, - ) - - result = runner.invoke( - cli.app, - [ - "install", - str(tmp_path), - "--archive", - str(archive), - "--checksum", - hashlib.sha256(archive.read_bytes()).hexdigest(), - "--framework-version", - "0.1.0", - "--apply", - "--json", - ], - ) - - assert result.exit_code == 4 - payload = json.loads(result.stdout) - assert payload["status"] == "repository-conflict" - assert payload["category"] == "repository-conflict" - assert payload["reason"] == "repository-content-conflict" - - -def test_install_reports_lock_contention(monkeypatch, tmp_path: Path) -> None: - archive = _archive( - tmp_path / "framework.zip", - {".flywheel/manifest.yaml": "schema_version: 1\n"}, - ) - - def conflict(*_args, **_kwargs): - raise LockContentionError("lock busy") - - monkeypatch.setattr(cli, "install_from_archive", conflict) - - result = runner.invoke( - cli.app, - [ - "install", - str(tmp_path), - "--archive", - str(archive), - "--checksum", - hashlib.sha256(archive.read_bytes()).hexdigest(), - "--framework-version", - "0.1.0", - "--apply", - "--json", - ], - ) - - assert result.exit_code == 5 - payload = json.loads(result.stdout) - assert payload["status"] == "lock-contention" - assert payload["reason"] == "repository-lock-active" +def test_install_and_upgrade_commands_are_not_exposed() -> None: + for command in ("install", "upgrade"): + result = runner.invoke(cli.app, [command]) + assert result.exit_code == 2 + assert "No such command" in result.output def test_advance_lifecycle_reports_ai_fallback(monkeypatch, tmp_path: Path) -> None: @@ -216,58 +147,3 @@ def reject(*_args, **_kwargs): assert payload["category"] == "operation-failed" assert payload["reason"] == "operation-error" assert "failures" not in payload - - -def test_upgrade_displays_plan_without_apply(tmp_path: Path) -> None: - archive = _archive( - tmp_path / "framework.zip", - {".flywheel/manifest.yaml": "schema_version: 1\n"}, - ) - - result = runner.invoke( - cli.app, - [ - "upgrade", - str(tmp_path), - "--archive", - str(archive), - "--checksum", - hashlib.sha256(archive.read_bytes()).hexdigest(), - "--framework-version", - "0.2.0", - "--json", - ], - ) - - assert result.exit_code == 0 - payload = json.loads(result.stdout) - assert payload["status"] == "planned" - - -def test_upgrade_reports_repository_conflict(tmp_path: Path) -> None: - archive = _archive( - tmp_path / "framework.zip", - {".flywheel/manifest.yaml": "schema_version: 1\n"}, - ) - - result = runner.invoke( - cli.app, - [ - "upgrade", - str(tmp_path), - "--archive", - str(archive), - "--checksum", - hashlib.sha256(archive.read_bytes()).hexdigest(), - "--framework-version", - "0.2.0", - "--apply", - "--json", - ], - ) - - assert result.exit_code == 4 - payload = json.loads(result.stdout) - assert payload["status"] == "repository-conflict" - assert payload["category"] == "repository-conflict" - assert payload["reason"] == "repository-content-conflict" diff --git a/tests/test_framework_compatibility.py b/tests/test_framework_compatibility.py new file mode 100644 index 0000000..e4918c9 --- /dev/null +++ b/tests/test_framework_compatibility.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from pathlib import Path + +import yaml + +from ai_flywheel_cli.framework_compatibility import classify_framework + + +def _write_yaml(path: Path, value: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(yaml.safe_dump(value, sort_keys=False), encoding="utf-8") + + +def _framework(repository: Path, installation_version: str, manifest_version: str) -> None: + _write_yaml( + repository / ".flywheel/installation.yaml", + {"schema_version": 1, "framework_version": installation_version}, + ) + _write_yaml( + repository / ".flywheel/manifest.yaml", + {"schema_version": 1, "framework": {"version": manifest_version}}, + ) + + +def test_classifies_absent_framework(tmp_path: Path) -> None: + assert classify_framework(tmp_path).status == "not-installed" + + +def test_classifies_supported_framework(tmp_path: Path) -> None: + _framework(tmp_path, "2026.08.08", "2026.08.08") + result = classify_framework(tmp_path) + assert result.status == "compatible" + assert result.installed_version == "2026.08.08" + + +def test_classifies_older_and_newer_frameworks(tmp_path: Path) -> None: + _framework(tmp_path, "2026.07.31", "2026.07.31") + assert classify_framework(tmp_path).status == "older-unsupported" + _framework(tmp_path, "2026.08.09", "2026.08.09") + assert classify_framework(tmp_path).status == "newer-unsupported" + + +def test_classifies_framework_without_provenance(tmp_path: Path) -> None: + _write_yaml( + tmp_path / ".flywheel/manifest.yaml", + {"schema_version": 1, "framework": {"version": "2026.08.08"}}, + ) + assert classify_framework(tmp_path).status == "untracked-or-legacy" + + +def test_classifies_malformed_metadata(tmp_path: Path) -> None: + installation = tmp_path / ".flywheel/installation.yaml" + installation.parent.mkdir(parents=True) + installation.write_text("- not\n- a\n- mapping\n", encoding="utf-8") + assert classify_framework(tmp_path).status == "malformed" + + +def test_rejects_manifest_and_installation_version_disagreement(tmp_path: Path) -> None: + _framework(tmp_path, "2026.08.08", "2026.08.09") + assert classify_framework(tmp_path).status == "invalid" diff --git a/tests/test_operations.py b/tests/test_operations.py index 65967d8..7710767 100644 --- a/tests/test_operations.py +++ b/tests/test_operations.py @@ -1,148 +1,23 @@ from __future__ import annotations -import hashlib -import zipfile from pathlib import Path import pytest -from ai_flywheel_cli.operations import ( - ArchiveSafetyError, - ChecksumMismatchError, - LockContentionError, - RepositoryConflictError, - RepositoryLock, - detect_upgrade_conflicts, - inspect_archive, - install_from_archive, - load_installation_metadata, - sha256_file, - verify_checksum, -) -from ai_flywheel_cli.upgrade import upgrade_from_archive - - -def _archive(path: Path, files: dict[str, str]) -> Path: - with zipfile.ZipFile(path, "w") as archive: - for name, content in files.items(): - archive.writestr(name, content) - return path - - -def test_checksum_verification_accepts_expected_digest(tmp_path: Path) -> None: - value = tmp_path / "value.bin" - value.write_bytes(b"flywheel") - verify_checksum(value, hashlib.sha256(b"flywheel").hexdigest()) - - -def test_checksum_verification_rejects_mismatch(tmp_path: Path) -> None: - value = tmp_path / "value.bin" - value.write_bytes(b"flywheel") - with pytest.raises(ChecksumMismatchError): - verify_checksum(value, "0" * 64) - - -def test_archive_rejects_path_traversal(tmp_path: Path) -> None: - archive = _archive(tmp_path / "unsafe.zip", {"../escape.txt": "bad"}) - with pytest.raises(ArchiveSafetyError): - inspect_archive(archive) - - -def test_archive_requires_flywheel_root(tmp_path: Path) -> None: - archive = _archive(tmp_path / "unsafe.zip", {"README.md": "bad"}) - with pytest.raises(ArchiveSafetyError): - inspect_archive(archive) +from ai_flywheel_cli.operations import LockContentionError, RepositoryLock def test_repository_lock_rejects_contention(tmp_path: Path) -> None: with ( - RepositoryLock(tmp_path, "install"), + RepositoryLock(tmp_path, "persist-execution"), pytest.raises(LockContentionError), - RepositoryLock(tmp_path, "upgrade"), + RepositoryLock(tmp_path, "advance-lifecycle"), ): pass -def test_install_refuses_existing_flywheel(tmp_path: Path) -> None: - (tmp_path / ".flywheel").mkdir() - archive = _archive( - tmp_path / "framework.zip", {".flywheel/manifest.yaml": "schema_version: 1\n"} - ) - with pytest.raises(RepositoryConflictError): - install_from_archive(tmp_path, archive, sha256_file(archive), "0.1.0", "fixture") - - -def test_install_creates_files_and_metadata(tmp_path: Path) -> None: - archive = _archive( - tmp_path / "framework.zip", - { - ".flywheel/manifest.yaml": "schema_version: 1\n", - ".flywheel/state.yaml": "schema_version: 1\n", - }, - ) - result = install_from_archive( - tmp_path, - archive, - sha256_file(archive), - "0.1.0", - "fixture-release", - ) - assert result.status == "installed" - assert (tmp_path / ".flywheel/manifest.yaml").is_file() - metadata = load_installation_metadata(tmp_path) - assert metadata["framework_version"] == "0.1.0" - assert ".flywheel/manifest.yaml" in metadata["owned_files"] - assert ".flywheel/state.yaml" not in metadata["owned_files"] - - -def test_upgrade_detects_local_modification(tmp_path: Path) -> None: - archive = _archive( - tmp_path / "framework.zip", - { - ".flywheel/manifest.yaml": "schema_version: 1\n", - ".flywheel/state.yaml": "schema_version: 1\n", - }, - ) - install_from_archive(tmp_path, archive, sha256_file(archive), "0.1.0", "fixture") - (tmp_path / ".flywheel/manifest.yaml").write_text("changed: true\n", encoding="utf-8") - conflicts = detect_upgrade_conflicts(tmp_path, load_installation_metadata(tmp_path)) - assert conflicts == (".flywheel/manifest.yaml",) - - -def test_upgrade_refuses_local_modification(tmp_path: Path) -> None: - initial = _archive( - tmp_path / "initial.zip", - { - ".flywheel/manifest.yaml": "schema_version: 1\n", - ".flywheel/state.yaml": "schema_version: 1\n", - }, - ) - install_from_archive(tmp_path, initial, sha256_file(initial), "0.1.0", "fixture") - (tmp_path / ".flywheel/manifest.yaml").write_text("changed: true\n", encoding="utf-8") - target = _archive(tmp_path / "target.zip", {".flywheel/manifest.yaml": "schema_version: 2\n"}) - with pytest.raises(RepositoryConflictError): - upgrade_from_archive(tmp_path, target, sha256_file(target), "0.2.0", "fixture") - +def test_repository_lock_is_removed_after_operation(tmp_path: Path) -> None: + with RepositoryLock(tmp_path, "persist-execution"): + assert (tmp_path / ".flywheel/.runtime/operation.lock").is_file() -def test_upgrade_preserves_mutable_state(tmp_path: Path) -> None: - initial = _archive( - tmp_path / "initial.zip", - { - ".flywheel/manifest.yaml": "schema_version: 1\n", - ".flywheel/state.yaml": "value: original\n", - }, - ) - install_from_archive(tmp_path, initial, sha256_file(initial), "0.1.0", "fixture") - (tmp_path / ".flywheel/state.yaml").write_text("value: local\n", encoding="utf-8") - target = _archive( - tmp_path / "target.zip", - { - ".flywheel/manifest.yaml": "schema_version: 2\n", - ".flywheel/state.yaml": "value: release\n", - }, - ) - upgrade_from_archive(tmp_path, target, sha256_file(target), "0.2.0", "fixture") - assert (tmp_path / ".flywheel/manifest.yaml").read_text( - encoding="utf-8" - ) == "schema_version: 2\n" - assert (tmp_path / ".flywheel/state.yaml").read_text(encoding="utf-8") == "value: local\n" + assert not (tmp_path / ".flywheel/.runtime/operation.lock").exists() diff --git a/tools/test-install-launcher.ps1 b/tools/test-install-launcher.ps1 new file mode 100644 index 0000000..54b8e73 --- /dev/null +++ b/tools/test-install-launcher.ps1 @@ -0,0 +1,58 @@ +#Requires -Version 5.1 + +<# +.SYNOPSIS +Validates the public AI Flywheel PowerShell install launcher. + +.DESCRIPTION +Verifies that install.ps1 is safe for the `irm ... | iex` delivery pattern. The +test confirms the launcher has no top-level parameter block, runs in an isolated +child scope, pins an immutable canonical installer commit, delegates to the +canonical installer script, and does not embed Flywheel installation logic. +#> + +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path +$launcherPath = Join-Path $repositoryRoot 'install.ps1' +$launcherText = Get-Content -LiteralPath $launcherPath -Raw -ErrorAction Stop + +$tokens = $null +$parseErrors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile( + $launcherPath, + [ref]$tokens, + [ref]$parseErrors +) + +if ($parseErrors.Count -gt 0) { + throw "Public install launcher contains $($parseErrors.Count) parse error(s)." +} + +if ($null -ne $ast.ParamBlock) { + throw 'Public install launcher must not have a top-level parameter block when delivered through Invoke-Expression.' +} + +if (-not $launcherText.Contains('& {')) { + throw 'Public install launcher must isolate execution in a child script scope.' +} + +if ($launcherText -notmatch "installerCommit\s*=\s*'[0-9a-f]{40}'") { + throw 'Public install launcher must pin an immutable canonical installer commit.' +} + +if (-not $launcherText.Contains('/scripts/install-ai-flywheel.ps1')) { + throw 'Public install launcher must delegate to the canonical installer script.' +} + +foreach ($forbidden in @('Invoke-AIFlywheelBootstrap', 'start-execution', 'FrameworkRef', 'application_missions_allowed')) { + if ($launcherText.Contains($forbidden)) { + throw "Public install launcher contains canonical installer logic: $forbidden" + } +} + +Write-Output 'Public install launcher tests passed.' diff --git a/tools/test-windows-bootstrap.ps1 b/tools/test-windows-bootstrap.ps1 new file mode 100644 index 0000000..a830990 --- /dev/null +++ b/tools/test-windows-bootstrap.ps1 @@ -0,0 +1,174 @@ +#Requires -Version 5.1 + +<# +.SYNOPSIS +Runs dependency-free regression tests for the AI Flywheel Windows bootstrap. + +.DESCRIPTION +Verifies CLI-source extraction and the boundary between framework installation and +Python CLI setup. Framework compatibility classification is exercised without +network access or repository mutation. +#> + +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path +$bootstrapPath = Join-Path $repositoryRoot 'scripts\install-ai-flywheel.ps1' + +function Assert-BootstrapTest { + [CmdletBinding()] + param( + [Parameter(Mandatory)][bool]$Condition, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Message + ) + if (-not $Condition) { throw $Message } +} + +function New-BootstrapTestZip { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Path, + [Parameter(Mandatory)][hashtable]$Entries + ) + if (-not $PSCmdlet.ShouldProcess($Path, 'Create bootstrap regression-test ZIP archive')) { return } + + Add-Type -AssemblyName System.IO.Compression -ErrorAction Stop + Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop + $stream = [System.IO.File]::Open($Path, [System.IO.FileMode]::Create, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None) + try { + $zip = [System.IO.Compression.ZipArchive]::new($stream, [System.IO.Compression.ZipArchiveMode]::Create, $true) + try { + foreach ($name in @($Entries.Keys | Sort-Object)) { + $entry = $zip.CreateEntry($name) + $writer = [System.IO.StreamWriter]::new($entry.Open()) + try { $writer.Write([string]$Entries[$name]) } finally { $writer.Dispose() } + } + } + finally { $zip.Dispose() } + } + finally { $stream.Dispose() } +} + +function Set-TestFramework { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Root, + [Parameter(Mandatory)][string]$InstallationVersion, + [Parameter(Mandatory)][string]$ManifestVersion + ) + $flywheel = Join-Path $Root '.flywheel' + New-Item -ItemType Directory -Path $flywheel -Force | Out-Null + Set-Content -LiteralPath (Join-Path $flywheel 'installation.yaml') -Value "framework_version: $InstallationVersion" -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $flywheel 'manifest.yaml') -Value @( + 'schema_version: 1' + 'framework:' + " version: $ManifestVersion" + ) -Encoding UTF8 +} + +. $bootstrapPath + +$testRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('AIFW-tests-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) +New-Item -ItemType Directory -Path $testRoot -Force | Out-Null + +try { + $sourceText = Get-Content -LiteralPath $bootstrapPath -Raw + $parameterNames = @((Get-Command -Name $bootstrapPath).Parameters.Keys) + foreach ($removedParameter in @('FrameworkVersion', 'FrameworkRef', 'FrameworkPath')) { + Assert-BootstrapTest -Condition ($parameterNames -notcontains $removedParameter) -Message "Removed parameter remains exposed: $removedParameter" + } + + Assert-BootstrapTest -Condition ($sourceText.Contains('fe11b801b5dfeef812377a978558fd563b67fa9e')) -Message 'Official framework installer commit is not pinned.' + Assert-BootstrapTest -Condition ($sourceText.Contains('/scripts/install-framework.ps1')) -Message 'Bootstrap does not invoke the official framework installer.' + $frameworkStageIndex = $sourceText.IndexOf('$script:CurrentStage = ''Framework''') + $pythonStageIndex = $sourceText.IndexOf('$script:CurrentStage = ''Python''') + Assert-BootstrapTest -Condition ($frameworkStageIndex -ge 0 -and $frameworkStageIndex -lt $pythonStageIndex) -Message 'Framework assurance must occur before Python detection.' + foreach ($forbidden in @('Get-FlywheelFrameworkPackage', 'New-BootstrapDirectoryArchive', 'Get-BootstrapSha256', 'Test-FlywheelInstallationProvenance')) { + Assert-BootstrapTest -Condition (-not $sourceText.Contains($forbidden)) -Message "Framework-owned installation logic remains: $forbidden" + } + Assert-BootstrapTest -Condition (-not $sourceText.Contains("'start-execution'")) -Message 'Bootstrap must not invoke lifecycle operations.' + + $absentRoot = Join-Path $testRoot 'absent' + New-Item -ItemType Directory -Path $absentRoot | Out-Null + Assert-BootstrapTest -Condition ((Get-FlywheelFrameworkCompatibility -Root $absentRoot).Status -eq 'not-installed') -Message 'Absent framework classification failed.' + + $compatibleRoot = Join-Path $testRoot 'compatible' + Set-TestFramework -Root $compatibleRoot -InstallationVersion '2026.08.08' -ManifestVersion '2026.08.08' + $before = (Get-FileHash -LiteralPath (Join-Path $compatibleRoot '.flywheel\manifest.yaml') -Algorithm SHA256).Hash + Assert-BootstrapTest -Condition ((Get-FlywheelFrameworkCompatibility -Root $compatibleRoot).Status -eq 'compatible') -Message 'Compatible framework classification failed.' + $after = (Get-FileHash -LiteralPath (Join-Path $compatibleRoot '.flywheel\manifest.yaml') -Algorithm SHA256).Hash + Assert-BootstrapTest -Condition ($before -eq $after) -Message 'Compatibility detection modified the framework.' + + $olderRoot = Join-Path $testRoot 'older' + Set-TestFramework -Root $olderRoot -InstallationVersion '2026.08.07' -ManifestVersion '2026.08.07' + Assert-BootstrapTest -Condition ((Get-FlywheelFrameworkCompatibility -Root $olderRoot).Status -eq 'older-unsupported') -Message 'Older framework classification failed.' + + $newerRoot = Join-Path $testRoot 'newer' + Set-TestFramework -Root $newerRoot -InstallationVersion '2026.08.09' -ManifestVersion '2026.08.09' + Assert-BootstrapTest -Condition ((Get-FlywheelFrameworkCompatibility -Root $newerRoot).Status -eq 'newer-unsupported') -Message 'Newer framework classification failed.' + + $untrackedRoot = Join-Path $testRoot 'untracked' + New-Item -ItemType Directory -Path (Join-Path $untrackedRoot '.flywheel') -Force | Out-Null + Assert-BootstrapTest -Condition ((Get-FlywheelFrameworkCompatibility -Root $untrackedRoot).Status -eq 'untracked-or-legacy') -Message 'Untracked framework classification failed.' + + $disagreeRoot = Join-Path $testRoot 'disagree' + Set-TestFramework -Root $disagreeRoot -InstallationVersion '2026.08.08' -ManifestVersion '2026.08.09' + Assert-BootstrapTest -Condition ((Get-FlywheelFrameworkCompatibility -Root $disagreeRoot).Status -eq 'invalid') -Message 'Version disagreement classification failed.' + + $malformedRoot = Join-Path $testRoot 'malformed' + Set-TestFramework -Root $malformedRoot -InstallationVersion 'not-calver' -ManifestVersion 'not-calver' + Assert-BootstrapTest -Condition ((Get-FlywheelFrameworkCompatibility -Root $malformedRoot).Status -eq 'malformed') -Message 'Malformed framework classification failed.' + + $script:CapturedInstallerUri = $null + function Invoke-BootstrapWebRequest { + [CmdletBinding()] + param([Parameter(Mandatory)][uri]$Uri, [Parameter(Mandatory)][string]$OutFile) + $script:CapturedInstallerUri = $Uri.AbsoluteUri + Set-Content -LiteralPath $OutFile -Encoding UTF8 -Value @' +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [Parameter(Mandatory)][string]$Repository, + [Parameter()][switch]$NonInteractive, + [Parameter()][switch]$Apply +) +$record = Join-Path $Repository 'framework-installer-invocation.txt' +Set-Content -LiteralPath $record -Encoding UTF8 -Value ("{0}|{1}|{2}" -f $Repository, [bool]$NonInteractive, [bool]$Apply) +'@ + } + $installerRoot = Join-Path $testRoot 'official-installer' + New-Item -ItemType Directory -Path $installerRoot | Out-Null + $NonInteractive = $true + Invoke-OfficialFrameworkInstaller -Root $installerRoot -TemporaryRoot $testRoot + $NonInteractive = $false + $expectedInstallerUri = 'https://raw.githubusercontent.com/Infoconex/ai-flywheel-framework/fe11b801b5dfeef812377a978558fd563b67fa9e/scripts/install-framework.ps1' + Assert-BootstrapTest -Condition ($script:CapturedInstallerUri -eq $expectedInstallerUri) -Message 'Official framework installer URI is incorrect.' + $invocation = Get-Content -LiteralPath (Join-Path $installerRoot 'framework-installer-invocation.txt') -Raw + Assert-BootstrapTest -Condition ($invocation.Trim() -eq "$installerRoot|True|True") -Message 'Repository or non-interactive authority was not passed to the official installer.' + + Assert-BootstrapTest -Condition (Test-CliArchiveEntryExcluded -EntryName 'repo/.flywheel/state.yaml') -Message 'CLI .flywheel exclusion failed.' + Assert-BootstrapTest -Condition (-not (Test-CliArchiveEntryExcluded -EntryName 'repo/src/ai_flywheel_cli/cli.py')) -Message 'CLI source was incorrectly excluded.' + + $cliArchive = Join-Path $testRoot 'cli.zip' + $deepSegment = 'deep-' + ('x' * 120) + $entries = @{ + 'repo/.flywheel/operations/records/should-not-extract.yaml' = 'excluded' + 'repo/tests/should-not-extract.py' = 'excluded' + 'repo/src/ai_flywheel_cli/cli.py' = 'included' + } + $entries["repo/src/$deepSegment/$deepSegment/value.txt"] = 'deep-path' + New-BootstrapTestZip -Path $cliArchive -Entries $entries -Confirm:$false + $cliRoot = Expand-BootstrapArchive -Archive $cliArchive -Destination (Join-Path $testRoot 'cli') -Confirm:$false + Assert-BootstrapTest -Condition (Test-Path -LiteralPath (Join-Path $cliRoot 'src\ai_flywheel_cli\cli.py')) -Message 'CLI source extraction failed.' + Assert-BootstrapTest -Condition (-not (Test-Path -LiteralPath (Join-Path $cliRoot '.flywheel'))) -Message 'Excluded .flywheel content was extracted.' + $deepPath = Join-Path $cliRoot "src\$deepSegment\$deepSegment\value.txt" + Assert-BootstrapTest -Condition ([System.IO.File]::Exists((ConvertTo-BootstrapExtendedPath -Path $deepPath))) -Message 'Deep-path extraction failed.' + + Write-Output 'Windows bootstrap regression tests passed.' +} +finally { + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/tools/validate-powershell.ps1 b/tools/validate-powershell.ps1 new file mode 100644 index 0000000..e64ff2f --- /dev/null +++ b/tools/validate-powershell.ps1 @@ -0,0 +1,96 @@ +#Requires -Version 5.1 + +<# +.SYNOPSIS +Validates the AI Flywheel Windows bootstrap for parse errors and PowerShell best-practice violations. + +.DESCRIPTION +Runs the built-in PowerShell parser against the Windows bootstrap, then runs +PSScriptAnalyzer with the repository-owned settings, and finally verifies that +comment-based help is discoverable. The command fails when parsing fails, +PSScriptAnalyzer is unavailable, analyzer Error/Warning diagnostics are returned, +or comment-based help is missing. + +When no paths are provided, the validator resolves the bootstrap script and analyzer +settings relative to the repository checkout. Explicit paths are supported for +validating downloaded artifacts outside a repository checkout. + +.PARAMETER ScriptPath +Path to the PowerShell bootstrap script to validate. Defaults to +scripts\install-ai-flywheel.ps1 relative to the repository root. + +.PARAMETER SettingsPath +Path to the PSScriptAnalyzer settings file. Defaults to +PSScriptAnalyzerSettings.psd1 relative to the repository root. + +.EXAMPLE +.\tools\validate-powershell.ps1 + +Validates the bootstrap script from a repository checkout. + +.EXAMPLE +.\validate-powershell.ps1 -ScriptPath .\install-ai-flywheel.ps1 -SettingsPath .\PSScriptAnalyzerSettings.psd1 + +Validates downloaded bootstrap artifacts from an arbitrary directory. +#> + +[CmdletBinding()] +param( + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$ScriptPath, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$SettingsPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = (Resolve-Path -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath '..')).Path +if ([string]::IsNullOrWhiteSpace($ScriptPath)) { + $ScriptPath = Join-Path -Path $repositoryRoot -ChildPath 'scripts\install-ai-flywheel.ps1' +} +if ([string]::IsNullOrWhiteSpace($SettingsPath)) { + $SettingsPath = Join-Path -Path $repositoryRoot -ChildPath 'PSScriptAnalyzerSettings.psd1' +} + +$resolvedScriptPath = (Resolve-Path -LiteralPath $ScriptPath).Path +$resolvedSettingsPath = (Resolve-Path -LiteralPath $SettingsPath).Path + +$tokens = $null +$parseErrors = $null +[System.Management.Automation.Language.Parser]::ParseFile( + $resolvedScriptPath, + [ref]$tokens, + [ref]$parseErrors +) | Out-Null + +if ($parseErrors.Count -gt 0) { + $parseErrors | Format-Table -AutoSize + throw "PowerShell parser reported $($parseErrors.Count) error(s)." +} + +$analyzer = Get-Module -ListAvailable -Name PSScriptAnalyzer | + Sort-Object -Property Version -Descending | + Select-Object -First 1 +if (-not $analyzer) { + throw 'PSScriptAnalyzer is required. Install with: Install-Module PSScriptAnalyzer -Scope CurrentUser' +} + +Import-Module -Name $analyzer.Path -Force +$diagnostics = @( + Invoke-ScriptAnalyzer -Path $resolvedScriptPath -Settings $resolvedSettingsPath +) +if ($diagnostics.Count -gt 0) { + $diagnostics | Format-Table -Property RuleName, Severity, Line, Message -AutoSize -Wrap + throw "PSScriptAnalyzer reported $($diagnostics.Count) error/warning diagnostic(s)." +} + +$help = Get-Help -Name $resolvedScriptPath -Full +if ([string]::IsNullOrWhiteSpace($help.Synopsis) -or $help.Synopsis -eq $resolvedScriptPath) { + throw 'Bootstrap comment-based help is missing or invalid.' +} + +Write-Output 'PowerShell validation passed.' From f87e458564c0de85f780c584d6ad7a22780c10a6 Mon Sep 17 00:00:00 2001 From: Jim Scott Date: Sat, 8 Aug 2026 21:02:49 -0700 Subject: [PATCH 2/5] Pin compatible Python CLI source --- docs/windows-bootstrap.md | 1 + scripts/install-ai-flywheel.ps1 | 2 +- tools/test-windows-bootstrap.ps1 | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/windows-bootstrap.md b/docs/windows-bootstrap.md index 5cfccc4..d9e2f84 100644 --- a/docs/windows-bootstrap.md +++ b/docs/windows-bootstrap.md @@ -35,6 +35,7 @@ Release tag: v2026.08.08 Package: ai-flywheel-framework-2026.08.08.zip Checksum asset: ai-flywheel-framework-2026.08.08.zip.sha256 Installer commit: fe11b801b5dfeef812377a978558fd563b67fa9e +Default CLI source commit: 2d84294cbe9922ec907fe718e9dd06e9944e0ebc ``` ## Invocation diff --git a/scripts/install-ai-flywheel.ps1 b/scripts/install-ai-flywheel.ps1 index 48fff95..aed0e3d 100644 --- a/scripts/install-ai-flywheel.ps1 +++ b/scripts/install-ai-flywheel.ps1 @@ -58,7 +58,7 @@ param( [Parameter()] [ValidateNotNullOrEmpty()] - [string]$CliRef = 'e766886acf35b145023292b954ff097b63e95b29', + [string]$CliRef = '2d84294cbe9922ec907fe718e9dd06e9944e0ebc', [Parameter()] [ValidateNotNullOrEmpty()] diff --git a/tools/test-windows-bootstrap.ps1 b/tools/test-windows-bootstrap.ps1 index a830990..597ddd9 100644 --- a/tools/test-windows-bootstrap.ps1 +++ b/tools/test-windows-bootstrap.ps1 @@ -83,6 +83,7 @@ try { } Assert-BootstrapTest -Condition ($sourceText.Contains('fe11b801b5dfeef812377a978558fd563b67fa9e')) -Message 'Official framework installer commit is not pinned.' + Assert-BootstrapTest -Condition ($sourceText.Contains("`$CliRef = '2d84294cbe9922ec907fe718e9dd06e9944e0ebc'")) -Message 'Default CLI source is not pinned to the compatible implementation.' Assert-BootstrapTest -Condition ($sourceText.Contains('/scripts/install-framework.ps1')) -Message 'Bootstrap does not invoke the official framework installer.' $frameworkStageIndex = $sourceText.IndexOf('$script:CurrentStage = ''Framework''') $pythonStageIndex = $sourceText.IndexOf('$script:CurrentStage = ''Python''') From 1bf1262ea3730f7a817223215918407cb283fa78 Mon Sep 17 00:00:00 2001 From: Jim Scott Date: Sat, 8 Aug 2026 21:03:06 -0700 Subject: [PATCH 3/5] Pin public Python bootstrap launcher --- docs/windows-bootstrap.md | 1 + install.ps1 | 2 +- tools/test-install-launcher.ps1 | 4 ++++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/windows-bootstrap.md b/docs/windows-bootstrap.md index d9e2f84..cc19465 100644 --- a/docs/windows-bootstrap.md +++ b/docs/windows-bootstrap.md @@ -36,6 +36,7 @@ Package: ai-flywheel-framework-2026.08.08.zip Checksum asset: ai-flywheel-framework-2026.08.08.zip.sha256 Installer commit: fe11b801b5dfeef812377a978558fd563b67fa9e Default CLI source commit: 2d84294cbe9922ec907fe718e9dd06e9944e0ebc +Public launcher bootstrap commit: f87e458564c0de85f780c584d6ad7a22780c10a6 ``` ## Invocation diff --git a/install.ps1 b/install.ps1 index e2a413f..4d56262 100644 --- a/install.ps1 +++ b/install.ps1 @@ -20,7 +20,7 @@ the Python CLI and performs compatibility and health checks. $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' - $installerCommit = 'a8cbeb6796ea0725cb179de4d289bb78d9707d5f' + $installerCommit = 'f87e458564c0de85f780c584d6ad7a22780c10a6' $installerUri = "https://raw.githubusercontent.com/Infoconex/ai-flywheel-cli-python/$installerCommit/scripts/install-ai-flywheel.ps1" $installerPath = Join-Path ([System.IO.Path]::GetTempPath()) ('ai-flywheel-installer-{0}.ps1' -f [guid]::NewGuid().ToString('N').Substring(0, 8)) diff --git a/tools/test-install-launcher.ps1 b/tools/test-install-launcher.ps1 index 54b8e73..cd2354c 100644 --- a/tools/test-install-launcher.ps1 +++ b/tools/test-install-launcher.ps1 @@ -45,6 +45,10 @@ if ($launcherText -notmatch "installerCommit\s*=\s*'[0-9a-f]{40}'") { throw 'Public install launcher must pin an immutable canonical installer commit.' } +if (-not $launcherText.Contains("installerCommit = 'f87e458564c0de85f780c584d6ad7a22780c10a6'")) { + throw 'Public install launcher does not pin the reviewed framework-delegating bootstrap.' +} + if (-not $launcherText.Contains('/scripts/install-ai-flywheel.ps1')) { throw 'Public install launcher must delegate to the canonical installer script.' } From 4f787819df1617d0a6b8435196e9c978cfbf1457 Mon Sep 17 00:00:00 2001 From: Jim Scott Date: Sun, 9 Aug 2026 07:30:56 -0700 Subject: [PATCH 4/5] Add selectable CLI installation modes --- README.md | 26 +++- docs/windows-bootstrap.md | 81 +++++++++-- scripts/install-ai-flywheel.ps1 | 243 ++++++++++++++++++++++++++++--- tools/test-windows-bootstrap.ps1 | 78 +++++++++- 4 files changed, 384 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 6971163..b8f6795 100644 --- a/README.md +++ b/README.md @@ -131,8 +131,8 @@ These commands enforce schema validation, active-stage boundaries, reference int The Python CLI does not install or upgrade `.flywheel`. Framework installation is owned by the published AI Flywheel Framework installer. On Windows, use the -repository bootstrap to ensure framework `2026.08.08` is present before preparing -the managed Python CLI: +repository bootstrap to ensure framework `2026.08.08` is present and choose either +repository-owned editable source or a managed Python CLI: ```powershell .\scripts\install-ai-flywheel.ps1 @@ -140,7 +140,27 @@ the managed Python CLI: The bootstrap invokes the official framework installer when `.flywheel` is absent, leaves a compatible installation intact, and stops without overwriting older, -newer, malformed, legacy, or untracked installations. +newer, malformed, legacy, or untracked installations. Interactive setup prompts: + +```text +1. Repository-owned source (recommended) + Seeds editable source, tests, and project tasks under .flywheel/tools. + +2. Managed CLI + Installs the current versioned CLI outside the repository. +``` + +For automation, select the mode explicitly: + +```powershell +.\scripts\install-ai-flywheel.ps1 -NonInteractive -Apply -CliInstallMode Source +.\scripts\install-ai-flywheel.ps1 -NonInteractive -Apply -CliInstallMode Managed +``` + +Source mode creates an editable runtime under `.flywheel/.runtime/python-cli` and +preserves `.flywheel/tools` on later runs so repository-governed adaptations are +never overwritten. Managed mode retains the existing isolated environment under +`%LOCALAPPDATA%\AI-Flywheel`. A hybrid extension system is not part of this change. ## Exit code contract diff --git a/docs/windows-bootstrap.md b/docs/windows-bootstrap.md index cc19465..1af292b 100644 --- a/docs/windows-bootstrap.md +++ b/docs/windows-bootstrap.md @@ -3,8 +3,8 @@ ## Purpose The Windows bootstrap ensures a compatible published AI Flywheel framework is -present, configures an isolated Python CLI, and verifies runtime health. It never -starts onboarding or lifecycle work. +present, asks the user to choose a Python CLI installation model, and verifies the +selected runtime. It never starts onboarding or lifecycle work. Dependency direction is: @@ -22,8 +22,9 @@ Python runtime implementation verification, archive safety, provenance, staging, atomic `.flywheel` publication, rollback, and refusal to overwrite. - **Windows Python bootstrap** detects framework compatibility, invokes the official - installer only when the framework is absent, prepares Python and the managed CLI, - and runs health checks. + installer only when the framework is absent, asks whether the CLI should be + repository-owned source or managed, prepares the selected runtime, and runs + health checks. - **Python CLI** validates and operates an installed framework. It does not install, extract, checksum, publish, or upgrade framework artifacts. @@ -50,8 +51,12 @@ Supported inputs: - `-Repository `: target Git repository or a path inside it. - `-CliRef `: CLI source ref; the normal default is immutable. - `-CliPath `: local CLI source/package for development testing. +- `-CliInstallMode Source|Managed`: makes the installation choice explicitly. + Interactive runs prompt when this parameter is omitted. Non-interactive runs + require it. - `-NonInteractive`: disables prompts. -- `-Apply`: required with `-NonInteractive` when framework installation is needed. +- `-Apply`: required with `-NonInteractive` when framework installation or initial + repository-owned source installation is needed. - `-ValidateOnly`: checks an existing installation without installing one. - Common `-WhatIf` and `-Confirm` semantics are passed to the official installer. @@ -68,12 +73,52 @@ select a local framework, development ref, archive, checksum, or source identity Python setup. 5. Reject older, newer, malformed, inconsistent, legacy, or untracked frameworks without overwriting them. -6. Detect Python 3.11+ and offer explicit `winget` remediation when appropriate. -7. Create or reuse a managed CLI environment under - `%LOCALAPPDATA%\AI-Flywheel\environments`. -8. Run `flywheel doctor`, which verifies CLI version, framework identity, +6. Ask the user to choose repository-owned source or a managed CLI. +7. Detect Python 3.11+ and offer explicit `winget` remediation when appropriate. +8. Install or reuse the selected CLI model. +9. Run `flywheel doctor`, which verifies CLI version, framework identity, compatibility, and repository validation. -9. Stop without invoking onboarding or lifecycle commands. +10. Stop without invoking onboarding or lifecycle commands. + +## CLI installation modes + +### Repository-owned source (recommended) + +This mode seeds an editable Python project into `.flywheel/tools`: + +```text +.flywheel/ +├── tools/ +│ ├── cli-source.yaml +│ ├── pyproject.toml +│ ├── README.md +│ ├── src/ +│ ├── tests/ +│ └── tools/ +└── .runtime/ + └── python-cli/ +``` + +The seed includes the CLI package source, tests, project quality-gate tasks, +project metadata, and README. It intentionally excludes the CLI repository's own +`.flywheel` records, Git metadata, release-proof files, installer scripts, and +distribution-only documentation. + +The runtime under `.flywheel/.runtime/python-cli` installs `.flywheel/tools` in +editable mode with development dependencies. AI may therefore adapt the source +and tests through the repository's governed lifecycle without rebuilding or +activating the virtual environment. Re-running setup preserves existing source; +the installer never replaces an existing `.flywheel/tools` directory. + +This milestone does not make the managed CLI discover repository extensions and +does not implement a hybrid CLI. + +### Managed CLI + +This mode retains the existing PR #9 behavior. It creates or reuses a versioned +environment under `%LOCALAPPDATA%\AI-Flywheel\environments`. The CLI source is not +copied into the application repository. This milestone does not add a stable PATH +launcher; the completion output reports the exact CLI executable path. ## Framework compatibility @@ -134,7 +179,7 @@ locations. ## Storage and safety -Managed Python assets remain outside the target repository: +Managed-mode Python assets remain outside the target repository: ```text %LOCALAPPDATA%\AI-Flywheel\ @@ -147,6 +192,9 @@ Temporary CLI-source extraction occurs under `%TEMP%\AIFW\` and is removed after the run. The bootstrap never commits, pushes, merges, changes application source, enables application missions, or begins lifecycle execution. +Source mode additionally writes the governed seed to `.flywheel/tools` and keeps +its generated virtual environment under `.flywheel/.runtime`. + ## Validation The full gate includes: @@ -157,8 +205,9 @@ The full gate includes: .\tools\test-windows-bootstrap.ps1 ``` -Regression coverage verifies compatibility classifications, immutable official -installer identity, framework-before-Python ordering, preservation of compatible -framework files, absence of Python-owned framework installation logic, CLI source -archive safety, and removal of lifecycle invocation from bootstrap. Python tests -cover the same compatibility policy and deterministic `doctor` output. +Regression coverage verifies compatibility classifications, explicit CLI mode +selection, immutable official installer identity, framework-before-Python ordering, +preservation of compatible framework files, repository-owned source seeding and +repeat-run preservation, absence of Python-owned framework installation logic, CLI +source archive safety, and removal of lifecycle invocation from bootstrap. Python +tests cover the same compatibility policy and deterministic `doctor` output. diff --git a/scripts/install-ai-flywheel.ps1 b/scripts/install-ai-flywheel.ps1 index aed0e3d..73018b7 100644 --- a/scripts/install-ai-flywheel.ps1 +++ b/scripts/install-ai-flywheel.ps1 @@ -5,10 +5,12 @@ Prepares a Windows Git repository for AI Flywheel onboarding. .DESCRIPTION -Ensures the official AI Flywheel framework is installed, then installs and -validates the Python CLI without starting onboarding. The bootstrap delegates all -framework acquisition, verification, provenance, archive safety, and repository -mutation to the published framework installer. +Ensures the official AI Flywheel framework is installed, then asks whether the +Python CLI should be installed as repository-owned editable source or as a managed +CLI outside the repository. The bootstrap validates the selected installation +without starting onboarding. It delegates all framework acquisition, verification, +provenance, archive safety, and framework publication to the published framework +installer. The bootstrap never commits, pushes, merges, enables application missions, or starts an onboarding execution. @@ -23,11 +25,16 @@ CLI Git branch, tag, or commit. Defaults to an approved immutable CLI commit. .PARAMETER CliPath Local CLI source directory, wheel, or sdist for development/testing. +.PARAMETER CliInstallMode +Selects Source or Managed CLI installation. Interactive runs prompt when omitted. +Non-interactive runs require an explicit selection. + .PARAMETER NonInteractive Disables prompts. Repository mutation additionally requires -Apply. .PARAMETER Apply -Explicitly authorizes repository mutation in non-interactive mode. +Explicitly authorizes framework or repository-owned source mutation in +non-interactive mode. .PARAMETER ValidateOnly Validates prerequisites and an existing installation without installing. @@ -64,6 +71,10 @@ param( [ValidateNotNullOrEmpty()] [string]$CliPath, + [Parameter()] + [ValidateSet('Source', 'Managed')] + [string]$CliInstallMode, + [Parameter()] [switch]$NonInteractive, @@ -111,6 +122,8 @@ $script:BootstrapContext = [ordered]@{ CliVersion = $null CliExecutable = $null CliResolvedCommit = $null + CliInstallMode = $null + CliSourcePath = $null FrameworkVersion = $null FrameworkCompatibility = $null FrameworkInstallerCommit = $script:FrameworkInstallerCommit @@ -269,6 +282,31 @@ function Confirm-BootstrapAction { return $answer.Trim().StartsWith('y', [System.StringComparison]::OrdinalIgnoreCase) } +function Resolve-CliInstallMode { + [CmdletBinding()] + param() + + if (-not [string]::IsNullOrWhiteSpace($CliInstallMode)) { return $CliInstallMode } + if ($NonInteractive) { + Invoke-BootstrapFailure -Message 'Non-interactive setup requires an explicit CLI installation mode.' -Code $script:ExitCode.Cancelled -Remediation 'Specify -CliInstallMode Source or -CliInstallMode Managed.' + } + + Write-Host 'How should the AI Flywheel CLI be installed?' + Write-Host '' + Write-Host ' 1. Repository-owned source (recommended)' + Write-Host ' Seed editable Python source in .flywheel/tools so this repository can evolve it.' + Write-Host '' + Write-Host ' 2. Managed CLI' + Write-Host ' Install a shared, versioned CLI outside the repository.' + Write-Host '' + while ($true) { + $selection = Read-Host 'Selection [1]' + if ([string]::IsNullOrWhiteSpace($selection) -or $selection.Trim() -eq '1') { return 'Source' } + if ($selection.Trim() -eq '2') { return 'Managed' } + Write-BootstrapWarning -Message 'Enter 1 for repository-owned source or 2 for managed CLI.' + } +} + function New-BootstrapDirectory { [CmdletBinding(SupportsShouldProcess = $true)] param([Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Path) @@ -525,19 +563,24 @@ function ConvertTo-BootstrapExtendedPath { function Test-CliArchiveEntryExcluded { [CmdletBinding()] - param([Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$EntryName) + param( + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$EntryName, + [switch]$IncludeDevelopmentFiles + ) $parts = @($EntryName.Replace('\', '/').Split('/') | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) if ($parts.Count -lt 2) { return $false } $rootChild = $parts[1] - return $rootChild -in @('.flywheel', '.gitignore', '.release-proof', 'tests', 'tools') + if ($rootChild -in @('.flywheel', '.git', '.gitignore', '.release-proof')) { return $true } + return (-not $IncludeDevelopmentFiles) -and $rootChild -in @('tests', 'tools') } function Expand-BootstrapArchive { [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Archive, - [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Destination + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Destination, + [switch]$IncludeDevelopmentFiles ) Add-Type -AssemblyName System.IO.Compression -ErrorAction Stop @@ -559,7 +602,7 @@ function Expand-BootstrapArchive { try { foreach ($entry in $zip.Entries) { if ([string]::IsNullOrWhiteSpace($entry.FullName)) { continue } - if ($isCliExtraction -and (Test-CliArchiveEntryExcluded -EntryName $entry.FullName)) { continue } + if ($isCliExtraction -and (Test-CliArchiveEntryExcluded -EntryName $entry.FullName -IncludeDevelopmentFiles:$IncludeDevelopmentFiles)) { continue } $relativeName = $entry.FullName.Replace('/', [System.IO.Path]::DirectorySeparatorChar) $targetPath = [System.IO.Path]::GetFullPath((Join-Path $destinationRoot $relativeName)) @@ -600,6 +643,36 @@ function Expand-BootstrapArchive { return $roots[0].FullName } +function Get-FlywheelCliSource { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$FlywheelHome, + [Parameter(Mandatory)][string]$TemporaryRoot, + [switch]$IncludeDevelopmentFiles + ) + + if ($script:InvocationBoundParameters.ContainsKey('CliPath')) { + $sourcePath = (Resolve-Path -LiteralPath $CliPath -ErrorAction Stop).Path + return [pscustomobject]@{ + Path = $sourcePath + Identity = "local:$sourcePath" + ResolvedCommit = $null + } + } + + $resolvedCommit = Resolve-GitHubCommit -RepositoryName $script:CliRepository -Ref $CliRef + $cacheDirectory = Join-Path $FlywheelHome 'cache\cli' + New-BootstrapDirectory -Path $cacheDirectory -Confirm:$false + $archive = Join-Path $cacheDirectory ("$resolvedCommit.zip") + Save-GitHubArchive -RepositoryName $script:CliRepository -CommitSha $resolvedCommit -Destination $archive + $sourcePath = Expand-BootstrapArchive -Archive $archive -Destination (Join-Path $TemporaryRoot 'cli') -IncludeDevelopmentFiles:$IncludeDevelopmentFiles -Confirm:$false + return [pscustomobject]@{ + Path = $sourcePath + Identity = "github-ref:$script:CliRepository@$resolvedCommit" + ResolvedCommit = $resolvedCommit + } +} + function Initialize-FlywheelCliEnvironment { [CmdletBinding()] param( @@ -607,21 +680,12 @@ function Initialize-FlywheelCliEnvironment { [Parameter(Mandatory)][string]$FlywheelHome, [Parameter(Mandatory)][string]$TemporaryRoot ) - $resolvedCommit = $null + $source = Get-FlywheelCliSource -FlywheelHome $FlywheelHome -TemporaryRoot $TemporaryRoot if ($script:InvocationBoundParameters.ContainsKey('CliPath')) { - $sourcePath = (Resolve-Path -LiteralPath $CliPath -ErrorAction Stop).Path - $sourceIdentity = "local:$sourcePath" $environmentName = 'cli-local' } else { - $resolvedCommit = Resolve-GitHubCommit -RepositoryName $script:CliRepository -Ref $CliRef - $cacheDirectory = Join-Path $FlywheelHome 'cache\cli' - New-BootstrapDirectory -Path $cacheDirectory -Confirm:$false - $archive = Join-Path $cacheDirectory ("$resolvedCommit.zip") - Save-GitHubArchive -RepositoryName $script:CliRepository -CommitSha $resolvedCommit -Destination $archive - $sourcePath = Expand-BootstrapArchive -Archive $archive -Destination (Join-Path $TemporaryRoot 'cli') -Confirm:$false - $sourceIdentity = "github-ref:$script:CliRepository@$resolvedCommit" - $environmentName = "cli-$($resolvedCommit.Substring(0, 12))" + $environmentName = "cli-$($source.ResolvedCommit.Substring(0, 12))" } $environment = Join-Path $FlywheelHome ("environments\$environmentName") @@ -632,6 +696,9 @@ function Initialize-FlywheelCliEnvironment { $healthy = (Invoke-BootstrapNativeCommand -FilePath $flywheel -ArgumentList @('--version') -AllowFailure).ExitCode -eq 0 } if (-not $healthy) { + if ($ValidateOnly) { + Invoke-BootstrapFailure -Message 'The managed CLI environment is not healthy.' -Code $script:ExitCode.Validation -Remediation 'Run setup without -ValidateOnly and select Managed to rebuild the environment.' + } if (Test-Path -LiteralPath $environment) { Write-BootstrapWarning -Message 'Existing managed CLI environment is unhealthy.' if ($NonInteractive -or (Confirm-BootstrapAction -Prompt 'Rebuild the Flywheel-owned CLI environment?' -DefaultYes $true)) { Remove-Item -LiteralPath $environment -Recurse -Force -ErrorAction Stop } @@ -640,7 +707,111 @@ function Initialize-FlywheelCliEnvironment { New-BootstrapDirectory -Path (Split-Path -Parent $environment) -Confirm:$false Write-Host 'Preparing managed AI Flywheel CLI environment...' -ForegroundColor DarkGray Invoke-BootstrapPython -Python $Python -ArgumentList @('-m', 'venv', $environment) | Out-Null - Invoke-BootstrapNativeCommand -FilePath $venvPython -ArgumentList @('-m', 'pip', 'install', '--disable-pip-version-check', $sourcePath) | Out-Null + Invoke-BootstrapNativeCommand -FilePath $venvPython -ArgumentList @('-m', 'pip', 'install', '--disable-pip-version-check', $source.Path) | Out-Null + } + $versionResult = Invoke-BootstrapNativeCommand -FilePath $flywheel -ArgumentList @('--version') + return [pscustomobject]@{ + Executable = $flywheel + Python = $venvPython + Version = (($versionResult.Output | Select-Object -Last 1).ToString().Trim()) + Environment = $environment + SourceIdentity = $source.Identity + ResolvedCommit = $source.ResolvedCommit + InstallMode = 'Managed' + SourcePath = $null + } +} + +function Initialize-RepositoryFlywheelCli { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory)]$Python, + [Parameter(Mandatory)][string]$Root, + [Parameter(Mandatory)][string]$FlywheelHome, + [Parameter(Mandatory)][string]$TemporaryRoot + ) + + $target = Join-Path $Root '.flywheel\tools' + $metadataPath = Join-Path $target 'cli-source.yaml' + if (Test-Path -LiteralPath $target) { + if (-not (Test-Path -LiteralPath $metadataPath -PathType Leaf)) { + Invoke-BootstrapFailure -Message 'Repository-owned CLI source cannot be installed because .flywheel/tools already exists without CLI source metadata.' -Code $script:ExitCode.RepositoryConflict -Remediation 'Preserve or relocate the existing tools, then retry. The installer will not overwrite them.' + } + $recordedMode = Get-TopLevelYamlValue -Path $metadataPath -Key 'installation_mode' + if ($recordedMode -ne 'source') { + Invoke-BootstrapFailure -Message 'Repository-owned CLI source metadata is malformed or uses an unsupported installation mode.' -Code $script:ExitCode.RepositoryConflict -Remediation 'Repair cli-source.yaml before retrying. The installer will not overwrite repository tools.' + } + if (-not (Test-Path -LiteralPath (Join-Path $target 'pyproject.toml') -PathType Leaf)) { + Invoke-BootstrapFailure -Message 'Repository-owned CLI source metadata exists, but pyproject.toml is missing.' -Code $script:ExitCode.RepositoryConflict -Remediation 'Repair the governed repository-owned tools before retrying.' + } + $sourceIdentity = Get-TopLevelYamlValue -Path $metadataPath -Key 'source_identity' + $resolvedCommit = Get-TopLevelYamlValue -Path $metadataPath -Key 'source_commit' + Write-BootstrapSuccess -Message 'Existing repository-owned CLI source preserved' + } + else { + if ($ValidateOnly) { + Invoke-BootstrapFailure -Message 'Repository-owned CLI source is not installed.' -Code $script:ExitCode.Validation -Remediation 'Run setup without -ValidateOnly and select Source.' + } + $source = Get-FlywheelCliSource -FlywheelHome $FlywheelHome -TemporaryRoot $TemporaryRoot -IncludeDevelopmentFiles + if (-not (Test-Path -LiteralPath $source.Path -PathType Container)) { + Invoke-BootstrapFailure -Message 'Repository-owned installation requires a CLI source directory.' -Code $script:ExitCode.Acquisition -Remediation 'Use a source directory with -CliPath or use -CliInstallMode Managed for a wheel or sdist.' + } + + $stagingParent = Join-Path $Root '.flywheel\.runtime' + New-BootstrapDirectory -Path $stagingParent -Confirm:$false + $staging = Join-Path $stagingParent ("cli-source-$($script:RunId.Substring(0, 8))") + New-BootstrapDirectory -Path $staging -Confirm:$false + try { + foreach ($item in @('pyproject.toml', 'README.md', 'src', 'tests', 'tools')) { + $sourceItem = Join-Path $source.Path $item + if (-not (Test-Path -LiteralPath $sourceItem)) { + Invoke-BootstrapFailure -Message "CLI source is missing required project content: $item" -Code $script:ExitCode.Integrity + } + Copy-Item -LiteralPath $sourceItem -Destination $staging -Recurse -Force -ErrorAction Stop + } + $sourceCommit = if ($source.ResolvedCommit) { $source.ResolvedCommit } else { 'local' } + @( + 'schema_version: 1' + 'installation_mode: source' + "source_repository: $($script:CliRepository)" + "source_commit: $sourceCommit" + "source_identity: '$($source.Identity.Replace("'", "''"))'" + 'update_policy: repository-governed' + ) | Set-Content -LiteralPath (Join-Path $staging 'cli-source.yaml') -Encoding UTF8 -ErrorAction Stop + if ($PSCmdlet.ShouldProcess($target, 'Publish repository-owned CLI source')) { + Move-Item -LiteralPath $staging -Destination $target -ErrorAction Stop + } + } + finally { + if (Test-Path -LiteralPath $staging) { + Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue + } + } + $sourceIdentity = $source.Identity + $resolvedCommit = $source.ResolvedCommit + Write-BootstrapSuccess -Message 'Editable CLI source installed in .flywheel/tools' + } + + $environment = Join-Path $Root '.flywheel\.runtime\python-cli' + $venvPython = Join-Path $environment 'Scripts\python.exe' + $flywheel = Join-Path $environment 'Scripts\flywheel.exe' + $healthy = $false + if ((Test-Path -LiteralPath $venvPython) -and (Test-Path -LiteralPath $flywheel)) { + $healthy = (Invoke-BootstrapNativeCommand -FilePath $flywheel -ArgumentList @('--version') -AllowFailure).ExitCode -eq 0 + } + if (-not $healthy) { + if ($ValidateOnly) { + Invoke-BootstrapFailure -Message 'The repository-owned CLI runtime is not healthy.' -Code $script:ExitCode.Validation -Remediation 'Run setup without -ValidateOnly and select Source to rebuild the runtime.' + } + if (Test-Path -LiteralPath $environment) { + Write-BootstrapWarning -Message 'Existing repository-owned CLI runtime is unhealthy.' + if ($NonInteractive -or (Confirm-BootstrapAction -Prompt 'Rebuild the Flywheel-owned repository runtime?' -DefaultYes $true)) { Remove-Item -LiteralPath $environment -Recurse -Force -ErrorAction Stop } + else { Invoke-BootstrapFailure -Message 'A healthy repository-owned CLI runtime is required.' -Code $script:ExitCode.Cancelled } + } + New-BootstrapDirectory -Path (Split-Path -Parent $environment) -Confirm:$false + Write-Host 'Preparing repository-owned AI Flywheel CLI runtime...' -ForegroundColor DarkGray + Invoke-BootstrapPython -Python $Python -ArgumentList @('-m', 'venv', $environment) | Out-Null + Invoke-BootstrapNativeCommand -FilePath $venvPython -ArgumentList @('-m', 'pip', 'install', '--disable-pip-version-check', '--editable', "$target[dev]") | Out-Null } $versionResult = Invoke-BootstrapNativeCommand -FilePath $flywheel -ArgumentList @('--version') return [pscustomobject]@{ @@ -650,6 +821,8 @@ function Initialize-FlywheelCliEnvironment { Environment = $environment SourceIdentity = $sourceIdentity ResolvedCommit = $resolvedCommit + InstallMode = 'Source' + SourcePath = $target } } @@ -835,6 +1008,23 @@ function Invoke-AIFlywheelBootstrap { if ($frameworkInstalled) { Write-BootstrapSuccess -Message "Official framework $($compatibility.Version) installed" } else { Write-BootstrapSuccess -Message "Compatible framework $($compatibility.Version) already installed and left intact" } + $script:CurrentStage = 'CLI Installation Mode' + Write-BootstrapSection -Title 'CLI Installation Mode' + $installMode = Resolve-CliInstallMode + $script:BootstrapContext.CliInstallMode = $installMode + if ($installMode -eq 'Source') { + if ($NonInteractive -and -not $Apply -and -not (Test-Path -LiteralPath (Join-Path $root '.flywheel\tools\cli-source.yaml') -PathType Leaf)) { + Invoke-BootstrapFailure -Message 'Non-interactive repository-owned source installation requires explicit -Apply authorization.' -Code $script:ExitCode.Cancelled + } + Write-BootstrapSuccess -Message 'Repository-owned editable source selected' + } + else { + if (Test-Path -LiteralPath (Join-Path $root '.flywheel\tools\cli-source.yaml') -PathType Leaf) { + Invoke-BootstrapFailure -Message 'This repository already uses repository-owned CLI source.' -Code $script:ExitCode.RepositoryConflict -Remediation 'Select Source. Hybrid operation is not implemented in this milestone.' + } + Write-BootstrapSuccess -Message 'Managed CLI selected' + } + $script:CurrentStage = 'Python' Write-BootstrapSection -Title 'Python' $python = Get-OrInstallPythonRuntime @@ -845,10 +1035,16 @@ function Invoke-AIFlywheelBootstrap { $script:CurrentStage = 'CLI' Write-BootstrapSection -Title 'AI Flywheel CLI' - $cli = Initialize-FlywheelCliEnvironment -Python $python -FlywheelHome $flywheelHome -TemporaryRoot $script:TemporaryRoot + if ($installMode -eq 'Source') { + $cli = Initialize-RepositoryFlywheelCli -Python $python -Root $root -FlywheelHome $flywheelHome -TemporaryRoot $script:TemporaryRoot -Confirm:$false + } + else { + $cli = Initialize-FlywheelCliEnvironment -Python $python -FlywheelHome $flywheelHome -TemporaryRoot $script:TemporaryRoot + } $script:BootstrapContext.CliVersion = $cli.Version $script:BootstrapContext.CliExecutable = $cli.Executable $script:BootstrapContext.CliResolvedCommit = $cli.ResolvedCommit + $script:BootstrapContext.CliSourcePath = $cli.SourcePath Write-BootstrapSuccess -Message "AI Flywheel CLI $($cli.Version) ready" $script:CurrentStage = 'Validation' @@ -866,6 +1062,9 @@ function Invoke-AIFlywheelBootstrap { Write-Host "Repository: $root" Write-Host "Framework: $($compatibility.Version)" Write-Host "Framework installer commit: $($script:FrameworkInstallerCommit)" + Write-Host "CLI installation mode: $($cli.InstallMode)" + if ($cli.SourcePath) { Write-Host "CLI source: $($cli.SourcePath)" } + Write-Host "CLI command: $($cli.Executable)" Write-Host "CLI: $($cli.Version)" Write-Host 'Compatibility: Passed' Write-Host 'Repository validation: Passed' diff --git a/tools/test-windows-bootstrap.ps1 b/tools/test-windows-bootstrap.ps1 index 597ddd9..d161245 100644 --- a/tools/test-windows-bootstrap.ps1 +++ b/tools/test-windows-bootstrap.ps1 @@ -5,9 +5,9 @@ Runs dependency-free regression tests for the AI Flywheel Windows bootstrap. .DESCRIPTION -Verifies CLI-source extraction and the boundary between framework installation and -Python CLI setup. Framework compatibility classification is exercised without -network access or repository mutation. +Verifies managed and repository-owned CLI selection, CLI-source extraction, and +the boundary between framework installation and Python CLI setup. Framework +compatibility classification is exercised without network access. #> [CmdletBinding()] @@ -81,6 +81,7 @@ try { foreach ($removedParameter in @('FrameworkVersion', 'FrameworkRef', 'FrameworkPath')) { Assert-BootstrapTest -Condition ($parameterNames -notcontains $removedParameter) -Message "Removed parameter remains exposed: $removedParameter" } + Assert-BootstrapTest -Condition ($parameterNames -contains 'CliInstallMode') -Message 'CLI installation mode parameter is missing.' Assert-BootstrapTest -Condition ($sourceText.Contains('fe11b801b5dfeef812377a978558fd563b67fa9e')) -Message 'Official framework installer commit is not pinned.' Assert-BootstrapTest -Condition ($sourceText.Contains("`$CliRef = '2d84294cbe9922ec907fe718e9dd06e9944e0ebc'")) -Message 'Default CLI source is not pinned to the compatible implementation.' @@ -92,6 +93,12 @@ try { Assert-BootstrapTest -Condition (-not $sourceText.Contains($forbidden)) -Message "Framework-owned installation logic remains: $forbidden" } Assert-BootstrapTest -Condition (-not $sourceText.Contains("'start-execution'")) -Message 'Bootstrap must not invoke lifecycle operations.' + Assert-BootstrapTest -Condition (-not $sourceText.Contains('SetEnvironmentVariable')) -Message 'Bootstrap must not add the managed CLI to PATH yet.' + + $CliInstallMode = 'Source' + Assert-BootstrapTest -Condition ((Resolve-CliInstallMode) -eq 'Source') -Message 'Explicit source mode selection failed.' + $CliInstallMode = 'Managed' + Assert-BootstrapTest -Condition ((Resolve-CliInstallMode) -eq 'Managed') -Message 'Explicit managed mode selection failed.' $absentRoot = Join-Path $testRoot 'absent' New-Item -ItemType Directory -Path $absentRoot | Out-Null @@ -152,6 +159,9 @@ Set-Content -LiteralPath $record -Encoding UTF8 -Value ("{0}|{1}|{2}" -f $Reposi Assert-BootstrapTest -Condition (Test-CliArchiveEntryExcluded -EntryName 'repo/.flywheel/state.yaml') -Message 'CLI .flywheel exclusion failed.' Assert-BootstrapTest -Condition (-not (Test-CliArchiveEntryExcluded -EntryName 'repo/src/ai_flywheel_cli/cli.py')) -Message 'CLI source was incorrectly excluded.' + Assert-BootstrapTest -Condition (Test-CliArchiveEntryExcluded -EntryName 'repo/tests/test_cli.py') -Message 'Managed CLI development-file exclusion failed.' + Assert-BootstrapTest -Condition (-not (Test-CliArchiveEntryExcluded -EntryName 'repo/tests/test_cli.py' -IncludeDevelopmentFiles)) -Message 'Repository-owned CLI tests were incorrectly excluded.' + Assert-BootstrapTest -Condition (-not (Test-CliArchiveEntryExcluded -EntryName 'repo/tools/__main__.py' -IncludeDevelopmentFiles)) -Message 'Repository-owned CLI project tasks were incorrectly excluded.' $cliArchive = Join-Path $testRoot 'cli.zip' $deepSegment = 'deep-' + ('x' * 120) @@ -168,6 +178,68 @@ Set-Content -LiteralPath $record -Encoding UTF8 -Value ("{0}|{1}|{2}" -f $Reposi $deepPath = Join-Path $cliRoot "src\$deepSegment\$deepSegment\value.txt" Assert-BootstrapTest -Condition ([System.IO.File]::Exists((ConvertTo-BootstrapExtendedPath -Path $deepPath))) -Message 'Deep-path extraction failed.' + $sourceFixture = Join-Path $testRoot 'source-fixture' + foreach ($directory in @('src\ai_flywheel_cli', 'tests', 'tools')) { + New-Item -ItemType Directory -Path (Join-Path $sourceFixture $directory) -Force | Out-Null + } + Set-Content -LiteralPath (Join-Path $sourceFixture 'pyproject.toml') -Value '[project]' -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $sourceFixture 'README.md') -Value 'fixture' -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $sourceFixture 'src\ai_flywheel_cli\cli.py') -Value 'app = None' -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $sourceFixture 'tests\test_cli.py') -Value 'def test_cli(): pass' -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $sourceFixture 'tools\__main__.py') -Value 'pass' -Encoding UTF8 + + $sourceRoot = Join-Path $testRoot 'source-install' + New-Item -ItemType Directory -Path (Join-Path $sourceRoot '.flywheel') -Force | Out-Null + $script:InvocationBoundParameters = @{ CliPath = $sourceFixture } + $CliPath = $sourceFixture + $ValidateOnly = $false + $NonInteractive = $true + function Invoke-BootstrapPython { + [CmdletBinding()] + param([Parameter(Mandatory)]$Python, [Parameter(Mandatory)][string[]]$ArgumentList) + $environment = $ArgumentList[-1] + $scripts = Join-Path $environment 'Scripts' + New-Item -ItemType Directory -Path $scripts -Force | Out-Null + Set-Content -LiteralPath (Join-Path $scripts 'python.exe') -Value 'fixture' -Encoding ASCII + Set-Content -LiteralPath (Join-Path $scripts 'flywheel.exe') -Value 'fixture' -Encoding ASCII + return [pscustomobject]@{ ExitCode = 0; Output = @() } + } + function Invoke-BootstrapNativeCommand { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$FilePath, + [Parameter()][string[]]$ArgumentList = @(), + [switch]$AllowFailure + ) + if ($ArgumentList -contains '--version') { return [pscustomobject]@{ ExitCode = 0; Output = @('0.1.0') } } + return [pscustomobject]@{ ExitCode = 0; Output = @() } + } + $sourceCli = Initialize-RepositoryFlywheelCli -Python ([pscustomobject]@{}) -Root $sourceRoot -FlywheelHome $testRoot -TemporaryRoot $testRoot -Confirm:$false + $installedTools = Join-Path $sourceRoot '.flywheel\tools' + Assert-BootstrapTest -Condition ($sourceCli.InstallMode -eq 'Source') -Message 'Repository-owned CLI mode was not reported.' + Assert-BootstrapTest -Condition (Test-Path -LiteralPath (Join-Path $installedTools 'src\ai_flywheel_cli\cli.py')) -Message 'Repository-owned CLI source was not installed.' + Assert-BootstrapTest -Condition (Test-Path -LiteralPath (Join-Path $installedTools 'tests\test_cli.py')) -Message 'Repository-owned CLI tests were not installed.' + Assert-BootstrapTest -Condition (Test-Path -LiteralPath (Join-Path $installedTools 'tools\__main__.py')) -Message 'Repository-owned CLI project tasks were not installed.' + Assert-BootstrapTest -Condition (Test-Path -LiteralPath (Join-Path $installedTools 'cli-source.yaml')) -Message 'Repository-owned CLI source metadata was not installed.' + Set-Content -LiteralPath (Join-Path $installedTools 'src\ai_flywheel_cli\cli.py') -Value 'repository-owned adaptation' -Encoding UTF8 + $beforeSourceHash = (Get-FileHash -LiteralPath (Join-Path $installedTools 'src\ai_flywheel_cli\cli.py') -Algorithm SHA256).Hash + Initialize-RepositoryFlywheelCli -Python ([pscustomobject]@{}) -Root $sourceRoot -FlywheelHome $testRoot -TemporaryRoot $testRoot -Confirm:$false | Out-Null + $afterSourceHash = (Get-FileHash -LiteralPath (Join-Path $installedTools 'src\ai_flywheel_cli\cli.py') -Algorithm SHA256).Hash + Assert-BootstrapTest -Condition ($beforeSourceHash -eq $afterSourceHash) -Message 'Repeat source setup overwrote a repository-owned CLI adaptation.' + + $conflictRoot = Join-Path $testRoot 'source-conflict' + New-Item -ItemType Directory -Path (Join-Path $conflictRoot '.flywheel\tools') -Force | Out-Null + Set-Content -LiteralPath (Join-Path $conflictRoot '.flywheel\tools\existing-tool.py') -Value 'preserve' -Encoding UTF8 + $conflictDetected = $false + try { + Initialize-RepositoryFlywheelCli -Python ([pscustomobject]@{}) -Root $conflictRoot -FlywheelHome $testRoot -TemporaryRoot $testRoot -Confirm:$false | Out-Null + } + catch { + $conflictDetected = $_.Exception.Data['BootstrapExitCode'] -eq $script:ExitCode.RepositoryConflict + } + Assert-BootstrapTest -Condition $conflictDetected -Message 'Existing untracked .flywheel/tools content was not protected.' + Assert-BootstrapTest -Condition ((Get-Content -LiteralPath (Join-Path $conflictRoot '.flywheel\tools\existing-tool.py') -Raw).Trim() -eq 'preserve') -Message 'Existing repository tools were modified during conflict handling.' + Write-Output 'Windows bootstrap regression tests passed.' } finally { From dc51459fadda9d4939a8c5209d1d11cdeb9b5bc7 Mon Sep 17 00:00:00 2001 From: Jim Scott Date: Sun, 9 Aug 2026 07:31:44 -0700 Subject: [PATCH 5/5] Pin selectable Windows bootstrap --- docs/windows-bootstrap.md | 2 +- install.ps1 | 7 ++++--- tools/test-install-launcher.ps1 | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/windows-bootstrap.md b/docs/windows-bootstrap.md index 1af292b..b24c2f1 100644 --- a/docs/windows-bootstrap.md +++ b/docs/windows-bootstrap.md @@ -37,7 +37,7 @@ Package: ai-flywheel-framework-2026.08.08.zip Checksum asset: ai-flywheel-framework-2026.08.08.zip.sha256 Installer commit: fe11b801b5dfeef812377a978558fd563b67fa9e Default CLI source commit: 2d84294cbe9922ec907fe718e9dd06e9944e0ebc -Public launcher bootstrap commit: f87e458564c0de85f780c584d6ad7a22780c10a6 +Public launcher bootstrap commit: 4f787819df1617d0a6b8435196e9c978cfbf1457 ``` ## Invocation diff --git a/install.ps1 b/install.ps1 index 4d56262..04e54ea 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,8 +11,9 @@ an isolated child scope, downloads the reviewed canonical installer to a tempora launcher artifact afterward. The canonical Python bootstrap detects framework compatibility and delegates an -absent framework to the official published framework installer. It then prepares -the Python CLI and performs compatibility and health checks. +absent framework to the official published framework installer. It then asks the +user to choose repository-owned source or a managed CLI and performs compatibility +and health checks. #> & { @@ -20,7 +21,7 @@ the Python CLI and performs compatibility and health checks. $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' - $installerCommit = 'f87e458564c0de85f780c584d6ad7a22780c10a6' + $installerCommit = '4f787819df1617d0a6b8435196e9c978cfbf1457' $installerUri = "https://raw.githubusercontent.com/Infoconex/ai-flywheel-cli-python/$installerCommit/scripts/install-ai-flywheel.ps1" $installerPath = Join-Path ([System.IO.Path]::GetTempPath()) ('ai-flywheel-installer-{0}.ps1' -f [guid]::NewGuid().ToString('N').Substring(0, 8)) diff --git a/tools/test-install-launcher.ps1 b/tools/test-install-launcher.ps1 index cd2354c..cb51685 100644 --- a/tools/test-install-launcher.ps1 +++ b/tools/test-install-launcher.ps1 @@ -45,8 +45,8 @@ if ($launcherText -notmatch "installerCommit\s*=\s*'[0-9a-f]{40}'") { throw 'Public install launcher must pin an immutable canonical installer commit.' } -if (-not $launcherText.Contains("installerCommit = 'f87e458564c0de85f780c584d6ad7a22780c10a6'")) { - throw 'Public install launcher does not pin the reviewed framework-delegating bootstrap.' +if (-not $launcherText.Contains("installerCommit = '4f787819df1617d0a6b8435196e9c978cfbf1457'")) { + throw 'Public install launcher does not pin the reviewed installation-mode bootstrap.' } if (-not $launcherText.Contains('/scripts/install-ai-flywheel.ps1')) {