From 8dd3dc5e48ba49487578b042e609b80ce3586bbc Mon Sep 17 00:00:00 2001 From: Vladimir Porton Date: Wed, 1 Jul 2026 20:07:07 +0300 Subject: [PATCH 01/19] Add Windows MSI installer pipeline --- .github/workflows/ci.yml | 41 ++ .github/workflows/release-assets.yml | 72 +++- README.md | 5 +- docs/commands.md | 3 + docs/getting-started.md | 4 +- docs/reference/commands.html | 1 + docs/release.md | 13 + docs/schemas/upgrade-check.v1.schema.json | 7 + docs/schemas/upgrade-result.v1.schema.json | 7 + docs/windows-installer.md | 150 +++++++ package.json | 2 + packaging/windows/Product.wxs.template | 74 ++++ .../windows/scripts/configure-xyte-cli.ps1 | 110 +++++ scripts/package_windows_msi.mjs | 386 ++++++++++++++++++ scripts/sign_windows_msi.ps1 | 51 +++ scripts/validate_windows_packaging.mjs | 75 ++++ .../schemas/upgrade-check.v1.schema.json | 7 + .../schemas/upgrade-result.v1.schema.json | 7 + src/cli/index.ts | 2 + src/cli/upgrade.ts | 19 +- src/contracts/upgrade.ts | 13 +- src/utils/install-channel.ts | 82 ++++ tests/cli-logging.test.ts | 2 +- tests/contracts.test.ts | 1 + tests/fixtures/golden/upgrade-check.json | 1 + tests/install-channel.test.ts | 58 +++ tests/upgrade.test.ts | 80 +++- 27 files changed, 1262 insertions(+), 11 deletions(-) create mode 100644 docs/windows-installer.md create mode 100644 packaging/windows/Product.wxs.template create mode 100644 packaging/windows/scripts/configure-xyte-cli.ps1 create mode 100644 scripts/package_windows_msi.mjs create mode 100644 scripts/sign_windows_msi.ps1 create mode 100644 scripts/validate_windows_packaging.mjs create mode 100644 src/utils/install-channel.ts create mode 100644 tests/install-channel.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53039f1..bca9c40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,6 +89,47 @@ 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: 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 + 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..e526c86 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -76,7 +76,7 @@ jobs: run: npm run smoke:pack-install release: - needs: [meta, packaged-install-smoke] + needs: [meta, packaged-install-smoke, windows-msi] runs-on: ubuntu-latest steps: - name: Checkout tag @@ -112,6 +112,12 @@ jobs: npm run build npm pack + - name: Download Windows MSI artifact + uses: actions/download-artifact@v4 + with: + name: windows-msi + path: windows-installer + - name: Generate SBOM run: npx --yes @cyclonedx/cyclonedx-npm --output-file sbom.cdx.json --omit dev @@ -119,7 +125,7 @@ jobs: shell: bash run: | set -euo pipefail - sha256sum *.tgz sbom.cdx.json > checksums.txt + sha256sum *.tgz sbom.cdx.json windows-installer/*.msi windows-installer/winget/*.yaml > checksums.txt - name: Publish GitHub release assets uses: softprops/action-gh-release@v2 @@ -128,5 +134,67 @@ jobs: generate_release_notes: true files: | *.tgz + windows-installer/*.msi + windows-installer/windows-installer-manifest.json + windows-installer/winget/*.yaml sbom.cdx.json checksums.txt + + windows-msi: + needs: meta + 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: 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 diff --git a/README.md b/README.md index c10cafb..a948875 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) @@ -78,7 +79,9 @@ For reproducible pipelines, replace `@latest` with a pinned version (e.g. `@0.10 ### Manual terminal -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`). +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 -e --id 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 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..2e6d8e5 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -16,7 +16,7 @@ If `node --version` is missing or below 22: brew install node@22 # Windows -winget install OpenJS.NodeJS.LTS +winget install -e --id OpenJS.NodeJS.LTS ``` Other platforms: download from [nodejs.org](https://nodejs.org/en/download). @@ -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/release.md b/docs/release.md index a74a8a6..4a36132 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,17 @@ 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`. + +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.v1.schema.json b/docs/schemas/upgrade-check.v1.schema.json index 09fc62c..8d00d63 100644 --- a/docs/schemas/upgrade-check.v1.schema.json +++ b/docs/schemas/upgrade-check.v1.schema.json @@ -8,6 +8,7 @@ "schemaVersion", "generatedAtUtc", "packageName", + "installChannel", "currentVersion", "latestVersion", "upToDate", @@ -23,6 +24,12 @@ "packageName": { "type": "string" }, + "installChannel": { + "enum": [ + "npm", + "windows-msi" + ] + }, "currentVersion": { "type": "string" }, diff --git a/docs/schemas/upgrade-result.v1.schema.json b/docs/schemas/upgrade-result.v1.schema.json index de8276b..98457a9 100644 --- a/docs/schemas/upgrade-result.v1.schema.json +++ b/docs/schemas/upgrade-result.v1.schema.json @@ -8,6 +8,7 @@ "schemaVersion", "generatedAtUtc", "packageName", + "installChannel", "currentVersion", "latestVersion", "upToDateBefore", @@ -26,6 +27,12 @@ "packageName": { "type": "string" }, + "installChannel": { + "enum": [ + "npm", + "windows-msi" + ] + }, "currentVersion": { "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..0167354 --- /dev/null +++ b/packaging/windows/scripts/configure-xyte-cli.ps1 @@ -0,0 +1,110 @@ +param( + [string]$Tenant, + [string]$KeyFile, + [switch]$AssumeYes, + [switch]$SkipApiKeySetup, + [switch]$SkipNpmMigration, + [switch]$NonInteractive +) + +$ErrorActionPreference = "Stop" + +function Write-Step { + param([string]$Message) + Write-Host "" + Write-Host "== $Message ==" +} + +function Invoke-XyteCli { + param([string[]]$Arguments) + & $script:XyteCli @Arguments +} + +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 Source -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.Source)" + } +} + +if (!$SkipNpmMigration) { + Write-Step "Check previous npm global install" + $npm = Get-Command "npm.cmd" -ErrorAction SilentlyContinue + if ($npm) { + $npmListOutput = & $npm.Source list -g @xyteai/cli --depth=0 2>$null + $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?") { + & $npm.Source 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" +try { + Invoke-XyteCli @("setup", "status", "--field", "tenantId") +} catch { + Write-Host "No connected tenant was confirmed. Run this assistant again or run: xyte-cli setup run" +} +Write-Host "" +Write-Host "Xyte CLI Windows setup is complete." diff --git a/scripts/package_windows_msi.mjs b/scripts/package_windows_msi.mjs new file mode 100644 index 0000000..9648c58 --- /dev/null +++ b/scripts/package_windows_msi.mjs @@ -0,0 +1,386 @@ +#!/usr/bin/env node + +import { createHash } from 'node:crypto'; +import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } 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 + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--out-dir') args.outDir = resolve(argv[++i]); + else if (arg === '--node-version') args.nodeVersion = argv[++i]?.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 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 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 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`; + + if (!existsSync(zipPath)) { + await runOrThrow( + process.platform === 'win32' ? 'curl.exe' : 'curl', + ['--fail', '--location', '--retry', '3', '--connect-timeout', '20', '--max-time', '300', '--output', zipPath, nodeUrl], + 'Download Node.js Windows runtime' + ); + } + + 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', + updateCommand: 'winget upgrade --id Xyte.XyteCLI --exact', + releaseUrl: 'https://github.com/xyte-io/xyte-cli/releases/latest' + }, + 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)); + 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, + wxsPath, + msiPath: args.skipMsi ? null : msiPath, + 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`); +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack || error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/scripts/sign_windows_msi.ps1 b/scripts/sign_windows_msi.ps1 new file mode 100644 index 0000000..2f291e6 --- /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 = "http://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/validate_windows_packaging.mjs b/scripts/validate_windows_packaging.mjs new file mode 100644 index 0000000..fe12e47 --- /dev/null +++ b/scripts/validate_windows_packaging.mjs @@ -0,0 +1,75 @@ +#!/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' +]) { + 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', + "kind: 'windows-msi'", + 'winget upgrade --id Xyte.XyteCLI --exact' +]) { + if (!packageScript.includes(expected)) { + throw new Error(`Windows packaging script is missing expected 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/schemas/upgrade-check.v1.schema.json b/skills/xyte-cli/schemas/upgrade-check.v1.schema.json index 09fc62c..8d00d63 100644 --- a/skills/xyte-cli/schemas/upgrade-check.v1.schema.json +++ b/skills/xyte-cli/schemas/upgrade-check.v1.schema.json @@ -8,6 +8,7 @@ "schemaVersion", "generatedAtUtc", "packageName", + "installChannel", "currentVersion", "latestVersion", "upToDate", @@ -23,6 +24,12 @@ "packageName": { "type": "string" }, + "installChannel": { + "enum": [ + "npm", + "windows-msi" + ] + }, "currentVersion": { "type": "string" }, diff --git a/skills/xyte-cli/schemas/upgrade-result.v1.schema.json b/skills/xyte-cli/schemas/upgrade-result.v1.schema.json index de8276b..98457a9 100644 --- a/skills/xyte-cli/schemas/upgrade-result.v1.schema.json +++ b/skills/xyte-cli/schemas/upgrade-result.v1.schema.json @@ -8,6 +8,7 @@ "schemaVersion", "generatedAtUtc", "packageName", + "installChannel", "currentVersion", "latestVersion", "upToDateBefore", @@ -26,6 +27,12 @@ "packageName": { "type": "string" }, + "installChannel": { + "enum": [ + "npm", + "windows-msi" + ] + }, "currentVersion": { "type": "string" }, diff --git a/src/cli/index.ts b/src/cli/index.ts index 2e9d9e8..623efb2 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -884,6 +884,7 @@ export function createCli(runtime: CliRuntime = {}): Command { if (options.check) { 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`); @@ -931,6 +932,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..20ff873 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, 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; } @@ -70,12 +72,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: installChannel.updateCommand, currentVersion, latestVersion }); @@ -88,6 +93,7 @@ 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( { @@ -103,18 +109,22 @@ export async function applyUpgrade( : typeof settings.latestVersionOverride === 'string' && settings.latestVersionOverride.trim() ? `${packageName}@${settings.latestVersionOverride.trim()}` : `${packageName}@latest`; - const updateArgs = ['install', '--global', installSpec]; + const updateArgs = + installChannel.kind === 'windows-msi' + ? ['upgrade', '--id', installChannel.packageId ?? 'Xyte.XyteCLI', '--exact'] + : ['install', '--global', installSpec]; let updateCommand: { command: string; args: string[] } | undefined; if (compareSemver(check.currentVersion, check.latestVersion) < 0) { + const command = installChannel.kind === 'windows-msi' ? 'winget' : npmCommand; updateCommand = { - command: npmCommand, + command, args: updateArgs }; - const installResult = await runner(npmCommand, updateArgs); + const installResult = await runner(command, updateArgs); 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 "${command} ${updateArgs.join(' ')}": ${installResult.stderr.trim() || installResult.stdout.trim() || 'unknown error'}` }); } } @@ -153,6 +163,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/utils/install-channel.ts b/src/utils/install-channel.ts new file mode 100644 index 0000000..ef81b50 --- /dev/null +++ b/src/utils/install-channel.ts @@ -0,0 +1,82 @@ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +export type InstallChannelKind = 'npm' | 'windows-msi'; + +export interface InstallChannel { + kind: InstallChannelKind; + updateCommand: string; + packageId?: string; + releaseUrl?: string; +} + +const DEFAULT_INSTALL_CHANNEL: InstallChannel = { + kind: 'npm', + updateCommand: 'npm install --global @xyteai/cli@latest' +}; + +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', + updateCommand: + typeof record.updateCommand === 'string' && record.updateCommand.trim() + ? record.updateCommand.trim() + : 'winget upgrade --id Xyte.XyteCLI --exact', + packageId: typeof record.packageId === 'string' ? record.packageId : undefined, + releaseUrl: typeof record.releaseUrl === 'string' ? record.releaseUrl : undefined + }; +} + +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', + updateCommand: 'winget upgrade --id Xyte.XyteCLI --exact', + packageId: 'Xyte.XyteCLI' + }; + } + + 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/contracts.test.ts b/tests/contracts.test.ts index c8e2da6..4c79cf2 100644 --- a/tests/contracts.test.ts +++ b/tests/contracts.test.ts @@ -314,6 +314,7 @@ describe('schema contracts', () => { schemaVersion: 'xyte.upgrade.result.v1', generatedAtUtc: new Date().toISOString(), packageName: '@xyteai/cli', + installChannel: 'npm', currentVersion: '0.4.0', latestVersion: '0.4.1', upToDateBefore: false, diff --git a/tests/fixtures/golden/upgrade-check.json b/tests/fixtures/golden/upgrade-check.json index 8a6765f..093ceb5 100644 --- a/tests/fixtures/golden/upgrade-check.json +++ b/tests/fixtures/golden/upgrade-check.json @@ -2,6 +2,7 @@ "schemaVersion": "xyte.upgrade.check.v1", "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..93090b5 --- /dev/null +++ b/tests/install-channel.test.ts @@ -0,0 +1,58 @@ +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', + updateCommand: 'npm install --global @xyteai/cli@latest' + }); + }); + + 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', + updateCommand: 'winget upgrade --id Xyte.XyteCLI --exact' + }) + ); + + expect(detectInstallChannel(nested)).toMatchObject({ + kind: 'windows-msi', + packageId: 'Xyte.XyteCLI', + updateCommand: 'winget upgrade --id Xyte.XyteCLI --exact' + }); + }); +}); diff --git a/tests/upgrade.test.ts b/tests/upgrade.test.ts index 7c7be16..ba8846e 100644 --- a/tests/upgrade.test.ts +++ b/tests/upgrade.test.ts @@ -22,15 +22,37 @@ describe('upgrade utilities', () => { }, { fetchImpl: fetchImpl as any, - getCurrentVersion: () => '0.4.0' + getCurrentVersion: () => '0.4.0', + getInstallChannel: () => ({ kind: 'npm', updateCommand: 'npm install --global @xyteai/cli@latest' }) } ); 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', async () => { + const result = await checkForUpgrade( + { + packageName: '@xyteai/cli', + latestVersionOverride: '0.5.0' + }, + { + getCurrentVersion: () => '0.4.0', + getInstallChannel: () => ({ + kind: 'windows-msi', + updateCommand: 'winget upgrade --id Xyte.XyteCLI --exact', + packageId: 'Xyte.XyteCLI' + }) + } + ); + + expect(result.installChannel).toBe('windows-msi'); + expect(result.recommendedCommand).toBe('winget upgrade --id Xyte.XyteCLI --exact'); + }); + 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 +84,7 @@ describe('upgrade utilities', () => { fetchImpl: vi.fn() as any, commandRunner, getCurrentVersion: () => '0.4.0', + getInstallChannel: () => ({ kind: 'npm', updateCommand: 'npm install --global @xyteai/cli@latest' }), installSkillsImpl: vi.fn().mockResolvedValue({ workspaceRoot: '/tmp/workspace', homeRoot: '/tmp/home', @@ -89,6 +112,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 +149,7 @@ describe('upgrade utilities', () => { fetchImpl: vi.fn() as any, commandRunner, getCurrentVersion: () => '0.5.0', + getInstallChannel: () => ({ kind: 'npm', updateCommand: 'npm install --global @xyteai/cli@latest' }), installSkillsImpl: vi.fn().mockResolvedValue({ workspaceRoot: '/tmp/workspace', homeRoot: '/tmp/home', @@ -138,4 +163,57 @@ 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', + updateCommand: 'winget upgrade --id Xyte.XyteCLI --exact', + 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'] + }); + }); }); From 7093c2e1d6295c20eb07d36079c6fade5f289081 Mon Sep 17 00:00:00 2001 From: Vladimir Porton Date: Wed, 1 Jul 2026 20:30:16 +0300 Subject: [PATCH 02/19] Address Windows installer review comments --- scripts/package_windows_msi.mjs | 12 ++++++++---- scripts/sign_windows_msi.ps1 | 2 +- src/utils/install-channel.ts | 17 +++++++++++------ tests/install-channel.test.ts | 25 +++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 11 deletions(-) diff --git a/scripts/package_windows_msi.mjs b/scripts/package_windows_msi.mjs index 9648c58..0bac2b9 100644 --- a/scripts/package_windows_msi.mjs +++ b/scripts/package_windows_msi.mjs @@ -76,6 +76,10 @@ function sha256File(filePath) { return createHash('sha256').update(readFileSync(filePath)).digest('hex').toUpperCase(); } +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.'); @@ -371,10 +375,10 @@ async function main() { packageVersion: packageJson.version, nodeVersion: args.skipNode ? null : args.nodeVersion, wixEulaId, - payloadDir, - wxsPath, - msiPath: args.skipMsi ? null : msiPath, - wingetDir + 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`); diff --git a/scripts/sign_windows_msi.ps1 b/scripts/sign_windows_msi.ps1 index 2f291e6..f5d7b3e 100644 --- a/scripts/sign_windows_msi.ps1 +++ b/scripts/sign_windows_msi.ps1 @@ -4,7 +4,7 @@ param( [string]$CertificateBase64 = $env:WINDOWS_CODESIGN_PFX_BASE64, [string]$CertificatePassword = $env:WINDOWS_CODESIGN_PFX_PASSWORD, - [string]$TimestampUrl = "http://timestamp.digicert.com" + [string]$TimestampUrl = "https://timestamp.digicert.com" ) $ErrorActionPreference = "Stop" diff --git a/src/utils/install-channel.ts b/src/utils/install-channel.ts index ef81b50..7d31b17 100644 --- a/src/utils/install-channel.ts +++ b/src/utils/install-channel.ts @@ -15,6 +15,14 @@ const DEFAULT_INSTALL_CHANNEL: InstallChannel = { updateCommand: 'npm install --global @xyteai/cli@latest' }; +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; @@ -27,12 +35,9 @@ function parseInstallChannel(payload: unknown): InstallChannel | undefined { return { kind: 'windows-msi', - updateCommand: - typeof record.updateCommand === 'string' && record.updateCommand.trim() - ? record.updateCommand.trim() - : 'winget upgrade --id Xyte.XyteCLI --exact', - packageId: typeof record.packageId === 'string' ? record.packageId : undefined, - releaseUrl: typeof record.releaseUrl === 'string' ? record.releaseUrl : undefined + updateCommand: nonBlankString(record.updateCommand) ?? 'winget upgrade --id Xyte.XyteCLI --exact', + packageId: nonBlankString(record.packageId), + releaseUrl: nonBlankString(record.releaseUrl) }; } diff --git a/tests/install-channel.test.ts b/tests/install-channel.test.ts index 93090b5..c99cff8 100644 --- a/tests/install-channel.test.ts +++ b/tests/install-channel.test.ts @@ -55,4 +55,29 @@ describe('install channel detection', () => { updateCommand: 'winget upgrade --id Xyte.XyteCLI --exact' }); }); + + it('trims optional Windows MSI channel metadata and ignores blanks', () => { + 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: ' ', + releaseUrl: ' ', + updateCommand: ' winget upgrade --id Xyte.XyteCLI --exact ' + }) + ); + + expect(detectInstallChannel(nested)).toEqual({ + kind: 'windows-msi', + updateCommand: 'winget upgrade --id Xyte.XyteCLI --exact', + packageId: undefined, + releaseUrl: undefined + }); + }); }); From 8505a349a4dd2e66b0e4d2bfb3e894a72feefb34 Mon Sep 17 00:00:00 2001 From: Vladimir Porton Date: Wed, 1 Jul 2026 21:08:56 +0300 Subject: [PATCH 03/19] Address installer release review findings --- .github/workflows/release-assets.yml | 32 +++++---- README.md | 2 +- docs/getting-started.md | 2 +- docs/release.md | 2 + docs/schemas/upgrade-check.v1.schema.json | 1 - docs/schemas/upgrade-result.v1.schema.json | 1 - .../windows/scripts/configure-xyte-cli.ps1 | 23 +++++-- scripts/package_windows_msi.mjs | 38 +++++++++++ scripts/validate_windows_packaging.mjs | 25 ++++++- .../schemas/upgrade-check.v1.schema.json | 1 - .../schemas/upgrade-result.v1.schema.json | 1 - src/cli/upgrade.ts | 50 ++++++++++---- src/contracts/upgrade.ts | 4 +- tests/cli-logging.test.ts | 2 +- tests/contracts.test.ts | 8 +++ tests/upgrade.test.ts | 66 +++++++++++++++++++ 16 files changed, 219 insertions(+), 39 deletions(-) diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index e526c86..c20b3c4 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -76,7 +76,7 @@ jobs: run: npm run smoke:pack-install release: - needs: [meta, packaged-install-smoke, windows-msi] + needs: [meta, packaged-install-smoke] runs-on: ubuntu-latest steps: - name: Checkout tag @@ -112,12 +112,6 @@ jobs: npm run build npm pack - - name: Download Windows MSI artifact - uses: actions/download-artifact@v4 - with: - name: windows-msi - path: windows-installer - - name: Generate SBOM run: npx --yes @cyclonedx/cyclonedx-npm --output-file sbom.cdx.json --omit dev @@ -125,7 +119,7 @@ jobs: shell: bash run: | set -euo pipefail - sha256sum *.tgz sbom.cdx.json windows-installer/*.msi windows-installer/winget/*.yaml > checksums.txt + sha256sum *.tgz sbom.cdx.json > checksums.txt - name: Publish GitHub release assets uses: softprops/action-gh-release@v2 @@ -134,9 +128,6 @@ jobs: generate_release_notes: true files: | *.tgz - windows-installer/*.msi - windows-installer/windows-installer-manifest.json - windows-installer/winget/*.yaml sbom.cdx.json checksums.txt @@ -190,6 +181,13 @@ jobs: 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: @@ -198,3 +196,15 @@ jobs: 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 }} + generate_release_notes: true + files: | + artifacts/windows-installer/*.msi + artifacts/windows-installer/windows-installer-manifest.json + artifacts/windows-installer/winget/*.yaml + artifacts/windows-installer/windows-checksums.txt diff --git a/README.md b/README.md index a948875..70ebcc5 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ For reproducible pipelines, replace `@latest` with a pinned version (e.g. `@0.10 ### Manual terminal -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 -e --id OpenJS.NodeJS.LTS`). +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). diff --git a/docs/getting-started.md b/docs/getting-started.md index 2e6d8e5..f650ec1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -16,7 +16,7 @@ If `node --version` is missing or below 22: brew install node@22 # Windows -winget install -e --id OpenJS.NodeJS.LTS +winget install OpenJS.NodeJS.LTS ``` Other platforms: download from [nodejs.org](https://nodejs.org/en/download). diff --git a/docs/release.md b/docs/release.md index 4a36132..726591c 100644 --- a/docs/release.md +++ b/docs/release.md @@ -84,6 +84,8 @@ Prerequisites: 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. diff --git a/docs/schemas/upgrade-check.v1.schema.json b/docs/schemas/upgrade-check.v1.schema.json index 8d00d63..089cc95 100644 --- a/docs/schemas/upgrade-check.v1.schema.json +++ b/docs/schemas/upgrade-check.v1.schema.json @@ -8,7 +8,6 @@ "schemaVersion", "generatedAtUtc", "packageName", - "installChannel", "currentVersion", "latestVersion", "upToDate", diff --git a/docs/schemas/upgrade-result.v1.schema.json b/docs/schemas/upgrade-result.v1.schema.json index 98457a9..aebbe97 100644 --- a/docs/schemas/upgrade-result.v1.schema.json +++ b/docs/schemas/upgrade-result.v1.schema.json @@ -8,7 +8,6 @@ "schemaVersion", "generatedAtUtc", "packageName", - "installChannel", "currentVersion", "latestVersion", "upToDateBefore", diff --git a/packaging/windows/scripts/configure-xyte-cli.ps1 b/packaging/windows/scripts/configure-xyte-cli.ps1 index 0167354..ed605d1 100644 --- a/packaging/windows/scripts/configure-xyte-cli.ps1 +++ b/packaging/windows/scripts/configure-xyte-cli.ps1 @@ -16,8 +16,18 @@ function Write-Step { } function Invoke-XyteCli { - param([string[]]$Arguments) + param( + [string[]]$Arguments, + [switch]$AllowFailure + ) & $script:XyteCli @Arguments + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0 -and !$AllowFailure) { + throw "xyte-cli $($Arguments -join ' ') failed with exit code $exitCode." + } + if ($AllowFailure) { + return $exitCode + } } function Test-Yes { @@ -101,10 +111,13 @@ if (!$SkipApiKeySetup) { } Write-Step "Readiness" -try { - Invoke-XyteCli @("setup", "status", "--field", "tenantId") -} catch { +$readinessExitCode = Invoke-XyteCli -Arguments @("setup", "status", "--field", "tenantId") -AllowFailure +if ($readinessExitCode -ne 0) { Write-Host "No connected tenant was confirmed. Run this assistant again or run: xyte-cli setup run" } Write-Host "" -Write-Host "Xyte CLI Windows setup is complete." +if ($readinessExitCode -eq 0) { + Write-Host "Xyte CLI Windows setup is complete." +} else { + Write-Host "Xyte CLI Windows install is complete, but setup still needs an API key." +} diff --git a/scripts/package_windows_msi.mjs b/scripts/package_windows_msi.mjs index 0bac2b9..7475338 100644 --- a/scripts/package_windows_msi.mjs +++ b/scripts/package_windows_msi.mjs @@ -33,6 +33,15 @@ function parseArgs(argv) { 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('&', '&') @@ -76,6 +85,16 @@ 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('/'); } @@ -103,6 +122,8 @@ async function downloadNode(args, payloadDir) { 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 runOrThrow( @@ -111,6 +132,22 @@ async function downloadNode(args, payloadDir) { 'Download Node.js Windows runtime' ); } + if (!existsSync(shasumsPath)) { + await runOrThrow( + process.platform === 'win32' ? 'curl.exe' : 'curl', + ['--fail', '--location', '--retry', '3', '--connect-timeout', '20', '--max-time', '60', '--output', shasumsPath, shasumsUrl], + 'Download Node.js runtime checksums' + ); + } + + const expectedSha256 = findExpectedSha256(readFileSync(shasumsPath, 'utf8'), `${nodeBase}.zip`); + if (!expectedSha256) { + throw new Error(`Could not find checksum for ${nodeBase}.zip in ${shasumsPath}.`); + } + const actualSha256 = sha256File(zipPath); + if (actualSha256 !== expectedSha256) { + throw new Error(`Node.js runtime checksum mismatch for ${nodeBase}.zip: expected ${expectedSha256}, got ${actualSha256}.`); + } const extractDir = join(cacheDir, nodeBase); rmSync(extractDir, { recursive: true, force: true }); @@ -345,6 +382,7 @@ async function signMsiIfConfigured(msiPath) { async function main() { const args = parseArgs(process.argv.slice(2)); + validateArgs(args); if (!args.skipMsi) { ensureMsiBuildSupported(); } diff --git a/scripts/validate_windows_packaging.mjs b/scripts/validate_windows_packaging.mjs index fe12e47..deb8f55 100644 --- a/scripts/validate_windows_packaging.mjs +++ b/scripts/validate_windows_packaging.mjs @@ -24,7 +24,10 @@ for (const expected of [ '"setup", "run"', '--key-file', '"doctor", "environment"', - 'Get-Command "xyte-cli" -All' + 'Get-Command "xyte-cli" -All', + '$LASTEXITCODE', + '-AllowFailure', + 'setup still needs an API key' ]) { if (!assistant.includes(expected)) { throw new Error(`Windows setup assistant is missing expected behavior: ${expected}`); @@ -36,6 +39,10 @@ for (const expected of [ "const wixEulaId = 'wix7'", "'-acceptEula', wixEulaId", 'Building a Windows MSI with WiX is supported only on Windows', + '--skip-node is only valid with --skip-msi', + '--skip-npm-install is only valid with --skip-msi', + 'SHASUMS256.txt', + 'Node.js runtime checksum mismatch', "kind: 'windows-msi'", 'winget upgrade --id Xyte.XyteCLI --exact' ]) { @@ -44,6 +51,22 @@ for (const expected of [ } } +const signingScript = readFileSync(join(repoRoot, 'scripts/sign_windows_msi.ps1'), 'utf8'); +if (!signingScript.includes('https://timestamp.digicert.com')) { + throw new Error('Windows MSI signing script must use an HTTPS timestamp URL.'); +} + +const releaseWorkflow = readFileSync(join(repoRoot, '.github/workflows/release-assets.yml'), 'utf8'); +for (const expected of [ + 'needs: [meta, packaged-install-smoke]', + 'Publish 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', diff --git a/skills/xyte-cli/schemas/upgrade-check.v1.schema.json b/skills/xyte-cli/schemas/upgrade-check.v1.schema.json index 8d00d63..089cc95 100644 --- a/skills/xyte-cli/schemas/upgrade-check.v1.schema.json +++ b/skills/xyte-cli/schemas/upgrade-check.v1.schema.json @@ -8,7 +8,6 @@ "schemaVersion", "generatedAtUtc", "packageName", - "installChannel", "currentVersion", "latestVersion", "upToDate", diff --git a/skills/xyte-cli/schemas/upgrade-result.v1.schema.json b/skills/xyte-cli/schemas/upgrade-result.v1.schema.json index 98457a9..aebbe97 100644 --- a/skills/xyte-cli/schemas/upgrade-result.v1.schema.json +++ b/skills/xyte-cli/schemas/upgrade-result.v1.schema.json @@ -8,7 +8,6 @@ "schemaVersion", "generatedAtUtc", "packageName", - "installChannel", "currentVersion", "latestVersion", "upToDateBefore", diff --git a/src/cli/upgrade.ts b/src/cli/upgrade.ts index 20ff873..e6c867b 100644 --- a/src/cli/upgrade.ts +++ b/src/cli/upgrade.ts @@ -45,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 ?? 'Xyte.XyteCLI'} --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 ?? 'Xyte.XyteCLI', '--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`, { @@ -80,7 +105,7 @@ export async function checkForUpgrade( return buildUpgradeCheck({ packageName, installChannel: installChannel.kind, - recommendedCommand: installChannel.updateCommand, + recommendedCommand: buildRecommendedUpdateCommand(packageName, installChannel), currentVersion, latestVersion }); @@ -100,7 +125,10 @@ export async function applyUpgrade( packageName, latestVersionOverride: settings.latestVersionOverride }, - deps + { + ...deps, + getInstallChannel: () => installChannel + } ); const warnings: string[] = []; @@ -109,22 +137,18 @@ export async function applyUpgrade( : typeof settings.latestVersionOverride === 'string' && settings.latestVersionOverride.trim() ? `${packageName}@${settings.latestVersionOverride.trim()}` : `${packageName}@latest`; - const updateArgs = - installChannel.kind === 'windows-msi' - ? ['upgrade', '--id', installChannel.packageId ?? 'Xyte.XyteCLI', '--exact'] - : ['install', '--global', installSpec]; let updateCommand: { command: string; args: string[] } | undefined; if (compareSemver(check.currentVersion, check.latestVersion) < 0) { - const command = installChannel.kind === 'windows-msi' ? 'winget' : npmCommand; - updateCommand = { - command, - args: updateArgs - }; - const installResult = await runner(command, 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 "${command} ${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'}` }); } } diff --git a/src/contracts/upgrade.ts b/src/contracts/upgrade.ts index 61dc0d0..b75ecde 100644 --- a/src/contracts/upgrade.ts +++ b/src/contracts/upgrade.ts @@ -7,7 +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']), + installChannel: z.enum(['npm', 'windows-msi']).optional(), currentVersion: z.string(), latestVersion: z.string(), upToDate: z.boolean(), @@ -48,7 +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']), + installChannel: z.enum(['npm', 'windows-msi']).optional(), currentVersion: z.string(), latestVersion: z.string(), upToDateBefore: z.boolean(), diff --git a/tests/cli-logging.test.ts b/tests/cli-logging.test.ts index d8b0a8b..5d29c87 100644 --- a/tests/cli-logging.test.ts +++ b/tests/cli-logging.test.ts @@ -221,7 +221,7 @@ describe('cli action logging', () => { '--format', 'json' ]); - const parsed = JSON.parse(stdout.write.mock.calls.map((call) => String(call[0])).join('')); + let 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/contracts.test.ts b/tests/contracts.test.ts index 4c79cf2..e4bdcf6 100644 --- a/tests/contracts.test.ts +++ b/tests/contracts.test.ts @@ -354,6 +354,14 @@ describe('schema contracts', () => { expect(validateStatus(status)).toBe(true); expect(validateUpgradeCheck(upgradeCheck)).toBe(true); expect(validateUpgradeResult(upgradeResult)).toBe(true); + + const legacyUpgradeCheck = { ...upgradeCheck }; + delete (legacyUpgradeCheck as Partial).installChannel; + const legacyUpgradeResult = { ...upgradeResult }; + delete (legacyUpgradeResult as Partial).installChannel; + + expect(validateUpgradeCheck(legacyUpgradeCheck)).toBe(true); + expect(validateUpgradeResult(legacyUpgradeResult)).toBe(true); }); it('validates watch frame payload', () => { diff --git a/tests/upgrade.test.ts b/tests/upgrade.test.ts index ba8846e..d2d3d00 100644 --- a/tests/upgrade.test.ts +++ b/tests/upgrade.test.ts @@ -53,6 +53,72 @@ describe('upgrade utilities', () => { expect(result.recommendedCommand).toBe('winget upgrade --id Xyte.XyteCLI --exact'); }); + it('derives Windows MSI update recommendations from package id instead of arbitrary updateCommand text', async () => { + const result = await checkForUpgrade( + { + packageName: '@xyteai/cli', + latestVersionOverride: '0.5.0' + }, + { + getCurrentVersion: () => '0.4.0', + getInstallChannel: () => ({ + kind: 'windows-msi', + updateCommand: 'winget upgrade --id Contoso.OtherTool --silent', + 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, + updateCommand: 'npm install --global @xyteai/cli@latest' + })); + 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)) { From 1e6d6d0db5a5dbdf56490464f6097f0d999136d8 Mon Sep 17 00:00:00 2001 From: Vladimir Porton Date: Wed, 1 Jul 2026 21:17:51 +0300 Subject: [PATCH 04/19] Fix timestamp URL validation --- scripts/validate_windows_packaging.mjs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/validate_windows_packaging.mjs b/scripts/validate_windows_packaging.mjs index deb8f55..0d2950d 100644 --- a/scripts/validate_windows_packaging.mjs +++ b/scripts/validate_windows_packaging.mjs @@ -52,7 +52,16 @@ for (const expected of [ } const signingScript = readFileSync(join(repoRoot, 'scripts/sign_windows_msi.ps1'), 'utf8'); -if (!signingScript.includes('https://timestamp.digicert.com')) { +const timestampUrlMatch = signingScript.match(/\$TimestampUrl\s*=\s*"([^"]+)"/); +if (!timestampUrlMatch) { + throw new Error('Windows MSI signing script must define a default timestamp URL.'); +} +const timestampUrl = new URL(timestampUrlMatch[1]); +if ( + timestampUrl.protocol !== 'https:' || + timestampUrl.hostname !== 'timestamp.digicert.com' || + timestampUrl.pathname !== '/' +) { throw new Error('Windows MSI signing script must use an HTTPS timestamp URL.'); } From 2121f2e5d749caf487d9aed73be4a8058a5147b7 Mon Sep 17 00:00:00 2001 From: Vladimir Porton Date: Wed, 1 Jul 2026 21:35:01 +0300 Subject: [PATCH 05/19] Use executable path in Windows setup assistant --- packaging/windows/scripts/configure-xyte-cli.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packaging/windows/scripts/configure-xyte-cli.ps1 b/packaging/windows/scripts/configure-xyte-cli.ps1 index ed605d1..eb4f5f0 100644 --- a/packaging/windows/scripts/configure-xyte-cli.ps1 +++ b/packaging/windows/scripts/configure-xyte-cli.ps1 @@ -55,12 +55,12 @@ 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 Source -Unique +$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.Source)" + Write-Host "PATH candidate: $($command.Definition)" } } From fb76973e7477821e4891968d7d96815bcd5a7884 Mon Sep 17 00:00:00 2001 From: Vladimir Porton Date: Wed, 1 Jul 2026 21:40:54 +0300 Subject: [PATCH 06/19] Remove timestamp URL check from packaging validator --- scripts/validate_windows_packaging.mjs | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/scripts/validate_windows_packaging.mjs b/scripts/validate_windows_packaging.mjs index 0d2950d..67cb7f8 100644 --- a/scripts/validate_windows_packaging.mjs +++ b/scripts/validate_windows_packaging.mjs @@ -51,20 +51,6 @@ for (const expected of [ } } -const signingScript = readFileSync(join(repoRoot, 'scripts/sign_windows_msi.ps1'), 'utf8'); -const timestampUrlMatch = signingScript.match(/\$TimestampUrl\s*=\s*"([^"]+)"/); -if (!timestampUrlMatch) { - throw new Error('Windows MSI signing script must define a default timestamp URL.'); -} -const timestampUrl = new URL(timestampUrlMatch[1]); -if ( - timestampUrl.protocol !== 'https:' || - timestampUrl.hostname !== 'timestamp.digicert.com' || - timestampUrl.pathname !== '/' -) { - throw new Error('Windows MSI signing script must use an HTTPS timestamp URL.'); -} - const releaseWorkflow = readFileSync(join(repoRoot, '.github/workflows/release-assets.yml'), 'utf8'); for (const expected of [ 'needs: [meta, packaged-install-smoke]', From be75fd7457fa37a02997a87cb8a66c5a5a04c4d9 Mon Sep 17 00:00:00 2001 From: Vladimir Porton Date: Wed, 1 Jul 2026 21:43:41 +0300 Subject: [PATCH 07/19] Simplify Windows release validation --- .github/workflows/release-assets.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index c20b3c4..f0a2d15 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -132,7 +132,7 @@ jobs: checksums.txt windows-msi: - needs: meta + needs: [meta, release] runs-on: windows-latest steps: - name: Checkout tag @@ -202,7 +202,6 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.meta.outputs.tag }} - generate_release_notes: true files: | artifacts/windows-installer/*.msi artifacts/windows-installer/windows-installer-manifest.json From b89534609ec62a286edb5f7d1c451d67857b1ffc Mon Sep 17 00:00:00 2001 From: Vladimir Porton Date: Wed, 1 Jul 2026 21:50:25 +0300 Subject: [PATCH 08/19] Handle optional install channel output --- scripts/package_windows_msi.mjs | 17 ++++++++++++++--- src/cli/index.ts | 4 ++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/scripts/package_windows_msi.mjs b/scripts/package_windows_msi.mjs index 7475338..b1d08f7 100644 --- a/scripts/package_windows_msi.mjs +++ b/scripts/package_windows_msi.mjs @@ -20,11 +20,22 @@ function parseArgs(argv) { 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') args.outDir = resolve(argv[++i]); - else if (arg === '--node-version') args.nodeVersion = argv[++i]?.replace(/^v/, ''); - else if (arg === '--skip-build') args.skipBuild = true; + 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; diff --git a/src/cli/index.ts b/src/cli/index.ts index 623efb2..5539083 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -884,7 +884,7 @@ export function createCli(runtime: CliRuntime = {}): Command { if (options.check) { if (output === 'text') { stdout.write(`Package: ${check.packageName}\n`); - stdout.write(`Install channel: ${check.installChannel}\n`); + stdout.write(`Install channel: ${check.installChannel ?? 'unknown'}\n`); stdout.write(`Current: ${check.currentVersion}\n`); stdout.write(`Latest: ${check.latestVersion}\n`); stdout.write(`Up to date: ${check.upToDate}\n`); @@ -932,7 +932,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(`Install channel: ${result.installChannel ?? 'unknown'}\n`); stdout.write(`Current: ${result.currentVersion}\n`); stdout.write(`Latest: ${result.latestVersion}\n`); stdout.write(`Updated: ${result.updated}\n`); From d31b7f34127d9117a5a481b9e20195ba9c54e611 Mon Sep 17 00:00:00 2001 From: Vladimir Porton Date: Wed, 1 Jul 2026 21:53:31 +0300 Subject: [PATCH 09/19] Smoke test installed Windows MSI --- .github/workflows/ci.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bca9c40..2b95609 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,6 +121,36 @@ jobs: - 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" + $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" + & msiexec.exe /i $msi.FullName /qn /norestart /l*v $installLog + if ($LASTEXITCODE -ne 0) { + Get-Content -LiteralPath $installLog -Tail 200 + throw "MSI install failed with exit code $LASTEXITCODE." + } + + $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") + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + $env:Path = @($machinePath, $userPath) -join ";" + + where.exe xyte-cli + xyte-cli --version + xyte-cli doctor environment --format text + - name: Upload Windows MSI artifact uses: actions/upload-artifact@v4 with: From a3f55d51d2b1d80bf95da1fdb4d4aaf6cffce21e Mon Sep 17 00:00:00 2001 From: Vladimir Porton Date: Wed, 1 Jul 2026 21:56:57 +0300 Subject: [PATCH 10/19] Harden Windows MSI install smoke --- .github/workflows/ci.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b95609..db89f25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,10 +131,13 @@ jobs: } $installLog = Join-Path $env:RUNNER_TEMP "xyte-cli-msi-install.log" - & msiexec.exe /i $msi.FullName /qn /norestart /l*v $installLog - if ($LASTEXITCODE -ne 0) { - Get-Content -LiteralPath $installLog -Tail 200 - throw "MSI install failed with exit code $LASTEXITCODE." + $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" From ff32eb960c33913b4fb4bb18b6fd79054cb968e7 Mon Sep 17 00:00:00 2001 From: Vladimir Porton Date: Wed, 1 Jul 2026 22:01:38 +0300 Subject: [PATCH 11/19] Smoke test Windows setup assistant --- .github/workflows/ci.yml | 15 +++++++++++++++ packaging/windows/scripts/configure-xyte-cli.ps1 | 5 +++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db89f25..81ab6f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,6 +154,21 @@ jobs: xyte-cli --version xyte-cli doctor environment --format text + - name: Smoke Windows setup assistant + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + $env:Path = @($machinePath, $userPath) -join ";" + + $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." + } + + & $assistant -NonInteractive + - name: Upload Windows MSI artifact uses: actions/upload-artifact@v4 with: diff --git a/packaging/windows/scripts/configure-xyte-cli.ps1 b/packaging/windows/scripts/configure-xyte-cli.ps1 index eb4f5f0..58f2da2 100644 --- a/packaging/windows/scripts/configure-xyte-cli.ps1 +++ b/packaging/windows/scripts/configure-xyte-cli.ps1 @@ -68,13 +68,14 @@ if (!$SkipNpmMigration) { Write-Step "Check previous npm global install" $npm = Get-Command "npm.cmd" -ErrorAction SilentlyContinue if ($npm) { - $npmListOutput = & $npm.Source list -g @xyteai/cli --depth=0 2>$null + $npmPath = $npm.Definition + $npmListOutput = & $npmPath list -g @xyteai/cli --depth=0 2>$null $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?") { - & $npm.Source uninstall -g @xyteai/cli + & $npmPath uninstall -g @xyteai/cli if ($LASTEXITCODE -ne 0) { throw "npm uninstall -g @xyteai/cli failed." } From 77c0b4510f4fb522a23594fac9cb4d0abe97341a Mon Sep 17 00:00:00 2001 From: Vladimir Porton Date: Wed, 1 Jul 2026 22:05:51 +0300 Subject: [PATCH 12/19] Fix Windows setup assistant readiness exit --- packaging/windows/scripts/configure-xyte-cli.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packaging/windows/scripts/configure-xyte-cli.ps1 b/packaging/windows/scripts/configure-xyte-cli.ps1 index 58f2da2..0613ac8 100644 --- a/packaging/windows/scripts/configure-xyte-cli.ps1 +++ b/packaging/windows/scripts/configure-xyte-cli.ps1 @@ -26,6 +26,7 @@ function Invoke-XyteCli { throw "xyte-cli $($Arguments -join ' ') failed with exit code $exitCode." } if ($AllowFailure) { + $global:LASTEXITCODE = 0 return $exitCode } } @@ -112,7 +113,7 @@ if (!$SkipApiKeySetup) { } Write-Step "Readiness" -$readinessExitCode = Invoke-XyteCli -Arguments @("setup", "status", "--field", "tenantId") -AllowFailure +$readinessExitCode = Invoke-XyteCli -Arguments @("setup", "status", "--format", "text") -AllowFailure if ($readinessExitCode -ne 0) { Write-Host "No connected tenant was confirmed. Run this assistant again or run: xyte-cli setup run" } From ae046f3fcaf86c76277e5f901cf883481825a10c Mon Sep 17 00:00:00 2001 From: Vladimir Porton Date: Wed, 1 Jul 2026 22:07:34 +0300 Subject: [PATCH 13/19] Record Windows installer terminal transcript --- .github/workflows/ci.yml | 46 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81ab6f2..bfbe49d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -169,6 +169,52 @@ jobs: & $assistant -NonInteractive + - name: Record pristine Windows 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" + $machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + $env:Path = @($machinePath, $userPath) -join ";" + + 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" + Write-Host "PS> & `"$assistant`" -NonInteractive" + & $assistant -NonInteractive + '@ | Set-Content -LiteralPath $demoScript -Encoding utf8 + + & pwsh -NoProfile -ExecutionPolicy Bypass -File $demoScript *>&1 | Tee-Object -FilePath $transcript + if ($LASTEXITCODE -ne 0) { + throw "Pristine Windows terminal transcript failed with exit code $LASTEXITCODE." + } + + - 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: From 718738973ba0674cd5d7fe48c4e41298bb11e70e Mon Sep 17 00:00:00 2001 From: Vladimir Porton Date: Thu, 2 Jul 2026 09:45:57 +0300 Subject: [PATCH 14/19] Address Windows installer re-review findings - Fix the setup assistant readiness check: display readiness uncaptured, gate on `setup status --field state` (the exit code carried no signal and the -AllowFailure capture mixed stdout with the returned code), and end with an explicit `exit 0`. - Bump upgrade contracts to v2: freeze the published v1 schemas at their original content and ship new v2 schemas with installChannel required, instead of mutating v1 under an unchanged schemaVersion. - Harden the Node runtime download cache: download via .partial + rename, and evict poisoned cache files on checksum failure. - Run one upgrade check per CLI invocation: compute checkForUpgrade lazily so `upgrade --yes` no longer double-fetches the registry and double-detects the install channel. - Slim InstallChannel to {kind, packageId}: drop the dead updateCommand/ releaseUrl fields and share a single WINDOWS_MSI_PACKAGE_ID constant. - Replace validator string-pins with real unit tests for parseArgs/ validateArgs/findExpectedSha256; move the release-needs invariant to a workflow comment. - Consolidate CI smokes: assert the MSI's machine PATH entry, publish it via GITHUB_PATH once, and make the transcript recording the assistant smoke (one execution instead of three). - Verify Windows release assets are actually attached after publish. - Restore the const lint fix in cli-logging.test.ts (lint fails without it). Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 38 ++-- .github/workflows/release-assets.yml | 22 ++ docs/reference/schema-contracts.html | 6 +- docs/schemas/upgrade-check.v1.schema.json | 6 - docs/schemas/upgrade-check.v2.schema.json | 49 +++++ docs/schemas/upgrade-result.v1.schema.json | 6 - docs/schemas/upgrade-result.v2.schema.json | 201 ++++++++++++++++++ .../windows/scripts/configure-xyte-cli.ps1 | 34 +-- scripts/package_windows_msi.mjs | 54 +++-- scripts/smoke_upgrade_controlled_inner.mjs | 2 +- scripts/validate_windows_packaging.mjs | 12 +- skills/xyte-cli/SKILL.md | 4 +- .../schemas/upgrade-check.v1.schema.json | 6 - .../schemas/upgrade-check.v2.schema.json | 49 +++++ .../schemas/upgrade-result.v1.schema.json | 6 - .../schemas/upgrade-result.v2.schema.json | 201 ++++++++++++++++++ src/cli/index.ts | 18 +- src/cli/upgrade.ts | 6 +- src/contracts/upgrade.ts | 4 +- src/contracts/versions.ts | 4 +- src/utils/install-channel.ts | 14 +- tests/cli-logging.test.ts | 2 +- tests/cli.test.ts | 4 +- tests/contracts.test.ts | 22 +- tests/fixtures/golden/upgrade-check.json | 2 +- tests/install-channel.test.ts | 21 +- tests/upgrade.test.ts | 21 +- tests/windows-packaging.test.ts | 76 +++++++ 28 files changed, 730 insertions(+), 160 deletions(-) create mode 100644 docs/schemas/upgrade-check.v2.schema.json create mode 100644 docs/schemas/upgrade-result.v2.schema.json create mode 100644 skills/xyte-cli/schemas/upgrade-check.v2.schema.json create mode 100644 skills/xyte-cli/schemas/upgrade-result.v2.schema.json create mode 100644 tests/windows-packaging.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfbe49d..2bbe63e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,6 +125,7 @@ jobs: 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." @@ -147,29 +148,19 @@ jobs: } $machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") - $userPath = [Environment]::GetEnvironmentVariable("Path", "User") - $env:Path = @($machinePath, $userPath) -join ";" + $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 - - name: Smoke Windows setup assistant - shell: pwsh - run: | - $ErrorActionPreference = "Stop" - $machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") - $userPath = [Environment]::GetEnvironmentVariable("Path", "User") - $env:Path = @($machinePath, $userPath) -join ";" - - $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." - } - - & $assistant -NonInteractive + Add-Content -Path $env:GITHUB_PATH -Value $installDir - - name: Record pristine Windows terminal transcript + - name: Smoke setup assistant and record terminal transcript shell: pwsh run: | $ErrorActionPreference = "Stop" @@ -177,9 +168,7 @@ jobs: $demoScript = Join-Path $env:RUNNER_TEMP "xyte-cli-windows-pristine-terminal.ps1" @' $ErrorActionPreference = "Stop" - $machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") - $userPath = [Environment]::GetEnvironmentVariable("Path", "User") - $env:Path = @($machinePath, $userPath) -join ";" + $PSNativeCommandUseErrorActionPreference = $true Write-Host "Windows: $([Environment]::OSVersion.VersionString)" Write-Host "PowerShell: $($PSVersionTable.PSVersion)" @@ -198,13 +187,20 @@ jobs: 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 "Pristine Windows terminal transcript failed with exit code $LASTEXITCODE." + throw "Windows terminal transcript smoke failed with exit code $LASTEXITCODE." } - name: Upload Windows terminal transcript diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index f0a2d15..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: @@ -207,3 +209,23 @@ jobs: 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/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/schemas/upgrade-check.v1.schema.json b/docs/schemas/upgrade-check.v1.schema.json index 089cc95..09fc62c 100644 --- a/docs/schemas/upgrade-check.v1.schema.json +++ b/docs/schemas/upgrade-check.v1.schema.json @@ -23,12 +23,6 @@ "packageName": { "type": "string" }, - "installChannel": { - "enum": [ - "npm", - "windows-msi" - ] - }, "currentVersion": { "type": "string" }, 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.v1.schema.json b/docs/schemas/upgrade-result.v1.schema.json index aebbe97..de8276b 100644 --- a/docs/schemas/upgrade-result.v1.schema.json +++ b/docs/schemas/upgrade-result.v1.schema.json @@ -26,12 +26,6 @@ "packageName": { "type": "string" }, - "installChannel": { - "enum": [ - "npm", - "windows-msi" - ] - }, "currentVersion": { "type": "string" }, 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/packaging/windows/scripts/configure-xyte-cli.ps1 b/packaging/windows/scripts/configure-xyte-cli.ps1 index 0613ac8..53c63f9 100644 --- a/packaging/windows/scripts/configure-xyte-cli.ps1 +++ b/packaging/windows/scripts/configure-xyte-cli.ps1 @@ -8,6 +8,9 @@ param( ) $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) @@ -16,18 +19,10 @@ function Write-Step { } function Invoke-XyteCli { - param( - [string[]]$Arguments, - [switch]$AllowFailure - ) + param([string[]]$Arguments) & $script:XyteCli @Arguments - $exitCode = $LASTEXITCODE - if ($exitCode -ne 0 -and !$AllowFailure) { - throw "xyte-cli $($Arguments -join ' ') failed with exit code $exitCode." - } - if ($AllowFailure) { - $global:LASTEXITCODE = 0 - return $exitCode + if ($LASTEXITCODE -ne 0) { + throw "xyte-cli $($Arguments -join ' ') failed with exit code $LASTEXITCODE." } } @@ -113,13 +108,20 @@ if (!$SkipApiKeySetup) { } Write-Step "Readiness" -$readinessExitCode = Invoke-XyteCli -Arguments @("setup", "status", "--format", "text") -AllowFailure -if ($readinessExitCode -ne 0) { - Write-Host "No connected tenant was confirmed. Run this assistant again or run: xyte-cli setup run" +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 ($readinessExitCode -eq 0) { +if ($setupState -eq "ready") { Write-Host "Xyte CLI Windows setup is complete." -} else { +} 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.mjs b/scripts/package_windows_msi.mjs index b1d08f7..0035644 100644 --- a/scripts/package_windows_msi.mjs +++ b/scripts/package_windows_msi.mjs @@ -1,9 +1,9 @@ #!/usr/bin/env node import { createHash } from 'node:crypto'; -import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { runOrThrow } from './run_command.mjs'; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -122,6 +122,17 @@ function ensureMsiBuildSupported() { } } +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'); @@ -137,27 +148,25 @@ async function downloadNode(args, payloadDir) { const shasumsUrl = `https://nodejs.org/dist/v${args.nodeVersion}/SHASUMS256.txt`; if (!existsSync(zipPath)) { - await runOrThrow( - process.platform === 'win32' ? 'curl.exe' : 'curl', - ['--fail', '--location', '--retry', '3', '--connect-timeout', '20', '--max-time', '300', '--output', zipPath, nodeUrl], - 'Download Node.js Windows runtime' - ); + await downloadFile(nodeUrl, zipPath, 'Download Node.js Windows runtime', '300'); } if (!existsSync(shasumsPath)) { - await runOrThrow( - process.platform === 'win32' ? 'curl.exe' : 'curl', - ['--fail', '--location', '--retry', '3', '--connect-timeout', '20', '--max-time', '60', '--output', shasumsPath, shasumsUrl], - 'Download Node.js runtime checksums' - ); + await downloadFile(shasumsUrl, shasumsPath, 'Download Node.js runtime checksums', '60'); } const expectedSha256 = findExpectedSha256(readFileSync(shasumsPath, 'utf8'), `${nodeBase}.zip`); if (!expectedSha256) { - throw new Error(`Could not find checksum for ${nodeBase}.zip in ${shasumsPath}.`); + 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) { - throw new Error(`Node.js runtime checksum mismatch for ${nodeBase}.zip: expected ${expectedSha256}, got ${actualSha256}.`); + rmSync(zipPath, { force: true }); + throw new Error( + `Node.js runtime checksum mismatch for ${nodeBase}.zip: expected ${expectedSha256}, got ${actualSha256}. Removed the cached download; re-run to download it again.` + ); } const extractDir = join(cacheDir, nodeBase); @@ -220,9 +229,7 @@ function copyPayloadFiles(payloadDir) { `${JSON.stringify( { kind: 'windows-msi', - packageId: 'Xyte.XyteCLI', - updateCommand: 'winget upgrade --id Xyte.XyteCLI --exact', - releaseUrl: 'https://github.com/xyte-io/xyte-cli/releases/latest' + packageId: 'Xyte.XyteCLI' }, null, 2 @@ -433,7 +440,12 @@ async function main() { process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`); } -main().catch((error) => { - process.stderr.write(`${error instanceof Error ? error.stack || error.message : String(error)}\n`); - process.exitCode = 1; -}); +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/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 index 67cb7f8..ff79fe0 100644 --- a/scripts/validate_windows_packaging.mjs +++ b/scripts/validate_windows_packaging.mjs @@ -26,7 +26,7 @@ for (const expected of [ '"doctor", "environment"', 'Get-Command "xyte-cli" -All', '$LASTEXITCODE', - '-AllowFailure', + '"setup", "status", "--field", "state"', 'setup still needs an API key' ]) { if (!assistant.includes(expected)) { @@ -39,12 +39,10 @@ for (const expected of [ "const wixEulaId = 'wix7'", "'-acceptEula', wixEulaId", 'Building a Windows MSI with WiX is supported only on Windows', - '--skip-node is only valid with --skip-msi', - '--skip-npm-install is only valid with --skip-msi', 'SHASUMS256.txt', 'Node.js runtime checksum mismatch', "kind: 'windows-msi'", - 'winget upgrade --id Xyte.XyteCLI --exact' + "packageId: 'Xyte.XyteCLI'" ]) { if (!packageScript.includes(expected)) { throw new Error(`Windows packaging script is missing expected behavior: ${expected}`); @@ -52,11 +50,7 @@ for (const expected of [ } const releaseWorkflow = readFileSync(join(repoRoot, '.github/workflows/release-assets.yml'), 'utf8'); -for (const expected of [ - 'needs: [meta, packaged-install-smoke]', - 'Publish Windows release assets', - 'windows-checksums.txt' -]) { +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}`); } 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.v1.schema.json b/skills/xyte-cli/schemas/upgrade-check.v1.schema.json index 089cc95..09fc62c 100644 --- a/skills/xyte-cli/schemas/upgrade-check.v1.schema.json +++ b/skills/xyte-cli/schemas/upgrade-check.v1.schema.json @@ -23,12 +23,6 @@ "packageName": { "type": "string" }, - "installChannel": { - "enum": [ - "npm", - "windows-msi" - ] - }, "currentVersion": { "type": "string" }, 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.v1.schema.json b/skills/xyte-cli/schemas/upgrade-result.v1.schema.json index aebbe97..de8276b 100644 --- a/skills/xyte-cli/schemas/upgrade-result.v1.schema.json +++ b/skills/xyte-cli/schemas/upgrade-result.v1.schema.json @@ -26,12 +26,6 @@ "packageName": { "type": "string" }, - "installChannel": { - "enum": [ - "npm", - "windows-msi" - ] - }, "currentVersion": { "type": "string" }, 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 5539083..179b00a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -877,14 +877,14 @@ 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 - ); if (options.check) { + const check = await checkForUpgrade( + { packageName: '@xyteai/cli', latestVersionOverride }, + runtime.upgradeDependencies + ); if (output === 'text') { stdout.write(`Package: ${check.packageName}\n`); - stdout.write(`Install channel: ${check.installChannel ?? 'unknown'}\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`); @@ -914,6 +914,10 @@ export function createCli(runtime: CliRuntime = {}): Command { if (output === 'text') { stdout.write('Upgrade canceled.\n'); } else { + const check = await checkForUpgrade( + { packageName: '@xyteai/cli', latestVersionOverride }, + runtime.upgradeDependencies + ); printJson(stdout, check, { strictJson: resolveStrictJson({ settings }) }); } return; @@ -922,7 +926,7 @@ export function createCli(runtime: CliRuntime = {}): Command { const result = await applyUpgrade( { - packageName: check.packageName, + packageName: '@xyteai/cli', skillSourceDir: resolveSkillSourceDir(), installSpec, latestVersionOverride @@ -932,7 +936,7 @@ export function createCli(runtime: CliRuntime = {}): Command { if (output === 'text') { stdout.write(`Package: ${result.packageName}\n`); - stdout.write(`Install channel: ${result.installChannel ?? 'unknown'}\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 e6c867b..07e6edf 100644 --- a/src/cli/upgrade.ts +++ b/src/cli/upgrade.ts @@ -5,7 +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, type InstallChannel } from '../utils/install-channel'; +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']; @@ -47,7 +47,7 @@ function parseVersionFromOutput(output: string): string | undefined { function buildRecommendedUpdateCommand(packageName: string, installChannel: InstallChannel): string { if (installChannel.kind === 'windows-msi') { - return `winget upgrade --id ${installChannel.packageId ?? 'Xyte.XyteCLI'} --exact`; + return `winget upgrade --id ${installChannel.packageId ?? WINDOWS_MSI_PACKAGE_ID} --exact`; } return `npm install --global ${packageName}@latest`; } @@ -60,7 +60,7 @@ function buildExecutableUpdateCommand(args: { if (args.installChannel.kind === 'windows-msi') { return { command: 'winget', - args: ['upgrade', '--id', args.installChannel.packageId ?? 'Xyte.XyteCLI', '--exact'] + args: ['upgrade', '--id', args.installChannel.packageId ?? WINDOWS_MSI_PACKAGE_ID, '--exact'] }; } diff --git a/src/contracts/upgrade.ts b/src/contracts/upgrade.ts index b75ecde..61dc0d0 100644 --- a/src/contracts/upgrade.ts +++ b/src/contracts/upgrade.ts @@ -7,7 +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']).optional(), + installChannel: z.enum(['npm', 'windows-msi']), currentVersion: z.string(), latestVersion: z.string(), upToDate: z.boolean(), @@ -48,7 +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']).optional(), + installChannel: z.enum(['npm', 'windows-msi']), currentVersion: z.string(), latestVersion: z.string(), upToDateBefore: z.boolean(), 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 index 7d31b17..95c4cbc 100644 --- a/src/utils/install-channel.ts +++ b/src/utils/install-channel.ts @@ -3,16 +3,15 @@ import path from 'node:path'; export type InstallChannelKind = 'npm' | 'windows-msi'; +export const WINDOWS_MSI_PACKAGE_ID = 'Xyte.XyteCLI'; + export interface InstallChannel { kind: InstallChannelKind; - updateCommand: string; packageId?: string; - releaseUrl?: string; } const DEFAULT_INSTALL_CHANNEL: InstallChannel = { - kind: 'npm', - updateCommand: 'npm install --global @xyteai/cli@latest' + kind: 'npm' }; function nonBlankString(value: unknown): string | undefined { @@ -35,9 +34,7 @@ function parseInstallChannel(payload: unknown): InstallChannel | undefined { return { kind: 'windows-msi', - updateCommand: nonBlankString(record.updateCommand) ?? 'winget upgrade --id Xyte.XyteCLI --exact', - packageId: nonBlankString(record.packageId), - releaseUrl: nonBlankString(record.releaseUrl) + packageId: nonBlankString(record.packageId) }; } @@ -61,8 +58,7 @@ export function detectInstallChannel(startDir: string = __dirname): InstallChann if (process.env.XYTE_CLI_INSTALL_CHANNEL?.trim() === 'windows-msi') { return { kind: 'windows-msi', - updateCommand: 'winget upgrade --id Xyte.XyteCLI --exact', - packageId: 'Xyte.XyteCLI' + packageId: WINDOWS_MSI_PACKAGE_ID }; } 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 e4bdcf6..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,7 +315,7 @@ describe('schema contracts', () => { }); const upgradeResult = { - schemaVersion: 'xyte.upgrade.result.v1', + schemaVersion: 'xyte.upgrade.result.v2', generatedAtUtc: new Date().toISOString(), packageName: '@xyteai/cli', installChannel: 'npm', @@ -355,13 +359,13 @@ describe('schema contracts', () => { expect(validateUpgradeCheck(upgradeCheck)).toBe(true); expect(validateUpgradeResult(upgradeResult)).toBe(true); - const legacyUpgradeCheck = { ...upgradeCheck }; - delete (legacyUpgradeCheck as Partial).installChannel; - const legacyUpgradeResult = { ...upgradeResult }; - delete (legacyUpgradeResult as Partial).installChannel; + 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(validateUpgradeCheck(legacyUpgradeCheck)).toBe(true); - expect(validateUpgradeResult(legacyUpgradeResult)).toBe(true); + 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 093ceb5..1970d0f 100644 --- a/tests/fixtures/golden/upgrade-check.json +++ b/tests/fixtures/golden/upgrade-check.json @@ -1,5 +1,5 @@ { - "schemaVersion": "xyte.upgrade.check.v1", + "schemaVersion": "xyte.upgrade.check.v2", "generatedAtUtc": "", "packageName": "@xyteai/cli", "installChannel": "npm", diff --git a/tests/install-channel.test.ts b/tests/install-channel.test.ts index c99cff8..f043e48 100644 --- a/tests/install-channel.test.ts +++ b/tests/install-channel.test.ts @@ -28,8 +28,7 @@ describe('install channel detection', () => { delete process.env.XYTE_CLI_INSTALL_CHANNEL_FILE; expect(detectInstallChannel('/tmp/no-channel-here')).toEqual({ - kind: 'npm', - updateCommand: 'npm install --global @xyteai/cli@latest' + kind: 'npm' }); }); @@ -44,19 +43,17 @@ describe('install channel detection', () => { join(root, 'install-channel.json'), JSON.stringify({ kind: 'windows-msi', - packageId: 'Xyte.XyteCLI', - updateCommand: 'winget upgrade --id Xyte.XyteCLI --exact' + packageId: 'Xyte.XyteCLI' }) ); - expect(detectInstallChannel(nested)).toMatchObject({ + expect(detectInstallChannel(nested)).toEqual({ kind: 'windows-msi', - packageId: 'Xyte.XyteCLI', - updateCommand: 'winget upgrade --id Xyte.XyteCLI --exact' + packageId: 'Xyte.XyteCLI' }); }); - it('trims optional Windows MSI channel metadata and ignores blanks', () => { + it('treats a blank packageId as absent', () => { delete process.env.XYTE_CLI_INSTALL_CHANNEL; delete process.env.XYTE_CLI_INSTALL_CHANNEL_FILE; @@ -67,17 +64,13 @@ describe('install channel detection', () => { join(root, 'install-channel.json'), JSON.stringify({ kind: 'windows-msi', - packageId: ' ', - releaseUrl: ' ', - updateCommand: ' winget upgrade --id Xyte.XyteCLI --exact ' + packageId: ' ' }) ); expect(detectInstallChannel(nested)).toEqual({ kind: 'windows-msi', - updateCommand: 'winget upgrade --id Xyte.XyteCLI --exact', - packageId: undefined, - releaseUrl: undefined + packageId: undefined }); }); }); diff --git a/tests/upgrade.test.ts b/tests/upgrade.test.ts index d2d3d00..28015fe 100644 --- a/tests/upgrade.test.ts +++ b/tests/upgrade.test.ts @@ -23,7 +23,7 @@ describe('upgrade utilities', () => { { fetchImpl: fetchImpl as any, getCurrentVersion: () => '0.4.0', - getInstallChannel: () => ({ kind: 'npm', updateCommand: 'npm install --global @xyteai/cli@latest' }) + getInstallChannel: () => ({ kind: 'npm' }) } ); @@ -33,7 +33,7 @@ describe('upgrade utilities', () => { expect(result.upToDate).toBe(false); }); - it('recommends winget updates for Windows MSI installs', async () => { + it('recommends winget updates for Windows MSI installs, defaulting the package id', async () => { const result = await checkForUpgrade( { packageName: '@xyteai/cli', @@ -41,11 +41,7 @@ describe('upgrade utilities', () => { }, { getCurrentVersion: () => '0.4.0', - getInstallChannel: () => ({ - kind: 'windows-msi', - updateCommand: 'winget upgrade --id Xyte.XyteCLI --exact', - packageId: 'Xyte.XyteCLI' - }) + getInstallChannel: () => ({ kind: 'windows-msi' }) } ); @@ -53,7 +49,7 @@ describe('upgrade utilities', () => { expect(result.recommendedCommand).toBe('winget upgrade --id Xyte.XyteCLI --exact'); }); - it('derives Windows MSI update recommendations from package id instead of arbitrary updateCommand text', async () => { + it('derives winget recommendations from a custom package id', async () => { const result = await checkForUpgrade( { packageName: '@xyteai/cli', @@ -63,7 +59,6 @@ describe('upgrade utilities', () => { getCurrentVersion: () => '0.4.0', getInstallChannel: () => ({ kind: 'windows-msi', - updateCommand: 'winget upgrade --id Contoso.OtherTool --silent', packageId: 'Xyte.CustomCLI' }) } @@ -74,8 +69,7 @@ describe('upgrade utilities', () => { it('detects install channel once when applying an upgrade', async () => { const getInstallChannel = vi.fn(() => ({ - kind: 'npm' as const, - updateCommand: 'npm install --global @xyteai/cli@latest' + kind: 'npm' as const })); const commandRunner = vi.fn(async (command: string) => { if (/^npm(?:\.cmd)?$/.test(command)) { @@ -150,7 +144,7 @@ describe('upgrade utilities', () => { fetchImpl: vi.fn() as any, commandRunner, getCurrentVersion: () => '0.4.0', - getInstallChannel: () => ({ kind: 'npm', updateCommand: 'npm install --global @xyteai/cli@latest' }), + getInstallChannel: () => ({ kind: 'npm' }), installSkillsImpl: vi.fn().mockResolvedValue({ workspaceRoot: '/tmp/workspace', homeRoot: '/tmp/home', @@ -215,7 +209,7 @@ describe('upgrade utilities', () => { fetchImpl: vi.fn() as any, commandRunner, getCurrentVersion: () => '0.5.0', - getInstallChannel: () => ({ kind: 'npm', updateCommand: 'npm install --global @xyteai/cli@latest' }), + getInstallChannel: () => ({ kind: 'npm' }), installSkillsImpl: vi.fn().mockResolvedValue({ workspaceRoot: '/tmp/workspace', homeRoot: '/tmp/home', @@ -262,7 +256,6 @@ describe('upgrade utilities', () => { getCurrentVersion: () => '0.6.0', getInstallChannel: () => ({ kind: 'windows-msi', - updateCommand: 'winget upgrade --id Xyte.XyteCLI --exact', packageId: 'Xyte.XyteCLI' }), installSkillsImpl: vi.fn().mockResolvedValue({ diff --git a/tests/windows-packaging.test.ts b/tests/windows-packaging.test.ts new file mode 100644 index 0000000..d4df213 --- /dev/null +++ b/tests/windows-packaging.test.ts @@ -0,0 +1,76 @@ +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { beforeAll, describe, expect, it } from 'vitest'; + +interface PackagingArgs { + outDir: string; + nodeVersion: string; + skipBuild: boolean; + skipMsi: boolean; + skipNode: boolean; + skipNpmInstall: boolean; +} + +interface PackagingModule { + parseArgs: (argv: string[]) => PackagingArgs; + validateArgs: (args: PackagingArgs) => void; + findExpectedSha256: (shasumsText: string, fileName: string) => string | undefined; +} + +let packaging: PackagingModule; + +beforeAll(async () => { + const scriptUrl = pathToFileURL(join(__dirname, '..', 'scripts', 'package_windows_msi.mjs')).href; + packaging = (await import(scriptUrl)) as PackagingModule; +}); + +describe('windows packaging argument parsing', () => { + it('parses flags and values', () => { + const args = packaging.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(() => packaging.parseArgs(['--out-dir'])).toThrow('--out-dir requires a value.'); + expect(() => packaging.parseArgs(['--node-version', '--skip-msi'])).toThrow('--node-version requires a value.'); + }); + + it('rejects unknown arguments', () => { + expect(() => packaging.parseArgs(['--bogus'])).toThrow('Unknown argument: --bogus'); + }); + + it('rejects payload skip flags on real MSI builds', () => { + expect(() => packaging.validateArgs(packaging.parseArgs(['--skip-node']))).toThrow( + '--skip-node is only valid with --skip-msi' + ); + expect(() => packaging.validateArgs(packaging.parseArgs(['--skip-npm-install']))).toThrow( + '--skip-npm-install is only valid with --skip-msi' + ); + expect(() => packaging.validateArgs(packaging.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(packaging.findExpectedSha256(shasums, 'node-v22.1.0-win-x64.zip')).toBe('A'.repeat(64)); + }); + + it('ignores malformed hash lines', () => { + expect(packaging.findExpectedSha256(shasums, 'node-v22.1.0-win-arm64.zip')).toBeUndefined(); + }); + + it('returns undefined when the file is missing', () => { + expect(packaging.findExpectedSha256(shasums, 'node-v99.0.0-win-x64.zip')).toBeUndefined(); + }); +}); From c84c839fb3e1210a6aff6147a3eaef5036934df8 Mon Sep 17 00:00:00 2001 From: Vladimir Porton Date: Thu, 2 Jul 2026 09:50:53 +0300 Subject: [PATCH 15/19] Fix windows-packaging test import on Windows runners The dynamic file-URL import resolved differently under vitest on Windows and failed suite load with a SyntaxError. Use a static relative import (uniform vite transform pipeline on all platforms) with a declaration file for the plain .mjs script. Co-Authored-By: Claude Fable 5 --- scripts/package_windows_msi.d.mts | 12 ++++++++ tests/windows-packaging.test.ts | 49 ++++++++----------------------- 2 files changed, 24 insertions(+), 37 deletions(-) create mode 100644 scripts/package_windows_msi.d.mts 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/tests/windows-packaging.test.ts b/tests/windows-packaging.test.ts index d4df213..4f344cc 100644 --- a/tests/windows-packaging.test.ts +++ b/tests/windows-packaging.test.ts @@ -1,33 +1,10 @@ -import { join } from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { describe, expect, it } from 'vitest'; -import { beforeAll, describe, expect, it } from 'vitest'; - -interface PackagingArgs { - outDir: string; - nodeVersion: string; - skipBuild: boolean; - skipMsi: boolean; - skipNode: boolean; - skipNpmInstall: boolean; -} - -interface PackagingModule { - parseArgs: (argv: string[]) => PackagingArgs; - validateArgs: (args: PackagingArgs) => void; - findExpectedSha256: (shasumsText: string, fileName: string) => string | undefined; -} - -let packaging: PackagingModule; - -beforeAll(async () => { - const scriptUrl = pathToFileURL(join(__dirname, '..', 'scripts', 'package_windows_msi.mjs')).href; - packaging = (await import(scriptUrl)) as PackagingModule; -}); +import { findExpectedSha256, parseArgs, validateArgs } from '../scripts/package_windows_msi.mjs'; describe('windows packaging argument parsing', () => { it('parses flags and values', () => { - const args = packaging.parseArgs(['--out-dir', '/tmp/out', '--node-version', 'v22.1.0', '--skip-build']); + 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); @@ -35,22 +12,20 @@ describe('windows packaging argument parsing', () => { }); it('rejects a value flag with no value', () => { - expect(() => packaging.parseArgs(['--out-dir'])).toThrow('--out-dir requires a value.'); - expect(() => packaging.parseArgs(['--node-version', '--skip-msi'])).toThrow('--node-version requires a 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(() => packaging.parseArgs(['--bogus'])).toThrow('Unknown argument: --bogus'); + expect(() => parseArgs(['--bogus'])).toThrow('Unknown argument: --bogus'); }); it('rejects payload skip flags on real MSI builds', () => { - expect(() => packaging.validateArgs(packaging.parseArgs(['--skip-node']))).toThrow( - '--skip-node is only valid with --skip-msi' - ); - expect(() => packaging.validateArgs(packaging.parseArgs(['--skip-npm-install']))).toThrow( + 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(() => packaging.validateArgs(packaging.parseArgs(['--skip-node', '--skip-npm-install', '--skip-msi']))).not.toThrow(); + expect(() => validateArgs(parseArgs(['--skip-node', '--skip-npm-install', '--skip-msi']))).not.toThrow(); }); }); @@ -63,14 +38,14 @@ describe('windows packaging checksum parsing', () => { ].join('\n'); it('finds the checksum for the exact file name', () => { - expect(packaging.findExpectedSha256(shasums, 'node-v22.1.0-win-x64.zip')).toBe('A'.repeat(64)); + expect(findExpectedSha256(shasums, 'node-v22.1.0-win-x64.zip')).toBe('A'.repeat(64)); }); it('ignores malformed hash lines', () => { - expect(packaging.findExpectedSha256(shasums, 'node-v22.1.0-win-arm64.zip')).toBeUndefined(); + expect(findExpectedSha256(shasums, 'node-v22.1.0-win-arm64.zip')).toBeUndefined(); }); it('returns undefined when the file is missing', () => { - expect(packaging.findExpectedSha256(shasums, 'node-v99.0.0-win-x64.zip')).toBeUndefined(); + expect(findExpectedSha256(shasums, 'node-v99.0.0-win-x64.zip')).toBeUndefined(); }); }); From 3ee40a7d8a1cceb919a5ded8b56653106c812497 Mon Sep 17 00:00:00 2001 From: Vladimir Porton Date: Thu, 2 Jul 2026 10:07:36 +0300 Subject: [PATCH 16/19] Drop shebangs from vitest-imported packaging scripts Windows runners check out with CRLF; a shebang line ending in CRLF makes vitest fail to load the .mjs with a bare SyntaxError, which broke tests/windows-packaging.test.ts on windows-latest. The scripts are only ever invoked via `node scripts/...`, so the shebangs were inert. Reproduced and verified under simulated CRLF checkout. Co-Authored-By: Claude Fable 5 --- scripts/package_windows_msi.mjs | 2 -- scripts/run_command.mjs | 2 -- 2 files changed, 4 deletions(-) diff --git a/scripts/package_windows_msi.mjs b/scripts/package_windows_msi.mjs index 0035644..354c059 100644 --- a/scripts/package_windows_msi.mjs +++ b/scripts/package_windows_msi.mjs @@ -1,5 +1,3 @@ -#!/usr/bin/env node - 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'; 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 = {}) { From b11f93ed69f9b85af860c599e010b41a8faf2647 Mon Sep 17 00:00:00 2001 From: Vladimir Porton Date: Thu, 2 Jul 2026 10:09:53 +0300 Subject: [PATCH 17/19] Polish from final review pass - Add the missing CHANGELOG entry for the Windows MSI channel and the upgrade-contract v1 -> v2 compatibility boundary. - Evict the cached SHASUMS file too on checksum mismatch so a stale well-formed checksum list cannot loop fresh downloads into failure. - Relax $ErrorActionPreference around the npm migration probe: Windows PowerShell 5.1 turns redirected native stderr into terminating errors, so npm warnings could crash the assistant. - Pin the readiness "ready" comparison in the packaging validator (CI smoke only exercises the needs_setup branch). - Deduplicate the checkForUpgrade call in the upgrade handler. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 7 +++++++ packaging/windows/scripts/configure-xyte-cli.ps1 | 5 +++++ scripts/package_windows_msi.mjs | 3 ++- scripts/validate_windows_packaging.mjs | 1 + src/cli/index.ts | 13 ++++--------- 5 files changed, 19 insertions(+), 10 deletions(-) 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/packaging/windows/scripts/configure-xyte-cli.ps1 b/packaging/windows/scripts/configure-xyte-cli.ps1 index 53c63f9..bccd7ba 100644 --- a/packaging/windows/scripts/configure-xyte-cli.ps1 +++ b/packaging/windows/scripts/configure-xyte-cli.ps1 @@ -65,7 +65,12 @@ if (!$SkipNpmMigration) { $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." diff --git a/scripts/package_windows_msi.mjs b/scripts/package_windows_msi.mjs index 354c059..fdb7a6c 100644 --- a/scripts/package_windows_msi.mjs +++ b/scripts/package_windows_msi.mjs @@ -162,8 +162,9 @@ async function downloadNode(args, payloadDir) { 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 download; re-run to download it again.` + `Node.js runtime checksum mismatch for ${nodeBase}.zip: expected ${expectedSha256}, got ${actualSha256}. Removed the cached files; re-run to download them again.` ); } diff --git a/scripts/validate_windows_packaging.mjs b/scripts/validate_windows_packaging.mjs index ff79fe0..16ab4c5 100644 --- a/scripts/validate_windows_packaging.mjs +++ b/scripts/validate_windows_packaging.mjs @@ -27,6 +27,7 @@ for (const expected of [ 'Get-Command "xyte-cli" -All', '$LASTEXITCODE', '"setup", "status", "--field", "state"', + '-eq "ready"', 'setup still needs an API key' ]) { if (!assistant.includes(expected)) { diff --git a/src/cli/index.ts b/src/cli/index.ts index 179b00a..c3041ed 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -877,11 +877,10 @@ 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 loadCheck = () => + checkForUpgrade({ packageName: '@xyteai/cli', latestVersionOverride }, runtime.upgradeDependencies); if (options.check) { - const check = await checkForUpgrade( - { packageName: '@xyteai/cli', latestVersionOverride }, - runtime.upgradeDependencies - ); + const check = await loadCheck(); if (output === 'text') { stdout.write(`Package: ${check.packageName}\n`); stdout.write(`Install channel: ${check.installChannel}\n`); @@ -914,11 +913,7 @@ export function createCli(runtime: CliRuntime = {}): Command { if (output === 'text') { stdout.write('Upgrade canceled.\n'); } else { - const check = await checkForUpgrade( - { packageName: '@xyteai/cli', latestVersionOverride }, - runtime.upgradeDependencies - ); - printJson(stdout, check, { strictJson: resolveStrictJson({ settings }) }); + printJson(stdout, await loadCheck(), { strictJson: resolveStrictJson({ settings }) }); } return; } From 8fae598d6073df460e68b223bb92b113f0c8cba2 Mon Sep 17 00:00:00 2001 From: Vladimir Porton Date: Thu, 2 Jul 2026 10:29:43 +0300 Subject: [PATCH 18/19] Extend Windows installer CI smoke coverage Three gaps the install smoke could not see: - Run the setup assistant under Windows PowerShell 5.1, the host real users get from the Start Menu (CI previously only exercised pwsh 7). - Resolve the installed Start Menu shortcuts via COM and execute their actual target/arguments, validating the WiX-generated quoting. - Exercise the npm migration path end to end: install the packed tarball as a global, run the assistant with -AssumeYes, and assert the global copy was removed. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 72 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bbe63e..f9c037e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -203,6 +203,78 @@ jobs: 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 From 6f339d9df8201c6bc8a82fea7a06d712960ab99f Mon Sep 17 00:00:00 2001 From: Vladimir Porton Date: Thu, 2 Jul 2026 11:42:37 +0300 Subject: [PATCH 19/19] Record interactive Windows install demo in CI Add a non-gating windows-installer-demo job that installs the MSI with the full msiexec UI - crossing the UILevel >= 5 gate so the Configure Xyte CLI custom action auto-launches, which the silent smoke cannot reach - while screen-recording the desktop with ffmpeg and capturing stills at each phase. The uploaded artifact lets a human watch the interactive install continuation without a Windows machine. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 92 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9c037e..33d8d35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -292,6 +292,98 @@ jobs: 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: