diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53039f1..33d8d35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,6 +89,301 @@ jobs: - name: Certify Windows native secret-store path run: npm run smoke:windows:native-secret-store + windows-installer-package: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.x" + + - name: Install dependencies + run: npm ci + + - name: Install WiX Toolset + shell: pwsh + run: | + dotnet tool install --global wix --version 7.0.0 + "$env:USERPROFILE\.dotnet\tools" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Validate Windows packaging metadata + run: npm run validate:windows-packaging + + - name: Build Windows MSI + run: npm run package:windows-msi -- --out-dir artifacts/windows-installer + + - name: Smoke installed MSI from PATH + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $PSNativeCommandUseErrorActionPreference = $true + $msi = Get-ChildItem -Path "artifacts/windows-installer" -Filter "*.msi" | Select-Object -First 1 + if (!$msi) { + throw "MSI artifact was not produced." + } + + $installLog = Join-Path $env:RUNNER_TEMP "xyte-cli-msi-install.log" + $installArgs = @("/i", "`"$($msi.FullName)`"", "/qn", "/norestart", "/L*V", "`"$installLog`"") + $install = Start-Process -FilePath "msiexec.exe" -ArgumentList $installArgs -Wait -PassThru + if ($install.ExitCode -ne 0) { + if (Test-Path -LiteralPath $installLog) { + Get-Content -LiteralPath $installLog -Tail 200 + } + throw "MSI install failed with exit code $($install.ExitCode)." + } + + $installDir = Join-Path $env:ProgramFiles "Xyte CLI" + $xyteCliCmd = Join-Path $installDir "xyte-cli.cmd" + if (!(Test-Path -LiteralPath $xyteCliCmd)) { + throw "Installed xyte-cli.cmd not found at $xyteCliCmd." + } + + $machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") + $machineEntries = ($machinePath -split ";") | ForEach-Object { $_.TrimEnd("\") } + if ($machineEntries -notcontains $installDir) { + throw "MSI did not add $installDir to the machine PATH." + } + $env:Path = @($machinePath, [Environment]::GetEnvironmentVariable("Path", "User")) -join ";" + + where.exe xyte-cli + xyte-cli --version + xyte-cli doctor environment --format text + + Add-Content -Path $env:GITHUB_PATH -Value $installDir + + - name: Smoke setup assistant and record terminal transcript + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $transcript = Join-Path (Resolve-Path "artifacts/windows-installer") "xyte-cli-windows-pristine-terminal.log" + $demoScript = Join-Path $env:RUNNER_TEMP "xyte-cli-windows-pristine-terminal.ps1" + @' + $ErrorActionPreference = "Stop" + $PSNativeCommandUseErrorActionPreference = $true + + Write-Host "Windows: $([Environment]::OSVersion.VersionString)" + Write-Host "PowerShell: $($PSVersionTable.PSVersion)" + Write-Host "" + + Write-Host "PS> where.exe xyte-cli" + where.exe xyte-cli + Write-Host "" + + Write-Host "PS> xyte-cli --version" + xyte-cli --version + Write-Host "" + + Write-Host "PS> xyte-cli doctor environment --format text" + xyte-cli doctor environment --format text + Write-Host "" + + $assistant = Join-Path $env:ProgramFiles "Xyte CLI\scripts\configure-xyte-cli.ps1" + if (!(Test-Path -LiteralPath $assistant)) { + throw "Installed setup assistant not found at $assistant." + } + $PSNativeCommandUseErrorActionPreference = $false + Write-Host "PS> & `"$assistant`" -NonInteractive" + & $assistant -NonInteractive + if ($LASTEXITCODE -ne 0) { + throw "Setup assistant failed with exit code $LASTEXITCODE." + } + '@ | Set-Content -LiteralPath $demoScript -Encoding utf8 + + & pwsh -NoProfile -ExecutionPolicy Bypass -File $demoScript *>&1 | Tee-Object -FilePath $transcript + if ($LASTEXITCODE -ne 0) { + throw "Windows terminal transcript smoke failed with exit code $LASTEXITCODE." + } + + - name: Smoke setup assistant under Windows PowerShell 5.1 + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $assistant = Join-Path $env:ProgramFiles "Xyte CLI\scripts\configure-xyte-cli.ps1" + $winPowerShell = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe" + & $winPowerShell -NoProfile -ExecutionPolicy Bypass -File $assistant -NonInteractive + if ($LASTEXITCODE -ne 0) { + throw "Setup assistant failed under Windows PowerShell 5.1 with exit code $LASTEXITCODE." + } + + - name: Smoke Start Menu shortcuts + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $shortcutDir = Join-Path ([Environment]::GetFolderPath("CommonPrograms")) "Xyte CLI" + $shell = New-Object -ComObject WScript.Shell + + $configurePath = Join-Path $shortcutDir "Configure Xyte CLI.lnk" + if (!(Test-Path -LiteralPath $configurePath)) { + throw "Missing Start Menu shortcut: $configurePath" + } + $configure = $shell.CreateShortcut($configurePath) + if (!(Test-Path -LiteralPath $configure.TargetPath)) { + throw "Configure shortcut target not found: $($configure.TargetPath)" + } + $run = Start-Process -FilePath $configure.TargetPath -ArgumentList "$($configure.Arguments) -NonInteractive" -Wait -PassThru -NoNewWindow + if ($run.ExitCode -ne 0) { + throw "Configure shortcut command failed with exit code $($run.ExitCode)." + } + + $consolePath = Join-Path $shortcutDir "Xyte CLI PowerShell.lnk" + if (!(Test-Path -LiteralPath $consolePath)) { + throw "Missing Start Menu shortcut: $consolePath" + } + $console = $shell.CreateShortcut($consolePath) + if (!(Test-Path -LiteralPath $console.TargetPath)) { + throw "PowerShell shortcut target not found: $($console.TargetPath)" + } + $consoleArgs = $console.Arguments -replace '^\s*-NoExit\s+', '' + $run = Start-Process -FilePath $console.TargetPath -ArgumentList $consoleArgs -Wait -PassThru -NoNewWindow + if ($run.ExitCode -ne 0) { + throw "PowerShell shortcut command failed with exit code $($run.ExitCode)." + } + + - name: Smoke npm global migration + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $PSNativeCommandUseErrorActionPreference = $true + npm pack --silent + $tarball = Get-ChildItem -Filter "xyteai-cli-*.tgz" | Select-Object -First 1 + if (!$tarball) { + throw "npm pack did not produce a tarball." + } + npm install -g $tarball.FullName + npm list -g "@xyteai/cli" --depth=0 + + $assistant = Join-Path $env:ProgramFiles "Xyte CLI\scripts\configure-xyte-cli.ps1" + $winPowerShell = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe" + $PSNativeCommandUseErrorActionPreference = $false + & $winPowerShell -NoProfile -ExecutionPolicy Bypass -File $assistant -AssumeYes -NonInteractive + if ($LASTEXITCODE -ne 0) { + throw "Setup assistant migration run failed with exit code $LASTEXITCODE." + } + + & npm list -g "@xyteai/cli" --depth=0 + if ($LASTEXITCODE -eq 0) { + throw "Assistant did not remove the previous npm global install." + } + exit 0 + + - name: Upload Windows terminal transcript + if: always() + uses: actions/upload-artifact@v4 + with: + name: windows-terminal-transcript + path: artifacts/windows-installer/xyte-cli-windows-pristine-terminal.log + if-no-files-found: ignore + + - name: Upload Windows MSI artifact + uses: actions/upload-artifact@v4 + with: + name: windows-msi + path: | + artifacts/windows-installer/*.msi + artifacts/windows-installer/windows-installer-manifest.json + artifacts/windows-installer/winget/*.yaml + + # Non-gating: records the interactive install (full msiexec UI, which + # fires the UILevel >= 5 assistant auto-launch that the silent smoke + # cannot reach) as a video + stills for human review. + windows-installer-demo: + needs: windows-installer-package + runs-on: windows-latest + continue-on-error: true + timeout-minutes: 15 + steps: + - name: Download Windows MSI artifact + uses: actions/download-artifact@v4 + with: + name: windows-msi + path: msi + + - name: Record interactive install demo + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + New-Item -ItemType Directory -Force -Path demo | Out-Null + $msi = Get-ChildItem -Path msi -Filter "*.msi" -Recurse | Select-Object -First 1 + if (!$msi) { + throw "MSI artifact not found." + } + + Add-Type -AssemblyName System.Windows.Forms + Add-Type -AssemblyName System.Drawing + function Save-Screenshot([string]$name) { + $bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds + $bitmap = New-Object System.Drawing.Bitmap($bounds.Width, $bounds.Height) + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + $graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size) + $bitmap.Save("demo\$name.png", [System.Drawing.Imaging.ImageFormat]::Png) + $graphics.Dispose() + $bitmap.Dispose() + } + + # Matroska stays playable even when the recorder is force-stopped. + $recorder = $null + if (Get-Command ffmpeg -ErrorAction SilentlyContinue) { + $recorder = Start-Process ffmpeg -ArgumentList "-y", "-f", "gdigrab", "-framerate", "5", "-i", "desktop", "-t", "300", "-pix_fmt", "yuv420p", "demo\xyte-cli-windows-install-demo.mkv" -PassThru -WindowStyle Hidden + Start-Sleep -Seconds 3 + } + + Save-Screenshot "01-before-install" + $install = Start-Process msiexec.exe -ArgumentList "/i", "`"$($msi.FullName)`"" -PassThru + $install.WaitForExit() + if ($install.ExitCode -ne 0) { + throw "Interactive MSI install failed with exit code $($install.ExitCode)." + } + Save-Screenshot "02-install-finished" + + # The auto-launched assistant works through its checks before blocking + # on the interactive setup prompt. + Start-Sleep -Seconds 45 + Save-Screenshot "03-assistant-prompt" + + try { + $wshell = New-Object -ComObject WScript.Shell + $activated = $false + foreach ($title in @("Windows PowerShell", "powershell")) { + if ($wshell.AppActivate($title)) { + $activated = $true + break + } + } + if ($activated) { + Start-Sleep -Seconds 1 + [System.Windows.Forms.SendKeys]::SendWait("n{ENTER}") + Start-Sleep -Seconds 10 + } else { + Write-Host "Assistant window not found for keystroke automation; recording covers the prompt state." + } + } catch { + Write-Host "Could not drive the assistant window: $_" + } + Save-Screenshot "04-assistant-done" + + if ($recorder) { + Start-Sleep -Seconds 2 + Stop-Process -Id $recorder.Id -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 2 + } + + - name: Upload install demo recording + if: always() + uses: actions/upload-artifact@v4 + with: + name: windows-install-demo + path: demo/ + if-no-files-found: warn + linux-native-secret-store-cert: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index e3424f6..b13ed46 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -76,6 +76,8 @@ jobs: run: npm run smoke:pack-install release: + # windows-msi must stay out of this needs list: a Windows toolchain failure + # must not block the npm tarball/SBOM release (see docs/release.md). needs: [meta, packaged-install-smoke] runs-on: ubuntu-latest steps: @@ -130,3 +132,100 @@ jobs: *.tgz sbom.cdx.json checksums.txt + + windows-msi: + needs: [meta, release] + runs-on: windows-latest + steps: + - name: Checkout tag + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: refs/tags/${{ needs.meta.outputs.tag }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.x" + + - name: Install dependencies + run: npm ci + + - name: Validate package version matches tag + shell: bash + run: | + set -euo pipefail + PACKAGE_VERSION="$(node -p "require('./package.json').version")" + TAG_VERSION="${{ needs.meta.outputs.version }}" + if [[ "${PACKAGE_VERSION}" != "${TAG_VERSION}" ]]; then + echo "::error::package.json version (${PACKAGE_VERSION}) does not match tag (${TAG_VERSION})." + exit 1 + fi + + - name: Install WiX Toolset + shell: pwsh + run: | + dotnet tool install --global wix --version 7.0.0 + "$env:USERPROFILE\.dotnet\tools" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Validate Windows packaging metadata + run: npm run validate:windows-packaging + + - name: Build Windows MSI + env: + WINDOWS_CODESIGN_PFX_BASE64: ${{ secrets.WINDOWS_CODESIGN_PFX_BASE64 }} + WINDOWS_CODESIGN_PFX_PASSWORD: ${{ secrets.WINDOWS_CODESIGN_PFX_PASSWORD }} + run: npm run package:windows-msi -- --out-dir artifacts/windows-installer + + - name: Generate Windows checksums + shell: bash + run: | + set -euo pipefail + cd artifacts/windows-installer + sha256sum *.msi windows-installer-manifest.json winget/*.yaml > windows-checksums.txt + + - name: Upload Windows MSI artifact + uses: actions/upload-artifact@v4 + with: + name: windows-msi + path: | + artifacts/windows-installer/*.msi + artifacts/windows-installer/windows-installer-manifest.json + artifacts/windows-installer/winget/*.yaml + artifacts/windows-installer/windows-checksums.txt + + - name: Publish Windows release assets + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.meta.outputs.tag }} + files: | + artifacts/windows-installer/*.msi + artifacts/windows-installer/windows-installer-manifest.json + artifacts/windows-installer/winget/*.yaml + artifacts/windows-installer/windows-checksums.txt + + - name: Verify Windows release assets attached + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + assets="$(gh release view "${{ needs.meta.outputs.tag }}" --repo "${{ github.repository }}" --json assets --jq '.assets[].name')" + for expected in \ + "XyteCLI-${{ needs.meta.outputs.version }}-win-x64.msi" \ + "windows-installer-manifest.json" \ + "windows-checksums.txt" \ + "Xyte.XyteCLI.yaml" \ + "Xyte.XyteCLI.installer.yaml" \ + "Xyte.XyteCLI.locale.en-US.yaml"; do + if ! grep -Fxq "$expected" <<<"$assets"; then + echo "::error::Windows release asset missing: $expected" + exit 1 + fi + done diff --git a/CHANGELOG.md b/CHANGELOG.md index 598d52b..0d2d0da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ The format is inspired by Keep a Changelog and this project follows SemVer for ` ## [Unreleased] +### Added +- Windows MSI installer pipeline: WiX-based packaging with a bundled Node.js runtime (checksum-verified against nodejs.org SHASUMS256), machine `PATH` entry, Start Menu shortcuts, the Configure Xyte CLI post-install assistant, generated WinGet manifests for `Xyte.XyteCLI`, and optional Authenticode signing. Release assets include the MSI, WinGet manifests, and Windows checksums, published independently of the npm release. See `docs/windows-installer.md`. +- Install-channel detection: `xyte-cli upgrade` reports `installChannel` (`npm` | `windows-msi`) and routes upgrade execution through `winget upgrade --id Xyte.XyteCLI --exact` on MSI installs while npm installs keep using `npm install --global`. + +### Changed +- **Breaking (JSON contracts):** upgrade payloads moved to `xyte.upgrade.check.v2` and `xyte.upgrade.result.v2`, which add the required `installChannel` field. The v1 schemas remain published unchanged for legacy payloads; consumers validating upgrade JSON should adopt the v2 schemas in `docs/schemas/`. + ## [0.11.0] - 2026-06-24 ### Added diff --git a/README.md b/README.md index c10cafb..70ebcc5 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Manual terminal use is supported for setup, debugging, and local testing. - npm: [@xyteai/cli](https://www.npmjs.com/package/@xyteai/cli) - GitHub Page: [docs/index.html](./docs/index.html) - Command reference: [docs/commands.md](./docs/commands.md) +- Windows installer: [docs/windows-installer.md](./docs/windows-installer.md) - Flows: [docs/flows/agent-ops.md](./docs/flows/agent-ops.md) - Schemas: [docs/schemas](./docs/schemas) @@ -80,6 +81,8 @@ For reproducible pipelines, replace `@latest` with a pinned version (e.g. `@0.10 Install [Node.js 22+](https://nodejs.org/en/download) first if `node --version` is missing or below 22 (macOS: `brew install node@22`, Windows: `winget install OpenJS.NodeJS.LTS`). +On Windows, use the native MSI when you want a bundled runtime and normal Windows install/update behavior. The Node/npm path remains supported for developers, agents, and environments that already standardize on Node.js. See [docs/windows-installer.md](./docs/windows-installer.md). + ```sh npm install -g @xyteai/cli@latest xyte-cli --version diff --git a/docs/commands.md b/docs/commands.md index 3fc3c83..62b2938 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -95,6 +95,9 @@ Environment doctor notes: - If global install or persistent `PATH` changes are blocked, use `npm install --prefix ./.xyte-cli/runtime @xyteai/cli@latest`, then `./.xyte-cli/runtime/node_modules/.bin/xyte-cli ` or PowerShell `.\.xyte-cli\runtime\node_modules\.bin\xyte-cli.cmd `. - Chat-only assistants cannot install the CLI; use a shell-capable terminal or agent (Terminal, PowerShell, Codex, Claude Code/Desktop, GitHub Copilot CLI, VS Code Copilot Agent). +Upgrade notes: +- `xyte-cli upgrade --check --format json` reports `installChannel`. Npm installs use `npm install --global @xyteai/cli@latest`; Windows MSI installs use `winget upgrade --id Xyte.XyteCLI --exact` or a newer MSI. + Skill bundle notes: - `xyte-cli skills refresh` force-installs all agent skill bundles (project and user scope). - `xyte-cli upgrade` refreshes user-scope skills automatically; workspace copies are not auto-updated — run `xyte-cli skills refresh` in each workspace after upgrading. diff --git a/docs/getting-started.md b/docs/getting-started.md index 5751d98..f650ec1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -79,6 +79,8 @@ For reproducible pipelines, replace `@latest` with a pinned version (e.g. `@0.10 ### Manual terminal +On Windows, use the native MSI when you want a bundled runtime and normal Windows install/update behavior. The Node/npm path below remains supported for developers, agents, and environments that already standardize on Node.js. See [Windows Installer](./windows-installer.md). + ```sh npm install -g @xyteai/cli@latest xyte-cli --help diff --git a/docs/reference/commands.html b/docs/reference/commands.html index 8c18eb3..78a7216 100644 --- a/docs/reference/commands.html +++ b/docs/reference/commands.html @@ -114,6 +114,7 @@

