diff --git a/.changeset/standalone-prefer-exe.md b/.changeset/standalone-prefer-exe.md new file mode 100644 index 000000000..0c5c83985 --- /dev/null +++ b/.changeset/standalone-prefer-exe.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-cli": patch +--- + +Prefer `squad.exe` over `squad.cmd` when writing the `squad_state` MCP spec from a standalone bundle on Windows. Since the fix for CVE-2024-27980 Node refuses to spawn a `.cmd` without `shell: true`, so an MCP client that spawns the command directly would fail to start the server. Windows bundles now ship a real `squad.exe`, and the resolver picks it first. diff --git a/.github/workflows/squad-standalone-release.yml b/.github/workflows/squad-standalone-release.yml index 71acb734e..51e437e2b 100644 --- a/.github/workflows/squad-standalone-release.yml +++ b/.github/workflows/squad-standalone-release.yml @@ -80,6 +80,17 @@ jobs: - name: Install workspace dependencies run: npm ci --no-audit --no-fund + - name: Ensure unzip is available + # GNU tar cannot read zip archives, so cross-building a Windows bundle + # on a Linux runner needs unzip to unpack the Node runtime. + if: matrix.platform == 'win32' + run: | + set -euo pipefail + command -v unzip >/dev/null 2>&1 || { + sudo apt-get update -qq && sudo apt-get install -y -qq unzip + } + unzip -v | head -n 1 + - name: Build packages run: npm run build @@ -130,8 +141,13 @@ jobs: test -f "${bundle}/BUNDLE-INFO.json" test -d "${bundle}/app/node_modules/@bradygaster/squad-cli" if [ "${TARGET_PLATFORM}" = "win32" ]; then - test -f "${bundle}/runtime/node.exe" + # squad.exe is a Node single executable built from the vendored + # runtime, so it replaces runtime/node.exe rather than sitting + # next to it. winget requires this real .exe — its portable + # installer does not accept .cmd targets. + test -f "${bundle}/squad.exe" test -f "${bundle}/squad.cmd" + test ! -d "${bundle}/runtime" else test -f "${bundle}/runtime/bin/node" test -f "${bundle}/squad" @@ -178,3 +194,61 @@ jobs: run: | set -euo pipefail gh release upload "${TAG}" ./* --clobber --repo "${GITHUB_REPOSITORY}" + + packaging: + name: Generate Homebrew and winget manifests + needs: publish + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v6 + with: + node-version: 22 + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c #v8 + with: + path: artifacts + merge-multiple: true + + - name: Generate manifests + env: + TAG: ${{ github.event.release.tag_name || github.ref_name }} + run: | + set -euo pipefail + cat artifacts/*.sha256 > "${RUNNER_TEMP}/SHA256SUMS.txt" + node scripts/generate-packaging.mjs \ + --version "${TAG}" \ + --checksums "${RUNNER_TEMP}/SHA256SUMS.txt" \ + --out-dir dist-packaging + + # Submitting to homebrew-core and winget-pkgs requires pushing to + # repositories this workflow has no credentials for, so the manifests are + # published as an artifact for a maintainer to submit. Automating the + # submission is follow-up work once the packages are accepted once. + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7 + with: + name: packaging-manifests + path: dist-packaging/ + retention-days: 30 + if-no-files-found: error + + - name: Summarize + env: + TAG: ${{ github.event.release.tag_name || github.ref_name }} + run: | + set -euo pipefail + { + echo "### Packaging manifests for ${TAG}" + echo "" + echo "Download the \`packaging-manifests\` artifact, then:" + echo "" + # shellcheck disable=SC2016 + echo '- Homebrew — copy `homebrew/squad.rb` into the tap as `Casks/squad.rb`.' + # shellcheck disable=SC2016 + echo '- winget — copy `winget/` into `winget-pkgs/manifests/b/bradygaster/Squad//`,' + # shellcheck disable=SC2016 + echo ' validate with `winget validate --manifest `, then open the PR.' + } >> "${GITHUB_STEP_SUMMARY}" + diff --git a/cspell.json b/cspell.json index bc2b94c80..3a8bdd232 100644 --- a/cspell.json +++ b/cspell.json @@ -134,6 +134,7 @@ "Permadeath", "permadeath", "PKCE", + "pkgs", "pluggability", "Pomodoro", "pomodoro", diff --git a/docs/src/content/docs/features/standalone-install.md b/docs/src/content/docs/features/standalone-install.md index 06eb083cd..a3344651f 100644 --- a/docs/src/content/docs/features/standalone-install.md +++ b/docs/src/content/docs/features/standalone-install.md @@ -40,10 +40,25 @@ curl -fsSL https://raw.githubusercontent.com/bradygaster/squad/dev/scripts/insta ### Windows -Download `squad-win32-x64.zip` (or `squad-win32-arm64.zip`) from the +Install with winget: + +```powershell +winget install bradygaster.Squad +``` + +Or download `squad-win32-x64.zip` (or `squad-win32-arm64.zip`) from the [releases page](https://github.com/bradygaster/squad/releases), unpack it, and -add the folder to your `PATH`. The bundle ships both `squad.cmd` and -`squad.ps1`. +add the folder to your `PATH`. + +### macOS + +Install with Homebrew: + +```sh +brew install --cask bradygaster/squad/squad +``` + +Or use the install script above, which works on macOS too. ### Direct download @@ -59,8 +74,8 @@ SHA256SUMS.txt ``` squad--/ -├── squad launcher (squad.cmd + squad.ps1 on Windows) -├── runtime/ vendored Node.js runtime +├── squad launcher (squad.exe + squad.cmd + squad.ps1 on Windows) +├── runtime/ vendored Node.js runtime (POSIX only — see below) ├── app/ squad-cli, squad-sdk, templates, presets └── BUNDLE-INFO.json version, target, vendored Node version, build time ``` @@ -73,6 +88,23 @@ path at runtime, so the bundle deliberately preserves a real directory layout rather than compiling to a single executable. That keeps behavior identical to an npm install with no source changes. +### Why Windows bundles ship a real `squad.exe` + +winget's portable installer only creates command aliases for `.exe` targets — +`.cmd` and `.bat` are explicitly unsupported — so a package pointing at +`squad.cmd` would install something that cannot be invoked. + +Windows bundles therefore ship `squad.exe`, built with Node's +[single executable application](https://nodejs.org/api/single-executable-applications.html) +support. It is the vendored Node runtime with a small launcher embedded, so it +*replaces* `runtime/node.exe` rather than adding to the bundle: a Windows bundle +is the same size as before. `squad.cmd` and `squad.ps1` remain for anyone who +unpacks the archive and runs it in place. + +The launcher resolves the bundle from its own location, following a symlink if +it finds one, because winget installs portables by symlinking the executable +into a links directory. + ## Prerequisite: Copilot CLI Squad drives the GitHub Copilot CLI, which is **not** included in the bundle. @@ -174,20 +206,38 @@ need telemetry export or the `sql.js` state backend inside the bundle. npm is still used at *build* time to resolve the dependency tree. It is the **runtime** dependency on the registry that these bundles remove. +## Packaging manifests + +Homebrew and winget both embed the release version and a SHA-256 per artifact, +so their manifests cannot be hand-maintained without going stale every release. +They are generated from the release's own `SHA256SUMS.txt`: + +```sh +node scripts/generate-packaging.mjs --version v0.11.0 +``` + +That writes `dist-packaging/homebrew/squad.rb` and the three winget manifests +(version, installer, locale). The release workflow runs this automatically and +attaches the result as a `packaging-manifests` artifact; a maintainer submits +them to the tap and to `winget-pkgs`. + ## Known limitations - **Cross-built bundles are not executed in CI.** A vendored runtime only runs on a matching host, so the release workflow smoke-tests `linux-x64` by invoking the CLI and verifies the other five targets structurally. - **No code signing or notarization yet.** macOS Gatekeeper will quarantine the - downloaded bundle until it is signed and notarized. + downloaded bundle until it is signed and notarized, and injecting the SEA blob + invalidates the Node runtime's original Authenticode signature on Windows, so + SmartScreen will warn. This is the main gap before the Homebrew and winget + packages should be recommended widely. - **Optional deps are excluded by default**, so the bundled CLI has no OpenTelemetry exporter and no `sql.js` state backend unless it was built with `--include-optional`. The container image builds with them included. -- **The installer is POSIX-only.** Windows installs are download-and-unpack; - winget and Homebrew packaging are not part of this yet. -- **`squad upgrade` does not manage bundles.** Re-run the install script (or - pull a newer image) to move between versions. +- **The install script is POSIX-only.** On Windows use winget or + download-and-unpack. +- **`squad upgrade` does not manage bundles.** Re-run the install script, use + your package manager, or pull a newer image to move between versions. ## Verifying a bundle locally diff --git a/packages/squad-cli/src/cli/core/mcp-spec.ts b/packages/squad-cli/src/cli/core/mcp-spec.ts index a8b80a6c1..e95a07476 100644 --- a/packages/squad-cli/src/cli/core/mcp-spec.ts +++ b/packages/squad-cli/src/cli/core/mcp-spec.ts @@ -77,8 +77,11 @@ export function _resetMcpSpecCache(): void { export function detectStandaloneLauncher(): string | null { const home = process.env[STANDALONE_HOME_ENV]; if (!home) return null; + // Order matters on Windows: prefer the real executable. Since the fix for + // CVE-2024-27980, Node refuses to spawn a `.cmd`/`.bat` without `shell:true`, + // so an MCP client that spawns the command directly would fail on squad.cmd. const candidates = process.platform === 'win32' - ? ['squad.cmd', 'squad.ps1'] + ? ['squad.exe', 'squad.cmd', 'squad.ps1'] : ['squad']; for (const candidate of candidates) { const full = path.join(home, candidate); diff --git a/scripts/build-standalone.mjs b/scripts/build-standalone.mjs index f6223e7db..a7b6a5986 100644 --- a/scripts/build-standalone.mjs +++ b/scripts/build-standalone.mjs @@ -103,11 +103,16 @@ Targets: ${SUPPORTED.join(', ')}`); } function run(cmd, cmdArgs, cwd) { - return execFileSync(cmd, cmdArgs, { + // shell:true is needed on Windows so npm/npx (.cmd shims) resolve, but the + // shell then splits an unquoted command path on spaces — which breaks + // anything under "C:\Program Files\". + const useShell = process.platform === 'win32'; + const command = useShell && /\s/.test(cmd) ? `"${cmd}"` : cmd; + return execFileSync(command, cmdArgs, { cwd, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8', - shell: process.platform === 'win32', + shell: useShell, }); } @@ -186,20 +191,11 @@ function vendorRuntime(bundleDir, platform, arch, version, stagingDir) { run('curl', ['-fsSL', '-o', archivePath, url], stagingDir); step('Extracting runtime'); - // Extract only what the bundle actually ships. The full POSIX archives - // contain symlinks (bin/npm, bin/npx, bin/corepack) that cannot be created - // on a Windows host, which would break cross-building a Linux or macOS - // bundle from Windows. - const members = platform === 'win32' - // Windows archives keep node.exe plus its ICU data at the archive root. - ? [`${base}/node.exe`] - : [`${base}/bin/node`]; - try { - run('tar', ['-xf', archivePath, '-C', stagingDir, ...members], stagingDir); - } catch (error) { - const detail = error?.stderr?.toString().trim() || error?.message || String(error); - throw new Error(`Failed to extract ${members.join(', ')} from ${file}: ${detail}`); - } + const member = platform === 'win32' + // Windows archives keep node.exe at the archive root. + ? `${base}/node.exe` + : `${base}/bin/node`; + extractArchiveMember(archivePath, member, ext, stagingDir); const extracted = path.join(stagingDir, base); if (!existsSync(extracted)) { @@ -229,9 +225,157 @@ function vendorRuntime(bundleDir, platform, arch, version, stagingDir) { const CLI_ENTRY = 'app/node_modules/@bradygaster/squad-cli/dist/cli-entry.js'; +/** + * Script embedded into the Windows `squad.exe` single executable. + * + * It runs inside a SEA, so `require()` resolves builtins only — external code + * has to be loaded with a dynamic import of a file URL. Because a SEA has no + * script argument, `process.argv` is already `[exe, exe, ...userArgs]`, which + * has the same shape as `[node, script, ...userArgs]`. The CLI therefore sees + * exactly the arguments it expects with no rewriting. + */ +const SEA_LAUNCHER = `const path = require('node:path'); +const fs = require('node:fs'); +const { pathToFileURL } = require('node:url'); + +const ENTRY = ${JSON.stringify(CLI_ENTRY)}; + +// winget installs portables by symlinking the exe into a links directory, and +// process.execPath may report that link rather than the bundle. Try the exe's +// own directory first, then its resolved real path. +function resolveRoot() { + const candidates = [process.execPath]; + try { + const real = fs.realpathSync(process.execPath); + if (real !== process.execPath) candidates.push(real); + } catch {} + for (const candidate of candidates) { + const root = path.dirname(candidate); + if (fs.existsSync(path.join(root, ENTRY))) return root; + } + return null; +} + +const root = resolveRoot(); +if (!root) { + console.error('Squad: could not locate the bundle from ' + process.execPath + '.'); + console.error('The squad.exe launcher must stay alongside its app/ directory.'); + process.exit(1); +} + +process.env.SQUAD_STANDALONE_HOME = root; +import(pathToFileURL(path.join(root, ENTRY)).href).catch((error) => { + console.error('Squad: failed to start.', error && error.message ? error.message : error); + process.exit(1); +}); +`; + +/** + * Extract a single member from a downloaded Node archive. + * + * bsdtar (Windows, macOS) reads zip archives; GNU tar (Linux) does not, so a + * Linux runner cross-building a Windows bundle has to use unzip. Extracting one + * member also avoids the POSIX archives' symlinks (bin/npm, bin/npx, + * bin/corepack), which cannot be created on a Windows host. + */ +function extractArchiveMember(archivePath, member, ext, stagingDir) { + try { + if (ext === 'zip' && process.platform !== 'win32') { + run('unzip', ['-o', '-q', archivePath, member, '-d', stagingDir], stagingDir); + } else { + run('tar', ['-xf', archivePath, '-C', stagingDir, member], stagingDir); + } + } catch (error) { + const detail = error?.stderr?.toString().trim() || error?.message || String(error); + throw new Error(`Failed to extract ${member} from ${path.basename(archivePath)}: ${detail}`); + } +} + +/** + * Obtain a Node binary that can generate a SEA blob for `version`. + * + * The blob format is tied to the Node version that produced it — injecting a + * blob built by a different version into the vendored runtime produces an exe + * that crashes on startup with an access violation. When the host already runs + * the right version we use it directly; otherwise we download that version for + * the *host* platform purely to generate the blob. (The host cannot execute the + * target's binary when cross-building, which is the normal case in CI.) + */ +function resolveBlobBuilder(version, stagingDir) { + if (process.versions.node === version) return process.execPath; + + const hostPlatform = process.platform; + const { base, file, ext } = nodeArtifactName(hostPlatform, process.arch, version); + const archivePath = path.join(stagingDir, `host-${file}`); + + step(`Fetching Node ${version} for the host to generate the SEA blob`); + run('curl', ['-fsSL', '-o', archivePath, `https://nodejs.org/dist/v${version}/${file}`], stagingDir); + + const member = hostPlatform === 'win32' ? `${base}/node.exe` : `${base}/bin/node`; + extractArchiveMember(archivePath, member, ext, stagingDir); + + const builder = path.join(stagingDir, base, ...(hostPlatform === 'win32' ? ['node.exe'] : ['bin', 'node'])); + if (!existsSync(builder)) throw new Error(`Host Node ${version} not found at ${builder}`); + if (hostPlatform !== 'win32') chmodSync(builder, 0o755); + rmSync(archivePath, { force: true }); + return builder; +} + +/** + * Build a real `squad.exe` for Windows bundles using Node's single executable + * application support. + * + * This exists because winget's portable installer only accepts `.exe` targets — + * `.cmd` and `.bat` are explicitly unsupported, so a manifest pointing at + * `squad.cmd` would install something unusable. The SEA is produced from the + * *vendored* runtime for the target architecture, so `squad.exe` replaces + * `runtime/node.exe` entirely rather than adding to the bundle's size. + */ +function buildWindowsExe(bundleDir, stagingDir, nodeVersion) { + const runtimeExe = path.join(bundleDir, 'runtime', 'node.exe'); + if (!existsSync(runtimeExe)) { + throw new Error(`Cannot build squad.exe — vendored runtime missing at ${runtimeExe}`); + } + + const launcherPath = path.join(stagingDir, 'sea-launcher.js'); + const configPath = path.join(stagingDir, 'sea-config.json'); + const blobPath = path.join(stagingDir, 'sea-prep.blob'); + + writeFileSync(launcherPath, SEA_LAUNCHER); + writeFileSync( + configPath, + `${JSON.stringify({ + main: launcherPath, + output: blobPath, + disableExperimentalSEAWarning: true, + // V8 code cache is architecture-specific; leaving it off keeps the blob + // portable so win32-arm64 bundles can be built on an x64 runner. + useCodeCache: false, + useSnapshot: false, + }, null, 2)}\n`, + ); + + const builder = resolveBlobBuilder(nodeVersion, stagingDir); + run(builder, ['--experimental-sea-config', configPath], stagingDir); + if (!existsSync(blobPath)) throw new Error('SEA blob was not produced'); + + const exePath = path.join(bundleDir, 'squad.exe'); + cpSync(runtimeExe, exePath); + run('npx', ['--yes', 'postject', exePath, 'NODE_SEA_BLOB', blobPath, + '--sentinel-fuse', 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2'], stagingDir); + + // squad.exe *is* the runtime now — keeping node.exe would double the size. + rmSync(path.join(bundleDir, 'runtime'), { recursive: true, force: true }); + return exePath; +} + function writeLaunchers(bundleDir, platform, skipRuntime) { if (platform === 'win32') { - const nodeCmd = skipRuntime ? 'node' : '"%~dp0runtime\\node.exe"'; + // With a real squad.exe present the runtime lives inside it; the .cmd and + // .ps1 shims stay for people who unpack the archive and run it in place. + const hasExe = !skipRuntime; + const nodeCmd = hasExe ? '"%~dp0squad.exe"' : 'node'; + const entryArg = hasExe ? '' : ` "%~dp0${CLI_ENTRY.replace(/\//g, '\\')}"`; writeFileSync( path.join(bundleDir, 'squad.cmd'), [ @@ -240,7 +384,7 @@ function writeLaunchers(bundleDir, platform, skipRuntime) { // Lets the CLI detect it is running from a bundle so it writes an // npx-free squad_state MCP spec (see mcp-spec.ts tier 0). 'set "SQUAD_STANDALONE_HOME=%~dp0"', - `${nodeCmd} "%~dp0${CLI_ENTRY.replace(/\//g, '\\')}" %*`, + `${nodeCmd}${entryArg} %*`, 'exit /b %ERRORLEVEL%', '', ].join('\r\n'), @@ -248,15 +392,24 @@ function writeLaunchers(bundleDir, platform, skipRuntime) { // PowerShell shim so `squad` resolves for users whose PATHEXT excludes .CMD. writeFileSync( path.join(bundleDir, 'squad.ps1'), - [ - '$ErrorActionPreference = "Stop"', - '$root = Split-Path -Parent $MyInvocation.MyCommand.Path', - '$env:SQUAD_STANDALONE_HOME = $root', - skipRuntime ? '$node = "node"' : '$node = Join-Path $root "runtime\\node.exe"', - `& $node (Join-Path $root "${CLI_ENTRY.replace(/\//g, '\\')}") @args`, - 'exit $LASTEXITCODE', - '', - ].join('\r\n'), + hasExe + ? [ + '$ErrorActionPreference = "Stop"', + '$root = Split-Path -Parent $MyInvocation.MyCommand.Path', + '$env:SQUAD_STANDALONE_HOME = $root', + '& (Join-Path $root "squad.exe") @args', + 'exit $LASTEXITCODE', + '', + ].join('\r\n') + : [ + '$ErrorActionPreference = "Stop"', + '$root = Split-Path -Parent $MyInvocation.MyCommand.Path', + '$env:SQUAD_STANDALONE_HOME = $root', + '$node = "node"', + `& $node (Join-Path $root "${CLI_ENTRY.replace(/\//g, '\\')}") @args`, + 'exit $LASTEXITCODE', + '', + ].join('\r\n'), ); return; } @@ -326,6 +479,11 @@ function main() { step('Skipping Node runtime (--skip-runtime): bundle will use system node'); } else { vendorRuntime(bundleDir, args.platform, args.arch, args.nodeVersion, stagingDir); + if (args.platform === 'win32') { + // Must run before writeLaunchers so the shims know squad.exe exists. + step('Building squad.exe (single executable)'); + buildWindowsExe(bundleDir, stagingDir, args.nodeVersion); + } } step('Writing launcher'); diff --git a/scripts/generate-packaging.mjs b/scripts/generate-packaging.mjs new file mode 100644 index 000000000..71ce416dd --- /dev/null +++ b/scripts/generate-packaging.mjs @@ -0,0 +1,310 @@ +/** + * Generate Homebrew and winget packaging manifests for a Squad release. + * + * (No shebang: this module is imported by test/packaging-manifests.test.ts, and + * a shebang breaks vitest's module transform on Windows. Matches the shape of + * scripts/pr-readiness.mjs, the repo's other importable script. Run it with + * `node scripts/generate-packaging.mjs`.) + * + * Both ecosystems embed the release version and a SHA-256 per artifact, so the + * manifests cannot be hand-maintained without going stale on every release. + * This renders them from the release's own `SHA256SUMS.txt`. + * + * Usage: + * node scripts/generate-packaging.mjs --version v0.11.0 + * node scripts/generate-packaging.mjs --version v0.11.0 --checksums ./SHA256SUMS.txt + * node scripts/generate-packaging.mjs --version v0.11.0 --out-dir packaging/ + * + * With no --checksums the file is fetched from the GitHub release. + * + * Output: + * /homebrew/squad.rb + * /winget/..yaml (version manifest) + * /winget/..installer.yaml + * /winget/..locale.en-US.yaml + */ + +import { execFileSync } from 'node:child_process'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const DEFAULT_REPO = 'bradygaster/squad'; +const PACKAGE_IDENTIFIER = 'bradygaster.Squad'; +const HOMEPAGE = 'https://github.com/bradygaster/squad'; + +/** Manifest schema version. Matches what winget-pkgs currently accepts. */ +const WINGET_SCHEMA = '1.12.0'; + +/** + * winget's portable installer only creates symlinks to `.exe` targets — `.cmd` + * and `.bat` are explicitly unsupported — so the Windows bundle must ship a + * real executable and the manifest must point at it. + */ +const WINDOWS_ENTRY = 'squad.exe'; + +function parseArgs(argv) { + const args = { + version: null, + checksums: null, + repo: DEFAULT_REPO, + outDir: path.join(REPO_ROOT, 'dist-packaging'), + }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + const next = () => { + const value = argv[++i]; + if (value === undefined) throw new Error(`Missing value for ${arg}`); + return value; + }; + switch (arg) { + case '--version': args.version = next(); break; + case '--checksums': args.checksums = path.resolve(next()); break; + case '--repo': args.repo = next(); break; + case '--out-dir': args.outDir = path.resolve(next()); break; + case '--help': case '-h': printUsage(); process.exit(0); break; + default: throw new Error(`Unknown argument: ${arg}`); + } + } + if (!args.version) throw new Error('--version is required (e.g. --version v0.11.0)'); + if (!/^v?\d+\.\d+\.\d+[A-Za-z0-9.-]*$/.test(args.version)) { + throw new Error(`--version must look like v0.11.0, got "${args.version}"`); + } + return args; +} + +function printUsage() { + console.log(`Generate Homebrew and winget manifests for a Squad release. + +Options: + --version Release tag, e.g. v0.11.0 (required) + --checksums SHA256SUMS.txt to read. Fetched from the release if omitted. + --repo Source repository (default: ${DEFAULT_REPO}) + --out-dir Output directory (default: dist-packaging/) + -h, --help Show this message`); +} + +/** Strip a leading "v" — winget and Homebrew both want a bare semver. */ +function bareVersion(tag) { + return tag.replace(/^v/, ''); +} + +/** + * Parse a `sha256 filename` listing into { asset: sha } . + * Accepts both the GNU (`hash name`) and BSD (`SHA256 (name) = hash`) shapes. + */ +export function parseChecksums(text) { + const map = {}; + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + const bsd = trimmed.match(/^SHA256\s*\((.+)\)\s*=\s*([a-fA-F0-9]{64})$/); + if (bsd) { + map[path.basename(bsd[1])] = bsd[2].toLowerCase(); + continue; + } + const gnu = trimmed.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/); + if (gnu) map[path.basename(gnu[2])] = gnu[1].toLowerCase(); + } + return map; +} + +function loadChecksums(args) { + if (args.checksums) return parseChecksums(readFileSync(args.checksums, 'utf-8')); + const url = `https://github.com/${args.repo}/releases/download/${args.version}/SHA256SUMS.txt`; + const text = execFileSync('curl', ['-fsSL', url], { encoding: 'utf-8' }); + return parseChecksums(text); +} + +function requireAsset(checksums, asset) { + const sha = checksums[asset]; + if (!sha) { + throw new Error( + `No checksum for "${asset}". Present: ${Object.keys(checksums).join(', ') || '(none)'}`, + ); + } + return sha; +} + +function assetUrl(repo, tag, asset) { + return `https://github.com/${repo}/releases/download/${tag}/${asset}`; +} + +/** + * Homebrew cask. + * + * A cask rather than a formula: the bundle ships prebuilt binaries with a + * vendored runtime, so there is nothing to build from source. `binary` works on + * both macOS and Linux, and the launcher is a shell script — which Homebrew is + * happy to symlink (unlike winget, which requires a real executable). + */ +export function renderHomebrewCask({ version, repo, checksums }) { + const v = bareVersion(version); + const armAsset = 'squad-darwin-arm64.tar.gz'; + const intelAsset = 'squad-darwin-x64.tar.gz'; + const armSha = requireAsset(checksums, armAsset); + const intelSha = requireAsset(checksums, intelAsset); + + return `cask "squad" do + arch arm: "arm64", intel: "x64" + + version "${v}" + sha256 arm: "${armSha}", + intel: "${intelSha}" + + url "https://github.com/${repo}/releases/download/v#{version}/squad-darwin-#{arch}.tar.gz", + verified: "github.com/${repo}/" + name "Squad" + desc "Programmable multi-agent runtime for GitHub Copilot" + homepage "${HOMEPAGE}" + + livecheck do + url :url + strategy :github_latest + end + + depends_on macos: ">= :big_sur" + + binary "squad-darwin-#{arch}/squad" + + caveats do + <<~EOS + Squad drives the GitHub Copilot CLI, which is not bundled. Install it with: + brew install --cask copilot-cli + EOS + end + + zap trash: [ + "~/.squad", + "~/Library/Caches/squad", + ] +end +`; +} + +export function renderWingetVersion({ version }) { + return `# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.${WINGET_SCHEMA}.schema.json + +PackageIdentifier: ${PACKAGE_IDENTIFIER} +PackageVersion: ${bareVersion(version)} +DefaultLocale: en-US +ManifestType: version +ManifestVersion: ${WINGET_SCHEMA} +`; +} + +/** + * winget installer manifest. + * + * Modeled on GitHub.Copilot, which ships the same shape: a zip containing a + * portable executable. `NestedInstallerFiles.RelativeFilePath` is relative to + * the archive root, and our archives keep the bundle in a `squad-/` + * directory, so the path includes that prefix. + */ +export function renderWingetInstaller({ version, repo, checksums, releaseDate }) { + const v = bareVersion(version); + const targets = [ + { arch: 'x64', asset: 'squad-win32-x64.zip', dir: 'squad-win32-x64' }, + { arch: 'arm64', asset: 'squad-win32-arm64.zip', dir: 'squad-win32-arm64' }, + ]; + + const installers = targets.map(({ arch, asset, dir }) => { + const sha = requireAsset(checksums, asset).toUpperCase(); + return [ + `- Architecture: ${arch}`, + ` InstallerUrl: ${assetUrl(repo, version, asset)}`, + ` InstallerSha256: ${sha}`, + ' NestedInstallerFiles:', + ` - RelativeFilePath: ${dir}\\${WINDOWS_ENTRY}`, + ' PortableCommandAlias: squad', + ].join('\n'); + }).join('\n'); + + return `# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.${WINGET_SCHEMA}.schema.json + +PackageIdentifier: ${PACKAGE_IDENTIFIER} +PackageVersion: ${v} +MinimumOSVersion: 10.0.17763.0 +InstallerType: zip +NestedInstallerType: portable +Commands: +- squad +${releaseDate ? `ReleaseDate: ${releaseDate}\n` : ''}Installers: +${installers} +ManifestType: installer +ManifestVersion: ${WINGET_SCHEMA} +`; +} + +export function renderWingetLocale({ version, repo }) { + return `# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.${WINGET_SCHEMA}.schema.json + +PackageIdentifier: ${PACKAGE_IDENTIFIER} +PackageVersion: ${bareVersion(version)} +PackageLocale: en-US +Publisher: Brady Gaster +PublisherUrl: https://github.com/bradygaster +PublisherSupportUrl: https://github.com/${repo}/issues +PackageName: Squad +PackageUrl: ${HOMEPAGE} +License: MIT +LicenseUrl: https://github.com/${repo}/blob/HEAD/LICENSE +ShortDescription: Programmable multi-agent runtime for GitHub Copilot +Description: Squad turns GitHub Copilot into a coordinated team of specialist agents. It routes work to the right agent, enforces reviewer gates, and keeps durable team state in your repository. +Moniker: squad +Tags: +- agents +- ai +- cli +- copilot +- multi-agent +ReleaseNotesUrl: https://github.com/${repo}/releases/tag/${version} +Documentations: +- DocumentLabel: Documentation + DocumentUrl: https://bradygaster.github.io/squad/ +ManifestType: defaultLocale +ManifestVersion: ${WINGET_SCHEMA} +`; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const checksums = loadChecksums(args); + const releaseDate = new Date().toISOString().slice(0, 10); + + const brewDir = path.join(args.outDir, 'homebrew'); + const wingetDir = path.join(args.outDir, 'winget'); + mkdirSync(brewDir, { recursive: true }); + mkdirSync(wingetDir, { recursive: true }); + + const written = []; + const emit = (file, content) => { + writeFileSync(file, content); + written.push(path.relative(process.cwd(), file)); + }; + + emit(path.join(brewDir, 'squad.rb'), renderHomebrewCask({ ...args, checksums })); + emit(path.join(wingetDir, `${PACKAGE_IDENTIFIER}.yaml`), renderWingetVersion(args)); + emit( + path.join(wingetDir, `${PACKAGE_IDENTIFIER}.installer.yaml`), + renderWingetInstaller({ ...args, checksums, releaseDate }), + ); + emit(path.join(wingetDir, `${PACKAGE_IDENTIFIER}.locale.en-US.yaml`), renderWingetLocale(args)); + + console.log(`\nGenerated packaging manifests for ${args.version}:\n`); + for (const file of written) console.log(` ${file}`); + console.log(` +Next steps: + Homebrew — copy squad.rb into the tap repo (Casks/squad.rb) and open a PR. + winget — copy the winget/ directory into + winget-pkgs/manifests/b/bradygaster/Squad/${bareVersion(args.version)}/ + then validate with: winget validate --manifest +`); +} + +// Only run when invoked directly, so the renderers stay unit-testable. +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/test/mcp-spec-init.test.ts b/test/mcp-spec-init.test.ts index 0ae28ff01..4aa4091d0 100644 --- a/test/mcp-spec-init.test.ts +++ b/test/mcp-spec-init.test.ts @@ -190,6 +190,17 @@ describe('detectStandaloneLauncher (#1593)', () => { process.env[STANDALONE_HOME_ENV] = dir; expect(detectStandaloneLauncher()).toBe(path.join(dir, name)); }); + + it.runIf(process.platform === 'win32')('prefers squad.exe over squad.cmd on Windows', () => { + // Since the fix for CVE-2024-27980 Node refuses to spawn a .cmd without + // shell:true, so an MCP client spawning the command directly would fail. + // The .exe must win whenever the bundle ships one. + const dir = mkdtempSync(path.join(tmpdir(), 'squad-bundle-exe-')); + writeFileSync(path.join(dir, 'squad.cmd'), '@echo off\n'); + writeFileSync(path.join(dir, 'squad.exe'), 'MZ'); + process.env[STANDALONE_HOME_ENV] = dir; + expect(detectStandaloneLauncher()).toBe(path.join(dir, 'squad.exe')); + }); }); describe('describeMcpSpec — standalone specs (#1593)', () => { diff --git a/test/packaging-manifests.test.ts b/test/packaging-manifests.test.ts new file mode 100644 index 000000000..a2175d683 --- /dev/null +++ b/test/packaging-manifests.test.ts @@ -0,0 +1,152 @@ +/** + * Tests for scripts/generate-packaging.mjs. + * + * The renderers are pure, so these exercise them directly rather than shelling + * out. The assertions focus on the things that silently produce a broken + * package rather than a failed build: wrong checksum casing, a non-exe winget + * target, or a stale version leaking into one manifest but not another. + */ + +import { describe, it, expect } from 'vitest'; +import { + parseChecksums, + renderHomebrewCask, + renderWingetInstaller, + renderWingetLocale, + renderWingetVersion, +} from '../scripts/generate-packaging.mjs'; + +const SHA = { + darwinArm: 'a'.repeat(64), + darwinX64: 'b'.repeat(64), + winX64: 'c'.repeat(64), + winArm: 'd'.repeat(64), +}; + +const CHECKSUMS = { + 'squad-darwin-arm64.tar.gz': SHA.darwinArm, + 'squad-darwin-x64.tar.gz': SHA.darwinX64, + 'squad-win32-x64.zip': SHA.winX64, + 'squad-win32-arm64.zip': SHA.winArm, +}; + +const BASE = { version: 'v1.2.3', repo: 'bradygaster/squad', checksums: CHECKSUMS }; + +describe('parseChecksums', () => { + it('parses GNU sha256sum output', () => { + const parsed = parseChecksums(`${SHA.winX64} squad-win32-x64.zip\n${SHA.darwinArm} squad-darwin-arm64.tar.gz\n`); + expect(parsed['squad-win32-x64.zip']).toBe(SHA.winX64); + expect(parsed['squad-darwin-arm64.tar.gz']).toBe(SHA.darwinArm); + }); + + it('parses BSD shasum output', () => { + // macOS runners produce this shape; the release workflow may not always be + // the only producer of a checksum file. + const parsed = parseChecksums(`SHA256 (squad-darwin-x64.tar.gz) = ${SHA.darwinX64}`); + expect(parsed['squad-darwin-x64.tar.gz']).toBe(SHA.darwinX64); + }); + + it('strips directory prefixes and normalizes case', () => { + const parsed = parseChecksums(`${SHA.winArm.toUpperCase()} ./dist/squad-win32-arm64.zip`); + expect(parsed['squad-win32-arm64.zip']).toBe(SHA.winArm); + }); + + it('ignores blank lines and junk', () => { + const parsed = parseChecksums(`\n\nnot a checksum line\n${SHA.winX64} squad-win32-x64.zip\n`); + expect(Object.keys(parsed)).toEqual(['squad-win32-x64.zip']); + }); +}); + +describe('renderHomebrewCask', () => { + const cask = renderHomebrewCask(BASE); + + it('emits both arch checksums against the right assets', () => { + expect(cask).toContain(`arm: "${SHA.darwinArm}"`); + expect(cask).toContain(`intel: "${SHA.darwinX64}"`); + }); + + it('uses the bare version, not the tag', () => { + expect(cask).toContain('version "1.2.3"'); + expect(cask).not.toContain('version "v1.2.3"'); + }); + + it('installs the launcher via the binary stanza', () => { + // A cask, not a formula: the bundle is prebuilt with a vendored runtime, + // so there is nothing to compile. + expect(cask).toMatch(/binary "squad-darwin-#\{arch\}\/squad"/); + }); + + it('tells users Copilot CLI is not bundled', () => { + // Squad drives the copilot CLI and deliberately does not vendor it. + expect(cask).toContain('copilot-cli'); + }); + + it('fails loudly when an asset is missing rather than emitting an empty sha', () => { + expect(() => renderHomebrewCask({ ...BASE, checksums: {} })) + .toThrow(/No checksum for "squad-darwin-arm64.tar.gz"/); + }); +}); + +describe('renderWingetInstaller', () => { + const installer = renderWingetInstaller(BASE); + + it('targets squad.exe, never squad.cmd', () => { + // winget's portable installer only symlinks .exe targets; pointing it at a + // .cmd would install something that cannot be invoked. + expect(installer).toContain('squad-win32-x64\\squad.exe'); + expect(installer).toContain('squad-win32-arm64\\squad.exe'); + expect(installer).not.toContain('.cmd'); + expect(installer).not.toContain('.ps1'); + }); + + it('declares the zip + portable pairing winget requires', () => { + expect(installer).toContain('InstallerType: zip'); + expect(installer).toContain('NestedInstallerType: portable'); + }); + + it('uppercases checksums as winget-pkgs expects', () => { + expect(installer).toContain(SHA.winX64.toUpperCase()); + expect(installer).not.toContain(SHA.winX64); + }); + + it('covers both Windows architectures', () => { + expect(installer).toContain('Architecture: x64'); + expect(installer).toContain('Architecture: arm64'); + }); + + it('keeps the tag in URLs but the bare version in PackageVersion', () => { + expect(installer).toContain('PackageVersion: 1.2.3'); + expect(installer).toContain('/releases/download/v1.2.3/'); + }); + + it('fails loudly when a Windows asset is missing', () => { + expect(() => renderWingetInstaller({ ...BASE, checksums: {} })) + .toThrow(/No checksum for "squad-win32-x64.zip"/); + }); +}); + +describe('winget version and locale manifests', () => { + it('agree on identifier and version with the installer manifest', () => { + // winget-pkgs rejects a version directory whose three manifests disagree. + const all = [ + renderWingetVersion(BASE), + renderWingetInstaller(BASE), + renderWingetLocale(BASE), + ]; + for (const manifest of all) { + expect(manifest).toContain('PackageIdentifier: bradygaster.Squad'); + expect(manifest).toContain('PackageVersion: 1.2.3'); + expect(manifest).toContain('ManifestVersion: 1.12.0'); + } + }); + + it('declares the manifest types winget expects', () => { + expect(renderWingetVersion(BASE)).toContain('ManifestType: version'); + expect(renderWingetInstaller(BASE)).toContain('ManifestType: installer'); + expect(renderWingetLocale(BASE)).toContain('ManifestType: defaultLocale'); + }); + + it('points the locale manifest at the release notes for the same tag', () => { + expect(renderWingetLocale(BASE)).toContain('/releases/tag/v1.2.3'); + }); +}); diff --git a/test/standalone-build.test.ts b/test/standalone-build.test.ts index 3ed0667dc..50dfcda1e 100644 --- a/test/standalone-build.test.ts +++ b/test/standalone-build.test.ts @@ -24,7 +24,6 @@ describe('build-standalone.mjs — runtime extraction', () => { // because node's archives contain symlinks (bin/npm, bin/npx, // bin/corepack) that Windows cannot create — which broke cross-building // a linux bundle from Windows with "Can't create ... Invalid argument". - expect(build).toMatch(/const members = platform === 'win32'/); expect(build).toContain('${base}/bin/node'); expect(build).toContain('${base}/node.exe'); @@ -32,6 +31,14 @@ describe('build-standalone.mjs — runtime extraction', () => { expect(build).not.toMatch(/run\('tar', \['-xf', archivePath, '-C', stagingDir\]/); }); + it('uses unzip for zip archives on non-Windows hosts', () => { + // GNU tar (Linux) cannot read zip archives while bsdtar (Windows, macOS) + // can. The release workflow builds every target on ubuntu-latest, so + // without this every win32 bundle would fail to unpack its runtime. + expect(build).toMatch(/ext === 'zip' && process\.platform !== 'win32'/); + expect(build).toMatch(/run\('unzip'/); + }); + it('surfaces the underlying tar error instead of a bare exit code', () => { expect(build).toMatch(/Failed to extract .* from/); }); @@ -39,6 +46,52 @@ describe('build-standalone.mjs — runtime extraction', () => { it('marks the vendored posix node binary executable', () => { expect(build).toMatch(/chmodSync\(path\.join\(runtimeDir, 'bin', 'node'\), 0o755\)/); }); + + it('quotes the command when shelling out on Windows', () => { + // process.execPath is under "C:\Program Files\" on most Windows hosts, and + // execFileSync with shell:true splits an unquoted path on the space. + expect(build).toMatch(/useShell && \/\\s\/\.test\(cmd\)/); + }); +}); + +describe('build-standalone.mjs — Windows squad.exe', () => { + it('builds a real executable via Node SEA', () => { + // winget's portable installer only symlinks .exe targets, so a bundle + // whose entry point is squad.cmd cannot be packaged for winget at all. + expect(build).toContain('buildWindowsExe'); + expect(build).toContain('--experimental-sea-config'); + expect(build).toContain('NODE_SEA_BLOB'); + }); + + it('generates the blob with a Node matching the vendored runtime', () => { + // The SEA blob format is tied to the Node version that produced it. + // Injecting a mismatched blob yields an exe that dies at startup with an + // access violation, which is not obvious from the build output. + expect(build).toContain('resolveBlobBuilder'); + expect(build).toMatch(/process\.versions\.node === version/); + }); + + it('keeps the blob architecture-independent', () => { + // V8 code cache is arch-specific; leaving it off lets an x64 runner build + // the win32-arm64 bundle. + expect(build).toContain('useCodeCache: false'); + }); + + it('drops the vendored runtime once squad.exe exists', () => { + // squad.exe *is* the runtime; keeping node.exe too would double the size. + expect(build).toMatch(/rmSync\(path\.join\(bundleDir, 'runtime'\)/); + }); + + it('resolves the bundle through a symlink in the SEA launcher', () => { + // winget installs portables by symlinking the exe into a links directory, + // so process.execPath may not be inside the bundle. + expect(build).toContain('realpathSync'); + expect(build).toContain('resolveRoot'); + }); + + it('sets SQUAD_STANDALONE_HOME from the SEA launcher too', () => { + expect(build).toMatch(/process\.env\.SQUAD_STANDALONE_HOME = root/); + }); }); describe('build-standalone.mjs — launchers', () => {