Core setup

  • Use xyte-cli doctor environment when the command is missing or the environment is unknown.
  • Use xyte-cli setup status for the stored profile state.
  • Use xyte-cli config doctor for live connectivity and credential diagnostics.
  • +
  • Use xyte-cli upgrade --check --format json to see the active update channel: npm installs update with npm install --global @xyteai/cli@latest, while Windows MSI installs update with winget upgrade --id Xyte.XyteCLI --exact or a newer MSI.
  • Operations

    diff --git a/docs/reference/schema-contracts.html b/docs/reference/schema-contracts.html index aa97314..709972f 100644 --- a/docs/reference/schema-contracts.html +++ b/docs/reference/schema-contracts.html @@ -232,8 +232,10 @@
  • inspect-fleet.v1.schema.json
  • report.v1.schema.json
  • status.v1.schema.json
  • -
  • upgrade-check.v1.schema.json
  • -
  • upgrade-result.v1.schema.json
  • +
  • upgrade-check.v1.schema.json (legacy)
  • +
  • upgrade-check.v2.schema.json
  • +
  • upgrade-result.v1.schema.json (legacy)
  • +
  • upgrade-result.v2.schema.json
  • utility-batch.v1.schema.json
  • utility-prepare.v1.schema.json
  • watch-frame.v1.schema.json
  • diff --git a/docs/release.md b/docs/release.md index a74a8a6..726591c 100644 --- a/docs/release.md +++ b/docs/release.md @@ -9,6 +9,7 @@ This project targets **Node.js 22** as its primary runtime environment. - Earlier Node.js releases, including **Node 18**, are no longer supported. This repository ships one npm package: `@xyteai/cli`. +Release assets also include a Windows x64 MSI for users who should not install Node.js/npm manually. The npm package remains a first-class install path. ## Governance @@ -26,6 +27,7 @@ This repository ships one npm package: `@xyteai/cli`. - `npm test` - `npm run build` - packaged-install smoke from the built tarball (`npm run smoke:pack-install`) +- Windows MSI package build (`npm run package:windows-msi`) - Windows native secret-store certification (`windows-native-secret-store-cert`) - Linux native secret-store certification (`linux-native-secret-store-cert`) - separate security job: `npm audit --audit-level=high` @@ -65,6 +67,9 @@ Prerequisites: - npm package publish rights for `@xyteai/cli`. - `NPM_TOKEN` configured in repository/environment secrets. +- Optional Windows code-signing secrets for MSI release assets: + - `WINDOWS_CODESIGN_PFX_BASE64` + - `WINDOWS_CODESIGN_PFX_PASSWORD` ## Release Assets Workflow @@ -72,9 +77,19 @@ Prerequisites: - the same packaged-install smoke validates the tarball before attach/upload steps - built npm tarball (`*.tgz`) +- Windows installer (`XyteCLI--win-x64.msi`) +- generated WinGet manifests for `Xyte.XyteCLI` - CycloneDX SBOM (`sbom.cdx.json`) - SHA-256 checksums (`checksums.txt`) +The MSI embeds a bundled Windows Node.js runtime, adds `C:\Program Files\Xyte CLI` to machine `PATH`, ships the post-install setup assistant, and marks the install channel as `windows-msi`. Users update MSI installs with `winget upgrade --id Xyte.XyteCLI --exact` or a newer MSI, not `npm install -g`. + +The Windows MSI release asset job publishes MSI-specific assets independently from the npm package release job. A Windows runner, WiX, signing, or Node runtime download failure should not block the npm tarball, SBOM, and npm checksums from being attached to the release. + +If Windows code-signing secrets are configured, the Windows packaging script signs the MSI before generating WinGet manifests, checksums, and upload. If they are not configured, the workflow still builds and uploads the MSI, but the asset is unsigned and should not be submitted to WinGet. + +CI currently pins the WiX .NET tool to `7.0.0` and the MSI build command passes WiX's `-acceptEula wix7` flag. + ## Manual Emergency Publish If GitHub Actions is unavailable: diff --git a/docs/schemas/upgrade-check.v2.schema.json b/docs/schemas/upgrade-check.v2.schema.json new file mode 100644 index 0000000..4362af1 --- /dev/null +++ b/docs/schemas/upgrade-check.v2.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://xyte.dev/schemas/upgrade-check.v2.schema.json", + "title": "Xyte Upgrade Check V2", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "generatedAtUtc", + "packageName", + "installChannel", + "currentVersion", + "latestVersion", + "upToDate", + "recommendedCommand" + ], + "properties": { + "schemaVersion": { + "const": "xyte.upgrade.check.v2" + }, + "generatedAtUtc": { + "type": "string" + }, + "packageName": { + "type": "string" + }, + "installChannel": { + "enum": [ + "npm", + "windows-msi" + ] + }, + "currentVersion": { + "type": "string" + }, + "latestVersion": { + "type": "string" + }, + "upToDate": { + "type": "boolean" + }, + "recommendedCommand": { + "type": [ + "string", + "null" + ] + } + } +} diff --git a/docs/schemas/upgrade-result.v2.schema.json b/docs/schemas/upgrade-result.v2.schema.json new file mode 100644 index 0000000..9cd7e2d --- /dev/null +++ b/docs/schemas/upgrade-result.v2.schema.json @@ -0,0 +1,201 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://xyte.dev/schemas/upgrade-result.v2.schema.json", + "title": "Xyte Upgrade Result V2", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "generatedAtUtc", + "packageName", + "installChannel", + "currentVersion", + "latestVersion", + "upToDateBefore", + "updated", + "verify", + "skills", + "warnings" + ], + "properties": { + "schemaVersion": { + "const": "xyte.upgrade.result.v2" + }, + "generatedAtUtc": { + "type": "string" + }, + "packageName": { + "type": "string" + }, + "installChannel": { + "enum": [ + "npm", + "windows-msi" + ] + }, + "currentVersion": { + "type": "string" + }, + "latestVersion": { + "type": "string" + }, + "upToDateBefore": { + "type": "boolean" + }, + "updated": { + "type": "boolean" + }, + "updateCommand": { + "type": "object", + "additionalProperties": false, + "required": [ + "command", + "args" + ], + "properties": { + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "verify": { + "type": "object", + "additionalProperties": false, + "required": [ + "command", + "detectedVersion", + "expectedVersion", + "match" + ], + "properties": { + "command": { + "type": "object", + "additionalProperties": false, + "required": [ + "command", + "args" + ], + "properties": { + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "detectedVersion": { + "type": "string" + }, + "expectedVersion": { + "type": "string" + }, + "match": { + "type": "boolean" + } + } + }, + "skills": { + "type": "object", + "additionalProperties": false, + "required": [ + "scope", + "agents", + "force", + "sourceDir", + "outcomes", + "failedCount" + ], + "properties": { + "scope": { + "const": "user" + }, + "agents": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "claude", + "copilot", + "codex" + ] + } + }, + "force": { + "const": true + }, + "sourceDir": { + "type": "string" + }, + "failedCount": { + "type": "integer", + "minimum": 0 + }, + "outcomes": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "scope", + "agent", + "rootDir", + "targetDir", + "status" + ], + "properties": { + "scope": { + "type": "string", + "enum": [ + "project", + "user" + ] + }, + "agent": { + "type": "string", + "enum": [ + "claude", + "copilot", + "codex" + ] + }, + "rootDir": { + "type": "string" + }, + "targetDir": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "installed", + "overwritten", + "skipped", + "failed" + ] + }, + "error": { + "type": "string" + } + } + } + } + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + } +} diff --git a/docs/windows-installer.md b/docs/windows-installer.md new file mode 100644 index 0000000..94d275d --- /dev/null +++ b/docs/windows-installer.md @@ -0,0 +1,150 @@ +# Windows Installer + +Use the Windows installer when you want Xyte CLI to feel like a normal Windows tool: no separate Node.js install, no `npx`, and a stable `xyte-cli` command on `PATH`. + +The MSI does not replace the Node/npm install path. `npm install -g @xyteai/cli@latest` and `npx -y @xyteai/cli@latest ...` remain supported for developers, CI, and agent environments that already manage Node.js. + +## What the MSI installs + +The MSI installs into `C:\Program Files\Xyte CLI`: + +- `xyte-cli.cmd` +- a bundled Windows x64 Node.js runtime +- built CLI files under `dist` +- production `node_modules` +- shipped agent skills and JSON schemas +- `install-channel.json`, which marks this install as the `windows-msi` update channel +- `scripts\configure-xyte-cli.ps1`, the post-install setup and migration assistant + +The installer adds `C:\Program Files\Xyte CLI` to the machine `PATH` and creates Start Menu shortcuts: + +- **Configure Xyte CLI**: checks migration, asks for API-key setup, and verifies readiness +- **Xyte CLI PowerShell**: opens PowerShell with a quick CLI/version diagnostic + +For an interactive first install, the MSI launches **Configure Xyte CLI** at the end of the install. Silent deployments do not launch an interactive prompt; run the assistant in the signed-in user's context after installation. + +## Fresh install + +1. Download `XyteCLI--win-x64.msi` from the GitHub release. +2. Install it normally. +3. When **Configure Xyte CLI** opens, follow the prompts for npm migration and setup. +4. When prompted, paste the API key into the CLI prompt. The prompt hides input. + +If the assistant was skipped or closed, open **Configure Xyte CLI** from the Start Menu. + +The API key is not passed through MSI properties. It is handled by `xyte-cli setup run`, which uses Windows DPAPI when native secure storage is available. + +After setup: + +```powershell +xyte-cli doctor environment --format json +xyte-cli setup status --field tenantId +``` + +## Migrate from npm or npx + +Existing configuration and API keys live under the same user profile locations, so replacing a previous command-line install does not require creating a new key. + +After installing the MSI, run **Configure Xyte CLI**. It checks: + +- which `xyte-cli` commands are visible on `PATH` +- whether `npm list -g @xyteai/cli --depth=0` finds a previous global install +- whether the Windows installer command or npm command will win on `PATH` + +If it finds a global npm install and you want the MSI to be the only local update channel, accept the prompt to remove it: + +```powershell +npm uninstall -g @xyteai/cli +``` + +If you intentionally keep the npm global install, verify which command wins on `PATH` and update through that channel. Then open a new PowerShell window and verify: + +```powershell +Get-Command xyte-cli -All +xyte-cli upgrade --check --format json +``` + +The upgrade check should report: + +```json +{ + "installChannel": "windows-msi", + "recommendedCommand": "winget upgrade --id Xyte.XyteCLI --exact" +} +``` + +If a user only used `npx -y @xyteai/cli@latest ...`, there is usually nothing to uninstall. The MSI command becomes the durable local command, while `npx` remains available for one-off latest-version runs. + +## Enterprise or Intune install + +Silent install: + +```powershell +msiexec /i XyteCLI--win-x64.msi /qn /norestart +``` + +Run setup after install in the signed-in user's context, not as a machine-wide MSI property: + +```powershell +& "$env:ProgramFiles\Xyte CLI\scripts\configure-xyte-cli.ps1" ` + -Tenant "" ` + -KeyFile "" ` + -AssumeYes ` + -NonInteractive +``` + +For Intune detection, check that this file exists: + +```powershell +$env:ProgramFiles\Xyte CLI\xyte-cli.cmd +``` + +For readiness detection after user setup: + +```powershell +xyte-cli setup status --tenant --field tenantId +``` + +## Updates + +The MSI install is a separate update channel from npm. A Windows installer deployment should update with WinGet or a newer MSI. Node-based installs should keep using `npm install -g @xyteai/cli@latest` or `npx -y @xyteai/cli@latest ...`. + +Check: + +```powershell +xyte-cli upgrade --check --format text +``` + +Apply through WinGet: + +```powershell +winget upgrade --id Xyte.XyteCLI --exact +``` + +When WinGet is not available, download the newer MSI and run: + +```powershell +msiexec /i XyteCLI--win-x64.msi /qn /norestart +``` + +The MSI uses a stable `UpgradeCode`, so newer MSI versions replace older MSI versions. + +Release packaging generates WinGet manifest YAML under `artifacts/windows-installer/winget`. After the signed MSI is published to the GitHub release, submit those manifests to the WinGet package repository for `Xyte.XyteCLI`. + +## Build from this repo + +On Windows: + +```powershell +npm ci +dotnet tool install --global wix --version 7.0.0 +npm run package:windows-msi -- --out-dir artifacts/windows-installer +``` + +The packaging script invokes WiX with `-acceptEula wix7`, matching the WiX 7 Open Source Maintenance Fee EULA gate. WiX MSI compilation is supported on Windows runners; use `--skip-msi` on other platforms for metadata-only packaging checks. + +For a metadata-only validation: + +```powershell +npm run validate:windows-packaging +``` diff --git a/package.json b/package.json index 6508437..bbf3e57 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,8 @@ "smoke:windows:native-secret-store": "tsx tests/smoke/windows-native-secret-store.ts", "smoke:external-live": "tsx tests/smoke/external-user-live.ts", "smoke:upgrade:controlled": "node scripts/smoke_upgrade_controlled.mjs", + "package:windows-msi": "node scripts/package_windows_msi.mjs", + "validate:windows-packaging": "node scripts/validate_windows_packaging.mjs", "test:commit": "npm run typecheck && npm test && npm run smoke:pack-install", "release:check": "node scripts/release_check.mjs", "tui": "tsx src/bin/xyte-cli.ts tui", diff --git a/packaging/windows/Product.wxs.template b/packaging/windows/Product.wxs.template new file mode 100644 index 0000000..eade4fe --- /dev/null +++ b/packaging/windows/Product.wxs.template @@ -0,0 +1,74 @@ + + + + + + + + +{{DIRECTORIES}} +{{FILE_COMPONENTS}} + + + + + + + + + + + + + + + + + + + + + + + + +{{FEATURE_COMPONENTS}} + + + + + diff --git a/packaging/windows/scripts/configure-xyte-cli.ps1 b/packaging/windows/scripts/configure-xyte-cli.ps1 new file mode 100644 index 0000000..bccd7ba --- /dev/null +++ b/packaging/windows/scripts/configure-xyte-cli.ps1 @@ -0,0 +1,132 @@ +param( + [string]$Tenant, + [string]$KeyFile, + [switch]$AssumeYes, + [switch]$SkipApiKeySetup, + [switch]$SkipNpmMigration, + [switch]$NonInteractive +) + +$ErrorActionPreference = "Stop" +# This script inspects $LASTEXITCODE by hand (npm probes are allowed to fail); +# PowerShell 7 hosts must not convert native exit codes into terminating errors. +$PSNativeCommandUseErrorActionPreference = $false + +function Write-Step { + param([string]$Message) + Write-Host "" + Write-Host "== $Message ==" +} + +function Invoke-XyteCli { + param([string[]]$Arguments) + & $script:XyteCli @Arguments + if ($LASTEXITCODE -ne 0) { + throw "xyte-cli $($Arguments -join ' ') failed with exit code $LASTEXITCODE." + } +} + +function Test-Yes { + param([string]$Question) + if ($AssumeYes) { + return $true + } + if ($NonInteractive) { + return $false + } + $answer = Read-Host "$Question [y/N]" + return @("y", "yes") -contains $answer.Trim().ToLowerInvariant() +} + +$script:InstallRoot = Split-Path -Parent (Split-Path -Parent $PSCommandPath) +$script:XyteCli = Join-Path $script:InstallRoot "xyte-cli.cmd" + +if (!(Test-Path $script:XyteCli)) { + throw "Could not find installed xyte-cli at $script:XyteCli" +} + +Write-Step "Verify installed Xyte CLI" +Invoke-XyteCli @("--version") +Invoke-XyteCli @("doctor", "environment", "--format", "text") + +Write-Step "Check command precedence" +$commands = @(Get-Command "xyte-cli" -All -ErrorAction SilentlyContinue) + @(Get-Command "xyte-cli.cmd" -All -ErrorAction SilentlyContinue) +$uniqueCommands = $commands | Sort-Object -Property Definition -Unique +if ($uniqueCommands.Count -eq 0) { + Write-Host "No xyte-cli command is visible on PATH yet. Open a new PowerShell window after install." +} else { + foreach ($command in $uniqueCommands) { + Write-Host "PATH candidate: $($command.Definition)" + } +} + +if (!$SkipNpmMigration) { + Write-Step "Check previous npm global install" + $npm = Get-Command "npm.cmd" -ErrorAction SilentlyContinue + if ($npm) { + $npmPath = $npm.Definition + # Windows PowerShell 5.1 turns redirected native stderr into terminating + # errors under "Stop"; npm warnings must not abort the migration probe. + $previousErrorPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + $npmListOutput = & $npmPath list -g @xyteai/cli --depth=0 2>$null + $ErrorActionPreference = $previousErrorPreference + $hasNpmGlobal = $LASTEXITCODE -eq 0 -and (($npmListOutput -join "`n") -match "@xyteai/cli@") + if ($hasNpmGlobal) { + Write-Host "Found previous global npm install of @xyteai/cli." + Write-Host "The MSI install keeps config and API keys under the same user profile locations." + if (Test-Yes "Remove the global npm copy so the Windows installer is the only update channel?") { + & $npmPath uninstall -g @xyteai/cli + if ($LASTEXITCODE -ne 0) { + throw "npm uninstall -g @xyteai/cli failed." + } + } else { + Write-Host "Leaving npm global install in place. If PATH picks npm first, run: npm uninstall -g @xyteai/cli" + } + } else { + Write-Host "No previous global npm install was detected." + } + } else { + Write-Host "npm is not available; skipping npm migration check." + } +} + +if (!$SkipApiKeySetup) { + Write-Step "Connect API key" + if ($KeyFile) { + if (!$Tenant) { + throw "-Tenant is required when -KeyFile is provided." + } + Invoke-XyteCli @("setup", "run", "--non-interactive", "--tenant", $Tenant, "--key-file", $KeyFile, "--output", "json") + } elseif (!$NonInteractive) { + Write-Host "Interactive setup will store the API key using Windows secure storage when available." + if (Test-Yes "Run xyte-cli setup now?") { + if ($Tenant) { + Invoke-XyteCli @("setup", "run", "--tenant", $Tenant) + } else { + Invoke-XyteCli @("setup", "run") + } + } + } else { + Write-Host "No -KeyFile was supplied, and -NonInteractive is set. Skipping API key setup." + } +} + +Write-Step "Readiness" +Invoke-XyteCli @("setup", "status", "--format", "text") +# setup status exits 0 in every state; the readiness signal is the state field. +$setupState = (& $script:XyteCli @("setup", "status", "--field", "state") | Out-String).Trim() +if (!$setupState) { + $setupState = "unknown" +} +Write-Host "" +if ($setupState -eq "ready") { + Write-Host "Xyte CLI Windows setup is complete." +} elseif ($setupState -eq "needs_setup") { + Write-Host "Xyte CLI Windows install is complete, but setup still needs an API key." + Write-Host "Run this assistant again or run: xyte-cli setup run" +} else { + Write-Host "Xyte CLI Windows install is complete, but readiness reported '$setupState'." + Write-Host "Run: xyte-cli config doctor" +} +exit 0 diff --git a/scripts/package_windows_msi.d.mts b/scripts/package_windows_msi.d.mts new file mode 100644 index 0000000..4b63f24 --- /dev/null +++ b/scripts/package_windows_msi.d.mts @@ -0,0 +1,12 @@ +export interface WindowsPackagingArgs { + outDir: string; + nodeVersion: string; + skipBuild: boolean; + skipMsi: boolean; + skipNode: boolean; + skipNpmInstall: boolean; +} + +export declare function parseArgs(argv: string[]): WindowsPackagingArgs; +export declare function validateArgs(args: WindowsPackagingArgs): void; +export declare function findExpectedSha256(shasumsText: string, fileName: string): string | undefined; diff --git a/scripts/package_windows_msi.mjs b/scripts/package_windows_msi.mjs new file mode 100644 index 0000000..fdb7a6c --- /dev/null +++ b/scripts/package_windows_msi.mjs @@ -0,0 +1,450 @@ +import { createHash } from 'node:crypto'; +import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { runOrThrow } from './run_command.mjs'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const packageJson = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8')); +const upgradeCode = '51D2C16F-65D2-4C39-9C6D-49D1D513AF2A'; +const wixEulaId = 'wix7'; + +function parseArgs(argv) { + const args = { + outDir: join(repoRoot, 'artifacts', 'windows-installer'), + nodeVersion: process.versions.node, + skipBuild: false, + skipMsi: false, + skipNode: false, + skipNpmInstall: false + }; + const readValue = (index, flag) => { + const value = argv[index]; + if (!value || value.startsWith('--')) { + throw new Error(`${flag} requires a value.`); + } + return value; + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--out-dir') { + i += 1; + args.outDir = resolve(readValue(i, arg)); + } else if (arg === '--node-version') { + i += 1; + args.nodeVersion = readValue(i, arg).replace(/^v/, ''); + } else if (arg === '--skip-build') args.skipBuild = true; + else if (arg === '--skip-msi') args.skipMsi = true; + else if (arg === '--skip-node') args.skipNode = true; + else if (arg === '--skip-npm-install') args.skipNpmInstall = true; + else throw new Error(`Unknown argument: ${arg}`); + } + return args; +} + +function validateArgs(args) { + if (!args.skipMsi && args.skipNode) { + throw new Error('--skip-node is only valid with --skip-msi; real MSI builds must include the bundled Node runtime.'); + } + if (!args.skipMsi && args.skipNpmInstall) { + throw new Error('--skip-npm-install is only valid with --skip-msi; real MSI builds must include production dependencies.'); + } +} + +function xmlEscape(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function windowsPath(value) { + return value.split('/').join('\\'); +} + +function windowsDirname(value) { + const index = value.lastIndexOf('\\'); + return index === -1 ? '.' : value.slice(0, index); +} + +function stableId(prefix, value) { + const hash = createHash('sha1').update(value).digest('hex').slice(0, 16); + return `${prefix}_${hash}`; +} + +function listFiles(root) { + const results = []; + function walk(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + } else if (entry.isFile()) { + results.push(fullPath); + } + } + } + walk(root); + return results.sort(); +} + +function sha256File(filePath) { + return createHash('sha256').update(readFileSync(filePath)).digest('hex').toUpperCase(); +} + +function findExpectedSha256(shasumsText, fileName) { + for (const line of shasumsText.split(/\r?\n/)) { + const [hash, name] = line.trim().split(/\s+/, 2); + if (name === fileName && /^[a-fA-F0-9]{64}$/.test(hash)) { + return hash.toUpperCase(); + } + } + return undefined; +} + +function manifestRelativePath(outDir, filePath) { + return relative(outDir, filePath).split('\\').join('/'); +} + +function ensureBuilt() { + if (!existsSync(join(repoRoot, 'dist', 'bin', 'xyte-cli.js'))) { + throw new Error('dist/bin/xyte-cli.js is missing. Run npm run build first or omit --skip-build.'); + } +} + +function ensureMsiBuildSupported() { + if (process.platform !== 'win32') { + throw new Error('Building a Windows MSI with WiX is supported only on Windows. Re-run on Windows or pass --skip-msi for metadata-only packaging.'); + } +} + +async function downloadFile(url, destination, description, maxTime) { + const partialPath = `${destination}.partial`; + rmSync(partialPath, { force: true }); + await runOrThrow( + process.platform === 'win32' ? 'curl.exe' : 'curl', + ['--fail', '--location', '--retry', '3', '--connect-timeout', '20', '--max-time', maxTime, '--output', partialPath, url], + description + ); + renameSync(partialPath, destination); +} + +async function downloadNode(args, payloadDir) { + if (args.skipNode) { + writeFileSync(join(payloadDir, 'node.exe.placeholder'), 'Node runtime omitted by --skip-node.\n'); + return; + } + + const cacheDir = join(args.outDir, 'cache'); + mkdirSync(cacheDir, { recursive: true }); + const nodeBase = `node-v${args.nodeVersion}-win-x64`; + const zipPath = join(cacheDir, `${nodeBase}.zip`); + const nodeUrl = `https://nodejs.org/dist/v${args.nodeVersion}/${nodeBase}.zip`; + const shasumsPath = join(cacheDir, `node-v${args.nodeVersion}-SHASUMS256.txt`); + const shasumsUrl = `https://nodejs.org/dist/v${args.nodeVersion}/SHASUMS256.txt`; + + if (!existsSync(zipPath)) { + await downloadFile(nodeUrl, zipPath, 'Download Node.js Windows runtime', '300'); + } + if (!existsSync(shasumsPath)) { + await downloadFile(shasumsUrl, shasumsPath, 'Download Node.js runtime checksums', '60'); + } + + const expectedSha256 = findExpectedSha256(readFileSync(shasumsPath, 'utf8'), `${nodeBase}.zip`); + if (!expectedSha256) { + rmSync(shasumsPath, { force: true }); + throw new Error( + `Could not find checksum for ${nodeBase}.zip in ${shasumsPath}. Removed the cached checksum file; re-run to download it again.` + ); + } + const actualSha256 = sha256File(zipPath); + if (actualSha256 !== expectedSha256) { + rmSync(zipPath, { force: true }); + rmSync(shasumsPath, { force: true }); + throw new Error( + `Node.js runtime checksum mismatch for ${nodeBase}.zip: expected ${expectedSha256}, got ${actualSha256}. Removed the cached files; re-run to download them again.` + ); + } + + const extractDir = join(cacheDir, nodeBase); + rmSync(extractDir, { recursive: true, force: true }); + mkdirSync(extractDir, { recursive: true }); + + if (process.platform === 'win32') { + await runOrThrow( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', `Expand-Archive -LiteralPath '${zipPath}' -DestinationPath '${extractDir}' -Force`], + 'Extract Node.js Windows runtime' + ); + } else { + await runOrThrow('unzip', ['-q', zipPath, '-d', extractDir], 'Extract Node.js Windows runtime'); + } + + const sourceRoot = join(extractDir, nodeBase); + cpSync(join(sourceRoot, 'node.exe'), join(payloadDir, 'node.exe')); + cpSync(join(sourceRoot, 'LICENSE'), join(payloadDir, 'node-LICENSE')); + cpSync(join(sourceRoot, 'README.md'), join(payloadDir, 'node-README.md')); +} + +async function installProductionDependencies(args, payloadDir) { + if (args.skipNpmInstall) { + writeFileSync(join(payloadDir, 'node_modules.placeholder'), 'Production dependencies omitted by --skip-npm-install.\n'); + return; + } + + cpSync(join(repoRoot, 'package.json'), join(payloadDir, 'package.json')); + cpSync(join(repoRoot, 'package-lock.json'), join(payloadDir, 'package-lock.json')); + await runOrThrow( + process.platform === 'win32' ? 'npm.cmd' : 'npm', + ['ci', '--omit=dev', '--ignore-scripts'], + 'Install production dependencies for Windows payload', + { cwd: payloadDir } + ); +} + +function copyPayloadFiles(payloadDir) { + cpSync(join(repoRoot, 'dist'), join(payloadDir, 'dist'), { recursive: true }); + cpSync(join(repoRoot, 'skills'), join(payloadDir, 'skills'), { recursive: true }); + mkdirSync(join(payloadDir, 'docs'), { recursive: true }); + cpSync(join(repoRoot, 'docs', 'schemas'), join(payloadDir, 'docs', 'schemas'), { recursive: true }); + cpSync(join(repoRoot, 'README.md'), join(payloadDir, 'README.md')); + + mkdirSync(join(payloadDir, 'scripts'), { recursive: true }); + cpSync( + join(repoRoot, 'packaging', 'windows', 'scripts', 'configure-xyte-cli.ps1'), + join(payloadDir, 'scripts', 'configure-xyte-cli.ps1') + ); + + writeFileSync( + join(payloadDir, 'xyte-cli.cmd'), + '@echo off\r\nsetlocal\r\nset "XYTE_CLI_INSTALL_ROOT=%~dp0"\r\n"%XYTE_CLI_INSTALL_ROOT%node.exe" "%XYTE_CLI_INSTALL_ROOT%dist\\bin\\xyte-cli.js" %*\r\n', + 'utf8' + ); + + writeFileSync( + join(payloadDir, 'install-channel.json'), + `${JSON.stringify( + { + kind: 'windows-msi', + packageId: 'Xyte.XyteCLI' + }, + null, + 2 + )}\n`, + 'utf8' + ); +} + +function generateWxs(payloadDir, wxsPath) { + const files = listFiles(payloadDir); + const dirs = new Map(); + const children = new Map(); + const componentsByDir = new Map(); + const featureRefs = []; + + for (const filePath of files) { + const rel = windowsPath(relative(payloadDir, filePath)); + const relDir = windowsDirname(rel); + if (relDir !== '.') { + const parts = relDir.split('\\'); + let current = ''; + for (const part of parts) { + current = current ? `${current}\\${part}` : part; + if (!dirs.has(current)) { + const parent = current.includes('\\') ? current.slice(0, current.lastIndexOf('\\')) : ''; + dirs.set(current, { + id: stableId('Dir', current), + name: part, + parent + }); + const siblingList = children.get(parent) ?? []; + siblingList.push(current); + children.set(parent, siblingList); + } + } + } + + const componentId = stableId('Cmp', rel); + const fileId = stableId('File', rel); + const component = + ` \n` + + ` \n` + + ` `; + const componentList = componentsByDir.get(relDir) ?? []; + componentList.push(component); + componentsByDir.set(relDir, componentList); + featureRefs.push(` `); + } + + function renderComponents(relDir, indent) { + return (componentsByDir.get(relDir) ?? []).map((component) => + component + .split('\n') + .map((line) => `${indent}${line.trimStart()}`) + .join('\n') + ); + } + + function renderDirectories(parent, indent) { + const lines = []; + for (const relDir of (children.get(parent) ?? []).sort()) { + const dir = dirs.get(relDir); + lines.push(`${indent}`); + lines.push(...renderComponents(relDir, `${indent} `)); + lines.push(...renderDirectories(relDir, `${indent} `)); + lines.push(`${indent}`); + } + return lines; + } + const directoryLines = renderDirectories('', ' '); + const rootComponents = renderComponents('.', ' '); + + const template = readFileSync(join(repoRoot, 'packaging', 'windows', 'Product.wxs.template'), 'utf8'); + const wxs = template + .replaceAll('{{VERSION}}', packageJson.version) + .replaceAll('{{UPGRADE_CODE}}', upgradeCode) + .replaceAll('{{DIRECTORIES}}', directoryLines.join('\n')) + .replaceAll('{{FILE_COMPONENTS}}', rootComponents.join('\n')) + .replaceAll('{{FEATURE_COMPONENTS}}', featureRefs.join('\n')); + writeFileSync(wxsPath, wxs, 'utf8'); +} + +function generateWingetManifests(args, msiPath) { + const wingetDir = join(args.outDir, 'winget'); + mkdirSync(wingetDir, { recursive: true }); + + const packageIdentifier = 'Xyte.XyteCLI'; + const packageVersion = packageJson.version; + const installerUrl = + process.env.XYTE_WINDOWS_INSTALLER_URL?.trim() || + `https://github.com/xyte-io/xyte-cli/releases/download/v${packageVersion}/XyteCLI-${packageVersion}-win-x64.msi`; + const installerSha256 = existsSync(msiPath) ? sha256File(msiPath) : ''; + const manifestVersion = '1.9.0'; + + writeFileSync( + join(wingetDir, `${packageIdentifier}.yaml`), + [ + `PackageIdentifier: ${packageIdentifier}`, + `PackageVersion: ${packageVersion}`, + 'DefaultLocale: en-US', + 'ManifestType: version', + `ManifestVersion: ${manifestVersion}`, + '' + ].join('\n'), + 'utf8' + ); + + writeFileSync( + join(wingetDir, `${packageIdentifier}.installer.yaml`), + [ + `PackageIdentifier: ${packageIdentifier}`, + `PackageVersion: ${packageVersion}`, + 'InstallerType: wix', + 'Scope: machine', + 'InstallModes:', + '- interactive', + '- silent', + 'UpgradeBehavior: install', + 'Commands:', + '- xyte-cli', + 'Installers:', + '- Architecture: x64', + ` InstallerUrl: ${installerUrl}`, + ` InstallerSha256: ${installerSha256}`, + 'ManifestType: installer', + `ManifestVersion: ${manifestVersion}`, + '' + ].join('\n'), + 'utf8' + ); + + writeFileSync( + join(wingetDir, `${packageIdentifier}.locale.en-US.yaml`), + [ + `PackageIdentifier: ${packageIdentifier}`, + `PackageVersion: ${packageVersion}`, + 'PackageLocale: en-US', + 'Publisher: Xyte', + 'PackageName: Xyte CLI', + 'License: Apache-2.0', + 'ShortDescription: Agent-first Xyte CLI and console', + 'Description: Xyte CLI operates Xyte fleets from a terminal or shell-capable AI agent.', + 'PackageUrl: https://github.com/xyte-io/xyte-cli', + 'ManifestType: defaultLocale', + `ManifestVersion: ${manifestVersion}`, + '' + ].join('\n'), + 'utf8' + ); + + return wingetDir; +} + +async function signMsiIfConfigured(msiPath) { + if (!process.env.WINDOWS_CODESIGN_PFX_BASE64?.trim()) { + return false; + } + if (process.platform !== 'win32') { + throw new Error('WINDOWS_CODESIGN_PFX_BASE64 is set, but MSI signing is only supported on Windows runners.'); + } + await runOrThrow( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', join(repoRoot, 'scripts', 'sign_windows_msi.ps1'), '-MsiPath', msiPath], + 'Sign Windows MSI' + ); + return true; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + validateArgs(args); + if (!args.skipMsi) { + ensureMsiBuildSupported(); + } + const payloadDir = join(args.outDir, 'payload'); + const wxsPath = join(args.outDir, 'Product.generated.wxs'); + const msiPath = join(args.outDir, `XyteCLI-${packageJson.version}-win-x64.msi`); + + rmSync(payloadDir, { recursive: true, force: true }); + mkdirSync(payloadDir, { recursive: true }); + + if (!args.skipBuild) { + await runOrThrow(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', 'build'], 'Build xyte-cli'); + } + ensureBuilt(); + copyPayloadFiles(payloadDir); + await installProductionDependencies(args, payloadDir); + await downloadNode(args, payloadDir); + generateWxs(payloadDir, wxsPath); + + if (!args.skipMsi) { + await runOrThrow('wix', ['build', wxsPath, '-arch', 'x64', '-out', msiPath, '-acceptEula', wixEulaId], 'Build Windows MSI'); + await signMsiIfConfigured(msiPath); + } + const wingetDir = generateWingetManifests(args, msiPath); + + const manifest = { + schemaVersion: 'xyte.windowsInstallerBuild.v1', + packageVersion: packageJson.version, + nodeVersion: args.skipNode ? null : args.nodeVersion, + wixEulaId, + payloadDir: manifestRelativePath(args.outDir, payloadDir), + wxsPath: manifestRelativePath(args.outDir, wxsPath), + msiPath: args.skipMsi ? null : manifestRelativePath(args.outDir, msiPath), + wingetDir: manifestRelativePath(args.outDir, wingetDir) + }; + writeFileSync(join(args.outDir, 'windows-installer-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); + process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`); +} + +const invokedDirectly = process.argv[1] ? import.meta.url === pathToFileURL(resolve(process.argv[1])).href : false; +if (invokedDirectly) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack || error.message : String(error)}\n`); + process.exitCode = 1; + }); +} + +export { parseArgs, validateArgs, findExpectedSha256 }; diff --git a/scripts/run_command.mjs b/scripts/run_command.mjs index 9ff25cd..fb4ae90 100644 --- a/scripts/run_command.mjs +++ b/scripts/run_command.mjs @@ -1,5 +1,3 @@ -#!/usr/bin/env node - import crossSpawn from 'cross-spawn'; export function runCommand(command, args, options = {}) { diff --git a/scripts/sign_windows_msi.ps1 b/scripts/sign_windows_msi.ps1 new file mode 100644 index 0000000..f5d7b3e --- /dev/null +++ b/scripts/sign_windows_msi.ps1 @@ -0,0 +1,51 @@ +param( + [Parameter(Mandatory = $true)] + [string]$MsiPath, + + [string]$CertificateBase64 = $env:WINDOWS_CODESIGN_PFX_BASE64, + [string]$CertificatePassword = $env:WINDOWS_CODESIGN_PFX_PASSWORD, + [string]$TimestampUrl = "https://timestamp.digicert.com" +) + +$ErrorActionPreference = "Stop" + +if (!(Test-Path $MsiPath)) { + throw "MSI not found: $MsiPath" +} + +if ([string]::IsNullOrWhiteSpace($CertificateBase64)) { + throw "WINDOWS_CODESIGN_PFX_BASE64 is required to sign the MSI." +} + +$signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -Filter signtool.exe -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match "\\x64\\signtool\.exe$" } | + Sort-Object FullName -Descending | + Select-Object -First 1 + +if (!$signtool) { + throw "signtool.exe was not found. Install the Windows SDK on the signing runner." +} + +$pfxPath = Join-Path $env:RUNNER_TEMP "xyte-cli-codesign.pfx" +[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($CertificateBase64)) + +try { + $args = @( + "sign", + "/fd", "SHA256", + "/tr", $TimestampUrl, + "/td", "SHA256", + "/f", $pfxPath + ) + if (![string]::IsNullOrWhiteSpace($CertificatePassword)) { + $args += @("/p", $CertificatePassword) + } + $args += $MsiPath + + & $signtool.FullName @args + if ($LASTEXITCODE -ne 0) { + throw "signtool failed with exit code $LASTEXITCODE" + } +} finally { + Remove-Item -LiteralPath $pfxPath -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/smoke_upgrade_controlled_inner.mjs b/scripts/smoke_upgrade_controlled_inner.mjs index 630ecff..0aadef5 100644 --- a/scripts/smoke_upgrade_controlled_inner.mjs +++ b/scripts/smoke_upgrade_controlled_inner.mjs @@ -316,7 +316,7 @@ async function main() { ); assertSuccess(upgrade, 'xyte-cli upgrade', XYTE_COMMAND, ['upgrade', '--yes', '--output', 'json']); const upgradePayload = parseJsonOutput(upgrade.stdout); - if (upgradePayload.schemaVersion !== 'xyte.upgrade.result.v1') { + if (upgradePayload.schemaVersion !== 'xyte.upgrade.result.v2') { throw new Error(`Unexpected upgrade payload schema: ${upgradePayload.schemaVersion}`); } diff --git a/scripts/validate_windows_packaging.mjs b/scripts/validate_windows_packaging.mjs new file mode 100644 index 0000000..16ab4c5 --- /dev/null +++ b/scripts/validate_windows_packaging.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { join, resolve } from 'node:path'; + +const repoRoot = resolve(fileURLToPath(new URL('..', import.meta.url))); +const requiredFiles = [ + 'packaging/windows/Product.wxs.template', + 'packaging/windows/scripts/configure-xyte-cli.ps1', + 'scripts/package_windows_msi.mjs', + 'scripts/sign_windows_msi.ps1' +]; + +for (const file of requiredFiles) { + if (!existsSync(join(repoRoot, file))) { + throw new Error(`Missing Windows packaging file: ${file}`); + } +} + +const assistant = readFileSync(join(repoRoot, 'packaging/windows/scripts/configure-xyte-cli.ps1'), 'utf8'); +for (const expected of [ + 'npm uninstall -g @xyteai/cli', + '"setup", "run"', + '--key-file', + '"doctor", "environment"', + 'Get-Command "xyte-cli" -All', + '$LASTEXITCODE', + '"setup", "status", "--field", "state"', + '-eq "ready"', + 'setup still needs an API key' +]) { + if (!assistant.includes(expected)) { + throw new Error(`Windows setup assistant is missing expected behavior: ${expected}`); + } +} + +const packageScript = readFileSync(join(repoRoot, 'scripts/package_windows_msi.mjs'), 'utf8'); +for (const expected of [ + "const wixEulaId = 'wix7'", + "'-acceptEula', wixEulaId", + 'Building a Windows MSI with WiX is supported only on Windows', + 'SHASUMS256.txt', + 'Node.js runtime checksum mismatch', + "kind: 'windows-msi'", + "packageId: 'Xyte.XyteCLI'" +]) { + if (!packageScript.includes(expected)) { + throw new Error(`Windows packaging script is missing expected behavior: ${expected}`); + } +} + +const releaseWorkflow = readFileSync(join(repoRoot, '.github/workflows/release-assets.yml'), 'utf8'); +for (const expected of ['Publish Windows release assets', 'Verify Windows release assets', 'windows-checksums.txt']) { + if (!releaseWorkflow.includes(expected)) { + throw new Error(`Release workflow is missing expected Windows release behavior: ${expected}`); + } +} + +const template = readFileSync(join(repoRoot, 'packaging/windows/Product.wxs.template'), 'utf8'); +for (const expected of [ + 'PathEnvironment', + 'StartMenuShortcuts', + 'Configure Xyte CLI', + 'LaunchConfigureXyteCli', + 'Condition="NOT Installed AND NOT WIX_UPGRADE_DETECTED AND UILevel >= 5"', + '{{FILE_COMPONENTS}}' +]) { + if (!template.includes(expected)) { + throw new Error(`WiX template is missing expected marker: ${expected}`); + } +} + +const windowsDocs = readFileSync(join(repoRoot, 'docs/windows-installer.md'), 'utf8'); +for (const expected of [ + 'The MSI does not replace the Node/npm install path.', + 'npm uninstall -g @xyteai/cli', + 'winget upgrade --id Xyte.XyteCLI --exact', + 'API key is not passed through MSI properties', + 'For an interactive first install, the MSI launches', + '-acceptEula wix7' +]) { + if (!windowsDocs.includes(expected)) { + throw new Error(`Windows installer docs are missing expected guidance: ${expected}`); + } +} + +process.stdout.write('Windows packaging validation passed.\n'); diff --git a/skills/xyte-cli/SKILL.md b/skills/xyte-cli/SKILL.md index b1c069e..0f70315 100644 --- a/skills/xyte-cli/SKILL.md +++ b/skills/xyte-cli/SKILL.md @@ -360,8 +360,8 @@ Schema/version IDs: - inspect deep dive: `xyte.inspect.deep-dive.v1` - report metadata: `xyte.report.v1` - status: `xyte.status.v1` -- upgrade check: `xyte.upgrade.check.v1` -- upgrade result: `xyte.upgrade.result.v1` +- upgrade check: `xyte.upgrade.check.v2` +- upgrade result: `xyte.upgrade.result.v2` - utility batch summary: `xyte.utility.batch.v1` - utility prepare: `xyte.utility.prepare.v1` - watch frame: `xyte.watch.frame.v1` diff --git a/skills/xyte-cli/schemas/upgrade-check.v2.schema.json b/skills/xyte-cli/schemas/upgrade-check.v2.schema.json new file mode 100644 index 0000000..4362af1 --- /dev/null +++ b/skills/xyte-cli/schemas/upgrade-check.v2.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://xyte.dev/schemas/upgrade-check.v2.schema.json", + "title": "Xyte Upgrade Check V2", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "generatedAtUtc", + "packageName", + "installChannel", + "currentVersion", + "latestVersion", + "upToDate", + "recommendedCommand" + ], + "properties": { + "schemaVersion": { + "const": "xyte.upgrade.check.v2" + }, + "generatedAtUtc": { + "type": "string" + }, + "packageName": { + "type": "string" + }, + "installChannel": { + "enum": [ + "npm", + "windows-msi" + ] + }, + "currentVersion": { + "type": "string" + }, + "latestVersion": { + "type": "string" + }, + "upToDate": { + "type": "boolean" + }, + "recommendedCommand": { + "type": [ + "string", + "null" + ] + } + } +} diff --git a/skills/xyte-cli/schemas/upgrade-result.v2.schema.json b/skills/xyte-cli/schemas/upgrade-result.v2.schema.json new file mode 100644 index 0000000..9cd7e2d --- /dev/null +++ b/skills/xyte-cli/schemas/upgrade-result.v2.schema.json @@ -0,0 +1,201 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://xyte.dev/schemas/upgrade-result.v2.schema.json", + "title": "Xyte Upgrade Result V2", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "generatedAtUtc", + "packageName", + "installChannel", + "currentVersion", + "latestVersion", + "upToDateBefore", + "updated", + "verify", + "skills", + "warnings" + ], + "properties": { + "schemaVersion": { + "const": "xyte.upgrade.result.v2" + }, + "generatedAtUtc": { + "type": "string" + }, + "packageName": { + "type": "string" + }, + "installChannel": { + "enum": [ + "npm", + "windows-msi" + ] + }, + "currentVersion": { + "type": "string" + }, + "latestVersion": { + "type": "string" + }, + "upToDateBefore": { + "type": "boolean" + }, + "updated": { + "type": "boolean" + }, + "updateCommand": { + "type": "object", + "additionalProperties": false, + "required": [ + "command", + "args" + ], + "properties": { + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "verify": { + "type": "object", + "additionalProperties": false, + "required": [ + "command", + "detectedVersion", + "expectedVersion", + "match" + ], + "properties": { + "command": { + "type": "object", + "additionalProperties": false, + "required": [ + "command", + "args" + ], + "properties": { + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "detectedVersion": { + "type": "string" + }, + "expectedVersion": { + "type": "string" + }, + "match": { + "type": "boolean" + } + } + }, + "skills": { + "type": "object", + "additionalProperties": false, + "required": [ + "scope", + "agents", + "force", + "sourceDir", + "outcomes", + "failedCount" + ], + "properties": { + "scope": { + "const": "user" + }, + "agents": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "claude", + "copilot", + "codex" + ] + } + }, + "force": { + "const": true + }, + "sourceDir": { + "type": "string" + }, + "failedCount": { + "type": "integer", + "minimum": 0 + }, + "outcomes": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "scope", + "agent", + "rootDir", + "targetDir", + "status" + ], + "properties": { + "scope": { + "type": "string", + "enum": [ + "project", + "user" + ] + }, + "agent": { + "type": "string", + "enum": [ + "claude", + "copilot", + "codex" + ] + }, + "rootDir": { + "type": "string" + }, + "targetDir": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "installed", + "overwritten", + "skipped", + "failed" + ] + }, + "error": { + "type": "string" + } + } + } + } + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 2e9d9e8..c3041ed 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -877,13 +877,13 @@ export function createCli(runtime: CliRuntime = {}): Command { }); const latestVersionOverride = process.env.XYTE_CLI_UPGRADE_TARGET_VERSION?.trim() || undefined; const installSpec = process.env.XYTE_CLI_UPGRADE_SPEC?.trim() || undefined; - const check = await checkForUpgrade( - { packageName: '@xyteai/cli', latestVersionOverride }, - runtime.upgradeDependencies - ); + const loadCheck = () => + checkForUpgrade({ packageName: '@xyteai/cli', latestVersionOverride }, runtime.upgradeDependencies); if (options.check) { + const check = await loadCheck(); if (output === 'text') { stdout.write(`Package: ${check.packageName}\n`); + stdout.write(`Install channel: ${check.installChannel}\n`); stdout.write(`Current: ${check.currentVersion}\n`); stdout.write(`Latest: ${check.latestVersion}\n`); stdout.write(`Up to date: ${check.upToDate}\n`); @@ -913,7 +913,7 @@ export function createCli(runtime: CliRuntime = {}): Command { if (output === 'text') { stdout.write('Upgrade canceled.\n'); } else { - printJson(stdout, check, { strictJson: resolveStrictJson({ settings }) }); + printJson(stdout, await loadCheck(), { strictJson: resolveStrictJson({ settings }) }); } return; } @@ -921,7 +921,7 @@ export function createCli(runtime: CliRuntime = {}): Command { const result = await applyUpgrade( { - packageName: check.packageName, + packageName: '@xyteai/cli', skillSourceDir: resolveSkillSourceDir(), installSpec, latestVersionOverride @@ -931,6 +931,7 @@ export function createCli(runtime: CliRuntime = {}): Command { if (output === 'text') { stdout.write(`Package: ${result.packageName}\n`); + stdout.write(`Install channel: ${result.installChannel}\n`); stdout.write(`Current: ${result.currentVersion}\n`); stdout.write(`Latest: ${result.latestVersion}\n`); stdout.write(`Updated: ${result.updated}\n`); diff --git a/src/cli/upgrade.ts b/src/cli/upgrade.ts index 742e5c2..07e6edf 100644 --- a/src/cli/upgrade.ts +++ b/src/cli/upgrade.ts @@ -5,6 +5,7 @@ import { runProcess } from '../utils/run-command'; import { getCliVersion } from '../utils/version'; import { buildUpgradeCheck, type UpgradeCheckV1, type UpgradeResultV1 } from '../contracts/upgrade'; import { UPGRADE_RESULT_SCHEMA_VERSION } from '../contracts/versions'; +import { detectInstallChannel, WINDOWS_MSI_PACKAGE_ID, type InstallChannel } from '../utils/install-channel'; const DEFAULT_CLI_PACKAGE = '@xyteai/cli'; const DEFAULT_SKILL_AGENTS: SkillAgent[] = ['claude', 'copilot', 'codex']; @@ -22,6 +23,7 @@ export interface UpgradeDependencies { commandRunner?: CommandRunner; installSkillsImpl?: typeof installSkills; getCurrentVersion?: () => string; + getInstallChannel?: () => InstallChannel; npmCommand?: string; } @@ -43,6 +45,31 @@ function parseVersionFromOutput(output: string): string | undefined { return match ? match[0] : undefined; } +function buildRecommendedUpdateCommand(packageName: string, installChannel: InstallChannel): string { + if (installChannel.kind === 'windows-msi') { + return `winget upgrade --id ${installChannel.packageId ?? WINDOWS_MSI_PACKAGE_ID} --exact`; + } + return `npm install --global ${packageName}@latest`; +} + +function buildExecutableUpdateCommand(args: { + installChannel: InstallChannel; + installSpec: string; + npmCommand: string; +}): { command: string; args: string[] } { + if (args.installChannel.kind === 'windows-msi') { + return { + command: 'winget', + args: ['upgrade', '--id', args.installChannel.packageId ?? WINDOWS_MSI_PACKAGE_ID, '--exact'] + }; + } + + return { + command: args.npmCommand, + args: ['install', '--global', args.installSpec] + }; +} + async function fetchLatestVersion(packageName: string, fetchImpl: typeof fetch): Promise { const encodedName = encodeURIComponent(packageName); const response = await fetchImpl(`https://registry.npmjs.org/${encodedName}/latest`, { @@ -70,12 +97,15 @@ export async function checkForUpgrade( const packageName = settings.packageName ?? DEFAULT_CLI_PACKAGE; const fetchImpl = deps.fetchImpl ?? fetch; const currentVersion = (deps.getCurrentVersion ?? getCliVersion)(); + const installChannel = (deps.getInstallChannel ?? detectInstallChannel)(); const latestVersion = typeof settings.latestVersionOverride === 'string' && settings.latestVersionOverride.trim() ? settings.latestVersionOverride.trim() : await fetchLatestVersion(packageName, fetchImpl); return buildUpgradeCheck({ packageName, + installChannel: installChannel.kind, + recommendedCommand: buildRecommendedUpdateCommand(packageName, installChannel), currentVersion, latestVersion }); @@ -88,13 +118,17 @@ export async function applyUpgrade( const packageName = settings.packageName ?? DEFAULT_CLI_PACKAGE; const runner = deps.commandRunner ?? defaultRunner; const installSkillsImpl = deps.installSkillsImpl ?? installSkills; + const installChannel = (deps.getInstallChannel ?? detectInstallChannel)(); const npmCommand = deps.npmCommand ?? (process.platform === 'win32' ? 'npm.cmd' : 'npm'); const check = await checkForUpgrade( { packageName, latestVersionOverride: settings.latestVersionOverride }, - deps + { + ...deps, + getInstallChannel: () => installChannel + } ); const warnings: string[] = []; @@ -103,18 +137,18 @@ export async function applyUpgrade( : typeof settings.latestVersionOverride === 'string' && settings.latestVersionOverride.trim() ? `${packageName}@${settings.latestVersionOverride.trim()}` : `${packageName}@latest`; - const updateArgs = ['install', '--global', installSpec]; let updateCommand: { command: string; args: string[] } | undefined; if (compareSemver(check.currentVersion, check.latestVersion) < 0) { - updateCommand = { - command: npmCommand, - args: updateArgs - }; - const installResult = await runner(npmCommand, updateArgs); + updateCommand = buildExecutableUpdateCommand({ + installChannel, + installSpec, + npmCommand + }); + const installResult = await runner(updateCommand.command, updateCommand.args); if (installResult.code !== 0) { throw new CliUserError({ - summary: `Upgrade failed while running "${npmCommand} ${updateArgs.join(' ')}": ${installResult.stderr.trim() || installResult.stdout.trim() || 'unknown error'}` + summary: `Upgrade failed while running "${updateCommand.command} ${updateCommand.args.join(' ')}": ${installResult.stderr.trim() || installResult.stdout.trim() || 'unknown error'}` }); } } @@ -153,6 +187,7 @@ export async function applyUpgrade( schemaVersion: UPGRADE_RESULT_SCHEMA_VERSION, generatedAtUtc: new Date().toISOString(), packageName, + installChannel: installChannel.kind, currentVersion: check.currentVersion, latestVersion: check.latestVersion, upToDateBefore: check.upToDate, diff --git a/src/contracts/upgrade.ts b/src/contracts/upgrade.ts index 8074eb3..61dc0d0 100644 --- a/src/contracts/upgrade.ts +++ b/src/contracts/upgrade.ts @@ -7,6 +7,7 @@ export const UpgradeCheckSchema = z.object({ schemaVersion: z.literal(UPGRADE_CHECK_SCHEMA_VERSION), generatedAtUtc: z.string(), packageName: z.string(), + installChannel: z.enum(['npm', 'windows-msi']), currentVersion: z.string(), latestVersion: z.string(), upToDate: z.boolean(), @@ -47,6 +48,7 @@ export const UpgradeResultSchema = z.object({ schemaVersion: z.literal(UPGRADE_RESULT_SCHEMA_VERSION), generatedAtUtc: z.string(), packageName: z.string(), + installChannel: z.enum(['npm', 'windows-msi']), currentVersion: z.string(), latestVersion: z.string(), upToDateBefore: z.boolean(), @@ -62,17 +64,26 @@ export type UpgradeResultV1 = z.infer; export function buildUpgradeCheck(args: { packageName: string; + installChannel?: 'npm' | 'windows-msi'; + recommendedCommand?: string; currentVersion: string; latestVersion: string; }): UpgradeCheckV1 { const upToDate = compareSemver(args.currentVersion, args.latestVersion) >= 0; + const installChannel = args.installChannel ?? 'npm'; + const recommendedCommand = + args.recommendedCommand ?? + (installChannel === 'windows-msi' + ? 'winget upgrade --id Xyte.XyteCLI --exact' + : `npm install --global ${args.packageName}@latest`); return { schemaVersion: UPGRADE_CHECK_SCHEMA_VERSION, generatedAtUtc: new Date().toISOString(), packageName: args.packageName, + installChannel, currentVersion: args.currentVersion, latestVersion: args.latestVersion, upToDate, - recommendedCommand: upToDate ? null : `npm install --global ${args.packageName}@latest` + recommendedCommand: upToDate ? null : recommendedCommand }; } diff --git a/src/contracts/versions.ts b/src/contracts/versions.ts index af7f70d..baba3a6 100644 --- a/src/contracts/versions.ts +++ b/src/contracts/versions.ts @@ -8,8 +8,8 @@ export const REPORT_SCHEMA_VERSION = 'xyte.report.v1' as const; export const UTILITY_BATCH_SCHEMA_VERSION = 'xyte.utility.batch.v1' as const; export const UTILITY_PREPARE_SCHEMA_VERSION = 'xyte.utility.prepare.v1' as const; export const STATUS_SCHEMA_VERSION = 'xyte.status.v1' as const; -export const UPGRADE_CHECK_SCHEMA_VERSION = 'xyte.upgrade.check.v1' as const; -export const UPGRADE_RESULT_SCHEMA_VERSION = 'xyte.upgrade.result.v1' as const; +export const UPGRADE_CHECK_SCHEMA_VERSION = 'xyte.upgrade.check.v2' as const; +export const UPGRADE_RESULT_SCHEMA_VERSION = 'xyte.upgrade.result.v2' as const; export const FLOW_RUN_SCHEMA_VERSION = 'xyte.flow.run.v1' as const; export const DEVICE_MOVE_VERIFICATION_SCHEMA_VERSION = 'xyte.device.move-verification.v1' as const; export const FLOW_DEFINITION_SCHEMA_VERSION = 'xyte.flow.definition.v1' as const; diff --git a/src/utils/install-channel.ts b/src/utils/install-channel.ts new file mode 100644 index 0000000..95c4cbc --- /dev/null +++ b/src/utils/install-channel.ts @@ -0,0 +1,83 @@ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +export type InstallChannelKind = 'npm' | 'windows-msi'; + +export const WINDOWS_MSI_PACKAGE_ID = 'Xyte.XyteCLI'; + +export interface InstallChannel { + kind: InstallChannelKind; + packageId?: string; +} + +const DEFAULT_INSTALL_CHANNEL: InstallChannel = { + kind: 'npm' +}; + +function nonBlankString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +function parseInstallChannel(payload: unknown): InstallChannel | undefined { + if (!payload || typeof payload !== 'object') { + return undefined; + } + + const record = payload as Record; + if (record.kind !== 'windows-msi') { + return undefined; + } + + return { + kind: 'windows-msi', + packageId: nonBlankString(record.packageId) + }; +} + +function readInstallChannelFile(filePath: string): InstallChannel | undefined { + try { + return parseInstallChannel(JSON.parse(readFileSync(filePath, 'utf8'))); + } catch { + return undefined; + } +} + +export function detectInstallChannel(startDir: string = __dirname): InstallChannel { + const overrideFile = process.env.XYTE_CLI_INSTALL_CHANNEL_FILE?.trim(); + if (overrideFile) { + const channel = readInstallChannelFile(path.resolve(overrideFile)); + if (channel) { + return channel; + } + } + + if (process.env.XYTE_CLI_INSTALL_CHANNEL?.trim() === 'windows-msi') { + return { + kind: 'windows-msi', + packageId: WINDOWS_MSI_PACKAGE_ID + }; + } + + let current = path.resolve(startDir); + for (let depth = 0; depth < 8; depth += 1) { + const candidate = path.join(current, 'install-channel.json'); + if (existsSync(candidate)) { + const channel = readInstallChannelFile(candidate); + if (channel) { + return channel; + } + } + + const parent = path.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + + return DEFAULT_INSTALL_CHANNEL; +} diff --git a/tests/cli-logging.test.ts b/tests/cli-logging.test.ts index 5d29c87..d8b0a8b 100644 --- a/tests/cli-logging.test.ts +++ b/tests/cli-logging.test.ts @@ -221,7 +221,7 @@ describe('cli action logging', () => { '--format', 'json' ]); - let parsed = JSON.parse(stdout.write.mock.calls.map((call) => String(call[0])).join('')); + const parsed = JSON.parse(stdout.write.mock.calls.map((call) => String(call[0])).join('')); expect(parsed.entry.event).toBe('api.call.complete'); stdout.write.mockClear(); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index a0c7485..c0395b6 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -4058,7 +4058,7 @@ describe('cli integration', () => { const output = stdout.write.mock.calls.map((call) => String(call[0])).join(''); const parsed = JSON.parse(output); - expect(parsed.schemaVersion).toBe('xyte.upgrade.check.v1'); + expect(parsed.schemaVersion).toBe('xyte.upgrade.check.v2'); expect(parsed.currentVersion).toBe('0.4.0'); expect(parsed.latestVersion).toBe('0.5.0'); expect(commandRunner).not.toHaveBeenCalled(); @@ -4214,7 +4214,7 @@ describe('cli integration', () => { const output = stdout.write.mock.calls.map((call) => String(call[0])).join(''); const parsed = JSON.parse(output); - expect(parsed.schemaVersion).toBe('xyte.upgrade.result.v1'); + expect(parsed.schemaVersion).toBe('xyte.upgrade.result.v2'); expect(parsed.updated).toBe(true); expect(parsed.skills.scope).toBe('user'); expect(parsed.skills.failedCount).toBe(1); diff --git a/tests/contracts.test.ts b/tests/contracts.test.ts index c8e2da6..c07c544 100644 --- a/tests/contracts.test.ts +++ b/tests/contracts.test.ts @@ -11,8 +11,10 @@ import flowRunSchema from '../docs/schemas/flow-run.v1.schema.json'; import headlessSchema from '../docs/schemas/headless-frame.v1.schema.json'; import reportSchema from '../docs/schemas/report.v1.schema.json'; import statusSchema from '../docs/schemas/status.v1.schema.json'; -import upgradeCheckSchema from '../docs/schemas/upgrade-check.v1.schema.json'; -import upgradeResultSchema from '../docs/schemas/upgrade-result.v1.schema.json'; +import upgradeCheckSchema from '../docs/schemas/upgrade-check.v2.schema.json'; +import upgradeCheckLegacySchema from '../docs/schemas/upgrade-check.v1.schema.json'; +import upgradeResultSchema from '../docs/schemas/upgrade-result.v2.schema.json'; +import upgradeResultLegacySchema from '../docs/schemas/upgrade-result.v1.schema.json'; import watchFrameSchema from '../docs/schemas/watch-frame.v1.schema.json'; import { buildCallEnvelope } from '../src/contracts/call-envelope'; import { buildFlowRunSummary } from '../src/contracts/flow-run'; @@ -35,7 +37,9 @@ const validateFlowRun = ajv.compile(flowRunSchema); const validateReport = ajv.compile(reportSchema); const validateStatus = ajv.compile(statusSchema); const validateUpgradeCheck = ajv.compile(upgradeCheckSchema); +const validateUpgradeCheckLegacy = ajv.compile(upgradeCheckLegacySchema); const validateUpgradeResult = ajv.compile(upgradeResultSchema); +const validateUpgradeResultLegacy = ajv.compile(upgradeResultLegacySchema); const validateWatchFrame = ajv.compile(watchFrameSchema); const validateDoctorEnvironment = ajv.compile(doctorEnvironmentSchema); @@ -311,9 +315,10 @@ describe('schema contracts', () => { }); const upgradeResult = { - schemaVersion: 'xyte.upgrade.result.v1', + schemaVersion: 'xyte.upgrade.result.v2', generatedAtUtc: new Date().toISOString(), packageName: '@xyteai/cli', + installChannel: 'npm', currentVersion: '0.4.0', latestVersion: '0.4.1', upToDateBefore: false, @@ -353,6 +358,14 @@ describe('schema contracts', () => { expect(validateStatus(status)).toBe(true); expect(validateUpgradeCheck(upgradeCheck)).toBe(true); expect(validateUpgradeResult(upgradeResult)).toBe(true); + + const legacyUpgradeCheck: Record = { ...upgradeCheck, schemaVersion: 'xyte.upgrade.check.v1' }; + delete legacyUpgradeCheck.installChannel; + const legacyUpgradeResult: Record = { ...upgradeResult, schemaVersion: 'xyte.upgrade.result.v1' }; + delete legacyUpgradeResult.installChannel; + + expect(validateUpgradeCheckLegacy(legacyUpgradeCheck)).toBe(true); + expect(validateUpgradeResultLegacy(legacyUpgradeResult)).toBe(true); }); it('validates watch frame payload', () => { diff --git a/tests/fixtures/golden/upgrade-check.json b/tests/fixtures/golden/upgrade-check.json index 8a6765f..1970d0f 100644 --- a/tests/fixtures/golden/upgrade-check.json +++ b/tests/fixtures/golden/upgrade-check.json @@ -1,7 +1,8 @@ { - "schemaVersion": "xyte.upgrade.check.v1", + "schemaVersion": "xyte.upgrade.check.v2", "generatedAtUtc": "", "packageName": "@xyteai/cli", + "installChannel": "npm", "currentVersion": "0.4.0", "latestVersion": "0.4.1", "upToDate": false, diff --git a/tests/install-channel.test.ts b/tests/install-channel.test.ts new file mode 100644 index 0000000..f043e48 --- /dev/null +++ b/tests/install-channel.test.ts @@ -0,0 +1,76 @@ +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { detectInstallChannel } from '../src/utils/install-channel'; + +const previousChannel = process.env.XYTE_CLI_INSTALL_CHANNEL; +const previousChannelFile = process.env.XYTE_CLI_INSTALL_CHANNEL_FILE; + +afterEach(() => { + if (previousChannel === undefined) { + delete process.env.XYTE_CLI_INSTALL_CHANNEL; + } else { + process.env.XYTE_CLI_INSTALL_CHANNEL = previousChannel; + } + if (previousChannelFile === undefined) { + delete process.env.XYTE_CLI_INSTALL_CHANNEL_FILE; + } else { + process.env.XYTE_CLI_INSTALL_CHANNEL_FILE = previousChannelFile; + } +}); + +describe('install channel detection', () => { + it('defaults to npm', () => { + delete process.env.XYTE_CLI_INSTALL_CHANNEL; + delete process.env.XYTE_CLI_INSTALL_CHANNEL_FILE; + + expect(detectInstallChannel('/tmp/no-channel-here')).toEqual({ + kind: 'npm' + }); + }); + + it('detects Windows MSI channel from install-channel.json', () => { + delete process.env.XYTE_CLI_INSTALL_CHANNEL; + delete process.env.XYTE_CLI_INSTALL_CHANNEL_FILE; + + const root = mkdtempSync(join(tmpdir(), 'xyte-install-channel-')); + const nested = join(root, 'dist', 'utils'); + mkdirSync(nested, { recursive: true }); + writeFileSync( + join(root, 'install-channel.json'), + JSON.stringify({ + kind: 'windows-msi', + packageId: 'Xyte.XyteCLI' + }) + ); + + expect(detectInstallChannel(nested)).toEqual({ + kind: 'windows-msi', + packageId: 'Xyte.XyteCLI' + }); + }); + + it('treats a blank packageId as absent', () => { + delete process.env.XYTE_CLI_INSTALL_CHANNEL; + delete process.env.XYTE_CLI_INSTALL_CHANNEL_FILE; + + const root = mkdtempSync(join(tmpdir(), 'xyte-install-channel-')); + const nested = join(root, 'dist', 'utils'); + mkdirSync(nested, { recursive: true }); + writeFileSync( + join(root, 'install-channel.json'), + JSON.stringify({ + kind: 'windows-msi', + packageId: ' ' + }) + ); + + expect(detectInstallChannel(nested)).toEqual({ + kind: 'windows-msi', + packageId: undefined + }); + }); +}); diff --git a/tests/upgrade.test.ts b/tests/upgrade.test.ts index 7c7be16..28015fe 100644 --- a/tests/upgrade.test.ts +++ b/tests/upgrade.test.ts @@ -22,15 +22,97 @@ describe('upgrade utilities', () => { }, { fetchImpl: fetchImpl as any, - getCurrentVersion: () => '0.4.0' + getCurrentVersion: () => '0.4.0', + getInstallChannel: () => ({ kind: 'npm' }) } ); expect(fetchImpl).not.toHaveBeenCalled(); + expect(result.installChannel).toBe('npm'); expect(result.latestVersion).toBe('0.5.0'); expect(result.upToDate).toBe(false); }); + it('recommends winget updates for Windows MSI installs, defaulting the package id', async () => { + const result = await checkForUpgrade( + { + packageName: '@xyteai/cli', + latestVersionOverride: '0.5.0' + }, + { + getCurrentVersion: () => '0.4.0', + getInstallChannel: () => ({ kind: 'windows-msi' }) + } + ); + + expect(result.installChannel).toBe('windows-msi'); + expect(result.recommendedCommand).toBe('winget upgrade --id Xyte.XyteCLI --exact'); + }); + + it('derives winget recommendations from a custom package id', async () => { + const result = await checkForUpgrade( + { + packageName: '@xyteai/cli', + latestVersionOverride: '0.5.0' + }, + { + getCurrentVersion: () => '0.4.0', + getInstallChannel: () => ({ + kind: 'windows-msi', + packageId: 'Xyte.CustomCLI' + }) + } + ); + + expect(result.recommendedCommand).toBe('winget upgrade --id Xyte.CustomCLI --exact'); + }); + + it('detects install channel once when applying an upgrade', async () => { + const getInstallChannel = vi.fn(() => ({ + kind: 'npm' as const + })); + const commandRunner = vi.fn(async (command: string) => { + if (/^npm(?:\.cmd)?$/.test(command)) { + return { + code: 0, + stdout: '', + stderr: '' + }; + } + if (/^xyte-cli(?:\.cmd)?$/.test(command)) { + return { + code: 0, + stdout: 'xyte-cli 0.5.0\n', + stderr: '' + }; + } + throw new Error(`Unexpected command: ${command}`); + }); + + await applyUpgrade( + { + packageName: '@xyteai/cli', + skillSourceDir: '/repo/skills/xyte-cli', + latestVersionOverride: '0.5.0' + }, + { + fetchImpl: vi.fn() as any, + commandRunner, + getCurrentVersion: () => '0.4.0', + getInstallChannel, + installSkillsImpl: vi.fn().mockResolvedValue({ + workspaceRoot: '/tmp/workspace', + homeRoot: '/tmp/home', + sourceDir: '/repo/skills/xyte-cli', + outcomes: [], + createdRoots: [] + }) + } + ); + + expect(getInstallChannel).toHaveBeenCalledTimes(1); + }); + it('applies upgrade using install spec and emits skill warning on partial failure', async () => { const commandRunner = vi.fn(async (command: string, args: string[]) => { if (/^npm(?:\.cmd)?$/.test(command)) { @@ -62,6 +144,7 @@ describe('upgrade utilities', () => { fetchImpl: vi.fn() as any, commandRunner, getCurrentVersion: () => '0.4.0', + getInstallChannel: () => ({ kind: 'npm' }), installSkillsImpl: vi.fn().mockResolvedValue({ workspaceRoot: '/tmp/workspace', homeRoot: '/tmp/home', @@ -89,6 +172,7 @@ describe('upgrade utilities', () => { ); expect(result.updated).toBe(true); + expect(result.installChannel).toBe('npm'); expect(result.verify.match).toBe(true); expect(result.skills.scope).toBe('user'); expect(result.skills.failedCount).toBe(1); @@ -125,6 +209,7 @@ describe('upgrade utilities', () => { fetchImpl: vi.fn() as any, commandRunner, getCurrentVersion: () => '0.5.0', + getInstallChannel: () => ({ kind: 'npm' }), installSkillsImpl: vi.fn().mockResolvedValue({ workspaceRoot: '/tmp/workspace', homeRoot: '/tmp/home', @@ -138,4 +223,56 @@ describe('upgrade utilities', () => { expect(result.updated).toBe(true); expect(result.updateCommand?.args).toEqual(['install', '--global', '@xyteai/cli@0.6.0']); }); + + it('applies Windows MSI upgrades through winget', async () => { + const commandRunner = vi.fn(async (command: string, args: string[]) => { + if (command === 'winget') { + expect(args).toEqual(['upgrade', '--id', 'Xyte.XyteCLI', '--exact']); + return { + code: 0, + stdout: '', + stderr: '' + }; + } + if (/^xyte-cli(?:\.cmd)?$/.test(command)) { + return { + code: 0, + stdout: 'xyte-cli 0.7.0\n', + stderr: '' + }; + } + throw new Error(`Unexpected command: ${command}`); + }); + + const result = await applyUpgrade( + { + packageName: '@xyteai/cli', + skillSourceDir: '/repo/skills/xyte-cli', + latestVersionOverride: '0.7.0' + }, + { + fetchImpl: vi.fn() as any, + commandRunner, + getCurrentVersion: () => '0.6.0', + getInstallChannel: () => ({ + kind: 'windows-msi', + packageId: 'Xyte.XyteCLI' + }), + installSkillsImpl: vi.fn().mockResolvedValue({ + workspaceRoot: '/tmp/workspace', + homeRoot: '/tmp/home', + sourceDir: '/repo/skills/xyte-cli', + outcomes: [], + createdRoots: [] + }) + } + ); + + expect(result.installChannel).toBe('windows-msi'); + expect(result.updated).toBe(true); + expect(result.updateCommand).toEqual({ + command: 'winget', + args: ['upgrade', '--id', 'Xyte.XyteCLI', '--exact'] + }); + }); }); diff --git a/tests/windows-packaging.test.ts b/tests/windows-packaging.test.ts new file mode 100644 index 0000000..4f344cc --- /dev/null +++ b/tests/windows-packaging.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { findExpectedSha256, parseArgs, validateArgs } from '../scripts/package_windows_msi.mjs'; + +describe('windows packaging argument parsing', () => { + it('parses flags and values', () => { + const args = parseArgs(['--out-dir', '/tmp/out', '--node-version', 'v22.1.0', '--skip-build']); + expect(args.outDir.endsWith('out')).toBe(true); + expect(args.nodeVersion).toBe('22.1.0'); + expect(args.skipBuild).toBe(true); + expect(args.skipMsi).toBe(false); + }); + + it('rejects a value flag with no value', () => { + expect(() => parseArgs(['--out-dir'])).toThrow('--out-dir requires a value.'); + expect(() => parseArgs(['--node-version', '--skip-msi'])).toThrow('--node-version requires a value.'); + }); + + it('rejects unknown arguments', () => { + expect(() => parseArgs(['--bogus'])).toThrow('Unknown argument: --bogus'); + }); + + it('rejects payload skip flags on real MSI builds', () => { + expect(() => validateArgs(parseArgs(['--skip-node']))).toThrow('--skip-node is only valid with --skip-msi'); + expect(() => validateArgs(parseArgs(['--skip-npm-install']))).toThrow( + '--skip-npm-install is only valid with --skip-msi' + ); + expect(() => validateArgs(parseArgs(['--skip-node', '--skip-npm-install', '--skip-msi']))).not.toThrow(); + }); +}); + +describe('windows packaging checksum parsing', () => { + const shasums = [ + 'a'.repeat(64) + ' node-v22.1.0-win-x64.zip', + 'b'.repeat(64) + ' node-v22.1.0-win-x86.zip', + 'not-a-hash node-v22.1.0-win-arm64.zip', + '' + ].join('\n'); + + it('finds the checksum for the exact file name', () => { + expect(findExpectedSha256(shasums, 'node-v22.1.0-win-x64.zip')).toBe('A'.repeat(64)); + }); + + it('ignores malformed hash lines', () => { + expect(findExpectedSha256(shasums, 'node-v22.1.0-win-arm64.zip')).toBeUndefined(); + }); + + it('returns undefined when the file is missing', () => { + expect(findExpectedSha256(shasums, 'node-v99.0.0-win-x64.zip')).toBeUndefined(); + }); +});