From 01abaa67aac4ca7153da0ac65737305679991c7c Mon Sep 17 00:00:00 2001 From: Tamir Dresher <342800+tamirdresher@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:43:06 +0300 Subject: [PATCH 1/5] feat(dist): npm-free MCP spec, squad-init action, and container image (#1593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second slice of the standalone distribution work. Builds on the bundle mechanism from the previous commit and closes the gap that made it incomplete. The gap: installing Squad from a bundle removed npm from *installation*, but `squad init` still wrote an npx-based `squad_state` MCP entry into `.mcp.json`, and probed registry.npmjs.org to choose the version. On a firewalled machine the CLI installed fine and then the MCP server silently failed to launch on first use — the exact failure mode this work exists to prevent. What changed: - mcp-spec.ts gains a tier-0 ahead of the two npx tiers. When SQUAD_STANDALONE_HOME is set (exported by the bundle launchers) the spec points at that bundle's launcher by absolute path with args ['state-mcp']. It short-circuits before the registry probe, so a machine with no route to npm makes no network call here at all. Absolute because Copilot spawns the MCP server in its own environment, where neither PATH nor that variable is guaranteed. - describeMcpSpec() previously read args[1] to name the package; standalone specs have no package there and would have printed ''. It now reports the launcher path. - build-standalone.mjs launchers (sh, cmd, ps1) export SQUAD_STANDALONE_HOME. - .github/actions/squad-init — composite action that installs from a release bundle and runs squad init. This is the direct replacement for the `npx @bradygaster/squad-cli init` step in the gh-aw activation job (#1587). It also fails the build if the resulting .mcp.json still mentions npx. - Dockerfile + .dockerignore — image built from a bundle instead of `npm install -g`, honoring the existing container contract in docs/reference/container-image.md (WORKDIR /app, non-root uid 1001, SIGTERM, `squad watch --execute`). Sets SQUAD_STANDALONE_HOME=/opt/squad. Built with --include-optional because the contract documents OTLP export, which lives in the optional OpenTelemetry packages. - .gitattributes pins LF for Dockerfile and .dockerignore. CRLF breaks RUN line continuations when the image builds on Linux. Verified: - 13 new tests in mcp-spec-init.test.ts, including an explicit assertion that the registry probe never fires in standalone mode. 23/23 pass. - Built a bundle and ran `squad init` from it on win32-x64: the generated .mcp.json contains command=/squad.cmd, args=['state-mcp'], no npx. - Runtime stage of the Dockerfile built and exercised on linux/amd64 with a host-built bundle: `squad --version` returns 0.11.0 as the non-root squad user, `which squad` resolves to /opt/squad/squad, `squad init` writes command=/opt/squad/squad, and `squad state-mcp` answers a JSON-RPC initialize handshake with serverInfo.name=squad-state. - lint, docs-build, mcp-root-write, state-mcp, cspell, markdownlint all clean. Not verified: the Dockerfile builder stage. This machine intercepts TLS, so every container gets ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE reaching registry.npmjs.org — reproducible with a bare `docker run node:22-slim npm view lodash`. Not a Dockerfile defect, but the full image has never been built end to end and CI must prove it. Likewise the squad-init action cannot run until a release with bundle assets exists. Refs #1593, #1587 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d2a1f506-af41-4d6d-b497-d0a5b255d4cf --- .changeset/standalone-mcp-spec-1593.md | 5 + .dockerignore | 15 ++ .gitattributes | 5 + .github/actions/squad-init/action.yml | 172 ++++++++++++++++++ Dockerfile | 71 ++++++++ .../docs/features/standalone-install.md | 56 +++++- packages/squad-cli/src/cli/core/mcp-spec.ts | 68 ++++++- packages/squad-cli/src/cli/core/upgrade.ts | 7 +- scripts/build-standalone.mjs | 16 +- test/mcp-spec-init.test.ts | 108 ++++++++++- 10 files changed, 510 insertions(+), 13 deletions(-) create mode 100644 .changeset/standalone-mcp-spec-1593.md create mode 100644 .dockerignore create mode 100644 .github/actions/squad-init/action.yml create mode 100644 Dockerfile diff --git a/.changeset/standalone-mcp-spec-1593.md b/.changeset/standalone-mcp-spec-1593.md new file mode 100644 index 000000000..c39097407 --- /dev/null +++ b/.changeset/standalone-mcp-spec-1593.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-cli": patch +--- + +Write an npx-free `squad_state` MCP spec when Squad runs from a standalone bundle. Previously `squad init` and `squad upgrade` always emitted `npx -y @bradygaster/squad-cli@ state-mcp` into `.mcp.json`, and probed registry.npmjs.org to pick the version — so a machine installed without npm access still ended up with an MCP entry it could not launch. The resolver now detects a bundle via `SQUAD_STANDALONE_HOME` and points at that bundle's launcher by absolute path, short-circuiting before the registry probe. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..3492f3eae --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +# Keep the build context small and avoid copying host-built artifacts into the +# image — the builder stage compiles everything from source. +node_modules +**/node_modules +dist +**/dist +dist-standalone +docs/dist +coverage +.git +.github +.squad/.scratch +*.log +*.tsbuildinfo +.env diff --git a/.gitattributes b/.gitattributes index 93b2b53e2..be741a10c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -20,3 +20,8 @@ # Squad: shell scripts must stay LF — CRLF breaks the shebang on Linux/macOS. *.sh text eol=lf + +# Squad: CRLF breaks RUN line-continuations when the image builds on Linux. +Dockerfile text eol=lf +Dockerfile.* text eol=lf +.dockerignore text eol=lf diff --git a/.github/actions/squad-init/action.yml b/.github/actions/squad-init/action.yml new file mode 100644 index 000000000..175044215 --- /dev/null +++ b/.github/actions/squad-init/action.yml @@ -0,0 +1,172 @@ +# Composite action: install Squad from a standalone bundle and initialize a team. +# +# Installs the Squad CLI from a GitHub Release asset rather than npm, so this +# works on runners that cannot reach registry.npmjs.org — self-hosted agents +# behind a corporate firewall, locked-down build machines, air-gapped mirrors. +# +# This is the npm-free replacement for the `npx @bradygaster/squad-cli init` +# step in the gh-aw activation job (see the limitation documented in #1587, +# and the RFC in #1593). +# +# Usage: +# - uses: bradygaster/squad/.github/actions/squad-init@ +# with: +# version: 'v0.11.0' # default: latest release +# preset: 'default' # default: default +# state-backend: 'local' # default: local +# +# Every input is optional; the defaults match what the gh-aw shared component +# runs today. + +name: 'Squad Init (npm-free)' +description: 'Install Squad from a standalone release bundle and run squad init — no npm registry access required' + +inputs: + version: + description: 'Squad release tag to install (e.g. v0.11.0). Defaults to the latest release.' + required: false + default: 'latest' + preset: + description: 'Preset passed to squad init' + required: false + default: 'default' + state-backend: + description: 'State backend passed to squad init: local, or a backend supported by your Squad version' + required: false + default: 'local' + install-prefix: + description: 'Install prefix for the bundle. The launcher is linked into /bin.' + required: false + default: '' + repository: + description: 'Repository to download release assets from. Point this at an internal mirror when github.com is unreachable.' + required: false + default: 'bradygaster/squad' + skip-init: + description: 'Install the CLI but do not run squad init' + required: false + default: 'false' + working-directory: + description: 'Directory to run squad init in' + required: false + default: '.' + +outputs: + squad-path: + description: 'Absolute path to the installed squad launcher' + value: ${{ steps.install.outputs.squad-path }} + version: + description: 'Squad version that was installed' + value: ${{ steps.install.outputs.version }} + +runs: + using: 'composite' + steps: + - name: Validate inputs + shell: bash + env: + INPUT_VERSION: ${{ inputs.version }} + INPUT_PRESET: ${{ inputs.preset }} + INPUT_BACKEND: ${{ inputs.state-backend }} + INPUT_REPO: ${{ inputs.repository }} + INPUT_SKIP_INIT: ${{ inputs.skip-init }} + run: | + set -euo pipefail + # These values reach a shell command below, so constrain them tightly. + if ! echo "${INPUT_VERSION}" | LC_ALL=C grep -qE '^(latest|v?[0-9]+\.[0-9]+\.[0-9]+[A-Za-z0-9.-]*)$'; then + echo "::error::version must be 'latest' or a semver tag (e.g. v0.11.0)" + exit 1 + fi + for pair in "preset:${INPUT_PRESET}" "state-backend:${INPUT_BACKEND}"; do + name="${pair%%:*}"; value="${pair#*:}" + if ! echo "${value}" | LC_ALL=C grep -qE '^[A-Za-z0-9_.-]+$'; then + echo "::error::${name} contains invalid characters (only alphanumeric, ., -, _ allowed)" + exit 1 + fi + done + if ! echo "${INPUT_REPO}" | LC_ALL=C grep -qE '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$'; then + echo "::error::repository must be in owner/repo form" + exit 1 + fi + if [ "${INPUT_SKIP_INIT}" != "true" ] && [ "${INPUT_SKIP_INIT}" != "false" ]; then + echo "::error::skip-init must be 'true' or 'false'" + exit 1 + fi + + - name: Install Squad from release bundle + id: install + shell: bash + env: + INPUT_VERSION: ${{ inputs.version }} + INPUT_PREFIX: ${{ inputs.install-prefix }} + INPUT_REPO: ${{ inputs.repository }} + run: | + set -euo pipefail + prefix="${INPUT_PREFIX:-${HOME}/.squad-standalone}" + script_url="https://raw.githubusercontent.com/${INPUT_REPO}/HEAD/scripts/install.sh" + + curl -fsSL "${script_url}" -o "${RUNNER_TEMP}/squad-install.sh" + VERSION="${INPUT_VERSION}" PREFIX="${prefix}" REPO="${INPUT_REPO}" \ + sh "${RUNNER_TEMP}/squad-install.sh" + + squad_bin="${prefix}/bin/squad" + if [ ! -x "${squad_bin}" ]; then + echo "::error::squad was not installed at ${squad_bin}" + exit 1 + fi + + echo "${prefix}/bin" >> "${GITHUB_PATH}" + echo "squad-path=${squad_bin}" >> "${GITHUB_OUTPUT}" + echo "version=$("${squad_bin}" --version)" >> "${GITHUB_OUTPUT}" + + - name: Verify no npm registry dependency + shell: bash + env: + SQUAD_BIN: ${{ steps.install.outputs.squad-path }} + run: | + set -euo pipefail + # The whole point of this action is that the installed CLI runs without + # the npm registry. Prove the binary is self-contained before we rely + # on it, rather than discovering it mid-init. + "${SQUAD_BIN}" --version > /dev/null + echo "Squad $("${SQUAD_BIN}" --version) installed and runnable without npm" + + - name: Run squad init + if: inputs.skip-init != 'true' + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + SQUAD_BIN: ${{ steps.install.outputs.squad-path }} + INPUT_PRESET: ${{ inputs.preset }} + INPUT_BACKEND: ${{ inputs.state-backend }} + run: | + set -euo pipefail + "${SQUAD_BIN}" init --preset "${INPUT_PRESET}" --state-backend "${INPUT_BACKEND}" + + - name: Summarize + if: inputs.skip-init != 'true' + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + SQUAD_VERSION: ${{ steps.install.outputs.version }} + run: | + set -euo pipefail + if [ ! -f .squad/team.md ]; then + echo "::error::squad init completed but .squad/team.md is missing" + exit 1 + fi + { + echo "### Squad ${SQUAD_VERSION} initialized" + echo "" + echo "Installed from a standalone release bundle — no npm registry access used." + echo "" + # The MCP spec is the thing that silently breaks when a firewalled + # machine writes an npx-based launcher, so surface it here. + if [ -f .mcp.json ] && grep -q '"npx"' .mcp.json; then + echo "> [!WARNING]" + echo "> \`.mcp.json\` still references npx. squad_state will fail to start" + echo "> on a runner without npm registry access." + else + echo "\`.mcp.json\` points at the bundled launcher (no npx)." + fi + } >> "${GITHUB_STEP_SUMMARY}" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..65ed8e562 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,71 @@ +# syntax=docker/dockerfile:1.7 +# +# Squad container image — built from a standalone bundle, no npm at runtime. +# +# Honors the environment-variable and path contract documented in +# docs/src/content/docs/reference/container-image.md (WORKDIR /app, non-root +# uid 1001, SIGTERM drain, `squad watch --execute` as the default command). +# +# The difference from the reference Dockerfile in that page is where the CLI +# comes from: instead of `npm install -g @bradygaster/squad-cli` at image build +# time, this copies a self-contained bundle produced by +# scripts/build-standalone.mjs. npm is used only inside the builder stage, so +# the final image has no dependency on registry.npmjs.org — matching the goal +# of #1593 and the limitation deferred in #1587. +# +# Build: +# docker build -t squad:local . +# +# Run: +# docker run --rm -e GITHUB_TOKEN=... -v "$PWD/.squad:/app/.squad" squad:local + +# ---------------------------------------------------------------- builder --- +FROM node:22-alpine AS builder +WORKDIR /build + +# The root package.json declares a workspace with a `file:` dependency on +# packages/squad-sdk, so a manifest-only cache layer cannot resolve — copy the +# source first, then install. +COPY . . +RUN npm ci --no-audit --no-fund + +# --skip-runtime: the runtime stage is already a Node image, so vendoring a +# second Node would only add weight — and the official nodejs.org builds are +# glibc, which would not run on this musl base anyway. +# --include-optional: the container genuinely needs the optional dependencies. +# `squad watch --execute` spawns the Copilot CLI, and the container contract +# documents OTLP export, which lives in the optional OpenTelemetry packages. +RUN npm run build \ + && node scripts/build-standalone.mjs \ + --platform linux --arch x64 \ + --skip-runtime --include-optional \ + --out-dir /out + +# ---------------------------------------------------------------- runtime --- +FROM node:22-alpine AS runtime + +# Non-root user (UID 1001 avoids common conflicts with bind mounts). +RUN addgroup --system squad && adduser --system --ingroup squad --uid 1001 squad + +WORKDIR /app + +# The bundle: launcher + app/node_modules (squad-cli, squad-sdk, templates, +# presets, and the Copilot CLI platform binary). +COPY --from=builder --chown=squad:squad /out/squad-linux-x64 /opt/squad + +# `squad` on PATH, plus the bundled Copilot CLI that `squad watch` spawns. +ENV PATH="/opt/squad:/opt/squad/app/node_modules/.bin:${PATH}" + +# Tells the CLI it is running from a bundle, so `squad init` writes a +# squad_state MCP spec that points at this launcher instead of npx — which +# would be unrunnable in an image with no npm registry access (#1593). +ENV SQUAD_STANDALONE_HOME=/opt/squad + +USER squad + +# Squad handles SIGTERM: drains in-flight work, then exits cleanly. +STOPSIGNAL SIGTERM + +# GITHUB_TOKEN must be provided at runtime — never bake it into the image. +# Inject via Kubernetes Secret, ACA Key Vault reference, or CSI driver. +CMD ["squad", "watch", "--execute"] diff --git a/docs/src/content/docs/features/standalone-install.md b/docs/src/content/docs/features/standalone-install.md index 855c2e75c..55bce6992 100644 --- a/docs/src/content/docs/features/standalone-install.md +++ b/docs/src/content/docs/features/standalone-install.md @@ -91,6 +91,19 @@ The bundles are what make an npm-free CI job possible — including the [gh-aw](/features/gh-aw/) activation job, which previously required `npx` and so could not run on a runner without npm registry access. +The `squad-init` action wraps the install and init steps: + +```yaml +- uses: bradygaster/squad/.github/actions/squad-init@ + with: + version: v0.11.0 # default: latest release + preset: default + state-backend: local +``` + +Point `repository:` at an internal mirror if your runners cannot reach +`github.com` either. To do it by hand instead: + ```yaml - name: Install Squad env: @@ -104,8 +117,43 @@ could not run on a runner without npm registry access. run: squad init --preset default --state-backend local ``` -Set `REPO` to an internal mirror if your runners cannot reach `github.com` -either. +## Containers + +The repository `Dockerfile` builds an image from a bundle rather than +`npm install -g`, so the resulting image has no runtime dependency on the npm +registry. It follows the same contract as every other Squad image — see +[Container Image](/reference/container-image/) for the environment variables, +paths, and shutdown behavior. + +```sh +docker build -t squad:local . +docker run --rm -e GITHUB_TOKEN=... -v "$PWD/.squad:/app/.squad" squad:local +``` + +The image sets `SQUAD_STANDALONE_HOME=/opt/squad`, which is what makes +`squad init` inside the container write an npx-free MCP spec (below). + +## The squad_state MCP server + +`squad init` writes a `squad_state` MCP entry into `.mcp.json` so Copilot can +reach Squad's state tools. Normally that entry launches through `npx`: + +```json +{ "command": "npx", "args": ["-y", "@bradygaster/squad-cli@0.11.0", "state-mcp"] } +``` + +That would defeat the purpose here — the CLI would install fine from a bundle, +then the MCP server would fail to start on the first run because npm is +unreachable. When Squad is running from a bundle it instead writes: + +```json +{ "command": "/opt/squad/squad", "args": ["state-mcp"] } +``` + +The launcher exports `SQUAD_STANDALONE_HOME`, and the resolver checks it +*before* probing the npm registry, so a firewalled machine makes no registry +call at all. The path is absolute because Copilot spawns the MCP server in its +own environment, where neither `PATH` nor that variable is guaranteed. ## Building a bundle yourself @@ -135,6 +183,8 @@ npm is still used at *build* time to resolve the dependency tree. It is the downloaded bundle until it is signed and notarized. - **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`. + `--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. diff --git a/packages/squad-cli/src/cli/core/mcp-spec.ts b/packages/squad-cli/src/cli/core/mcp-spec.ts index 8c7aae55a..a8b80a6c1 100644 --- a/packages/squad-cli/src/cli/core/mcp-spec.ts +++ b/packages/squad-cli/src/cli/core/mcp-spec.ts @@ -4,9 +4,15 @@ * Used by BOTH `squad init` and `squad upgrade` so the runtime-MCP fallback * behavior stays symmetric. * - * Resolution order (iter-7, simplified to 2 tiers): - * 1. If `cliVersion` IS published on npm → `npx -y @ state-mcp` - * (clean cross-machine UX, the steady-state happy path). + * Resolution order: + * 0. If Squad is running from a standalone bundle (`SQUAD_STANDALONE_HOME`, + * exported by the bundle launcher) → spawn that bundle's launcher + * directly with an absolute path. This tier short-circuits *before* any + * registry probe, so a machine that cannot reach registry.npmjs.org + * never makes a network call here and never writes an `npx` spec it + * cannot execute later (#1593). + * 1. Else if `cliVersion` IS published on npm → `npx -y @ + * state-mcp` (clean cross-machine UX, the steady-state happy path). * 2. Else → `npx -y @insider state-mcp`. We do NOT probe the registry; * the `@insider` dist-tag is kept fresh by the publish flow and tier-2 * is the de-facto fallback whenever a pinned preview version isn't yet @@ -20,31 +26,67 @@ * mandate. */ +import { existsSync } from 'node:fs'; +import path from 'node:path'; + export interface SquadStateMcpSpec { - /** Executable to spawn (always `npx` after iter-7). */ + /** Executable to spawn — `npx`, or an absolute launcher path when bundled. */ command: string; /** Argv for the executable. */ args: string[]; /** How the spec was resolved — useful for logging + tests. */ - source: 'pinned' | 'insider'; + source: 'standalone' | 'pinned' | 'insider'; } const PACKAGE_NAME = '@bradygaster/squad-cli'; +/** + * Environment variable exported by the standalone bundle launchers + * (`squad`, `squad.cmd`, `squad.ps1`) pointing at the bundle root. + */ +export const STANDALONE_HOME_ENV = 'SQUAD_STANDALONE_HOME'; + export interface ResolveSquadStateMcpSpecOptions { /** * Override the published-version check. Tests inject this to avoid real * network traffic. */ publishedCheck?: (version: string) => Promise; + /** + * Override standalone-bundle detection. Returns the absolute path to the + * bundle launcher, or null when not running from a bundle. Tests inject + * this to avoid touching the filesystem or process env. + */ + standaloneCheck?: () => string | null; } /** Reset internal caches (test-only helper; retained for compat). */ export function _resetMcpSpecCache(): void { - // no caches in the 2-tier resolver — kept as a no-op for backward compat + // no caches in the resolver — kept as a no-op for backward compat // with any test that still calls it. } +/** + * Locate the launcher of the standalone bundle this CLI is running from. + * + * Returns an absolute path, or null when not running from a bundle. The + * absolute path matters: Copilot spawns the MCP server itself, in an + * environment that will not necessarily have the bundle on PATH or + * `SQUAD_STANDALONE_HOME` set, so the written spec has to stand alone. + */ +export function detectStandaloneLauncher(): string | null { + const home = process.env[STANDALONE_HOME_ENV]; + if (!home) return null; + const candidates = process.platform === 'win32' + ? ['squad.cmd', 'squad.ps1'] + : ['squad']; + for (const candidate of candidates) { + const full = path.join(home, candidate); + if (existsSync(full)) return full; + } + return null; +} + /** * Resolve the squad_state MCP launch spec given the running CLI version. * @@ -57,6 +99,20 @@ export async function resolveSquadStateMcpSpec( cliVersion: string, options: ResolveSquadStateMcpSpecOptions = {}, ): Promise { + // 0. Running from a standalone bundle — point at our own launcher and skip + // the registry entirely. Deliberately first: writing an `npx` spec on a + // machine installed from a bundle would leave squad_state unwired the + // moment the npm registry is unreachable (#1593). + const detect = options.standaloneCheck ?? detectStandaloneLauncher; + const launcher = detect(); + if (launcher) { + return { + command: launcher, + args: ['state-mcp'], + source: 'standalone', + }; + } + // 1. Try the pinned version on the public registry. Skip for placeholder // versions ('', '0.0.0') and local/dev builds with build metadata ('+') // — the registry will obviously not have them (#1204). diff --git a/packages/squad-cli/src/cli/core/upgrade.ts b/packages/squad-cli/src/cli/core/upgrade.ts index 45cbc8e95..e8a9fbc28 100644 --- a/packages/squad-cli/src/cli/core/upgrade.ts +++ b/packages/squad-cli/src/cli/core/upgrade.ts @@ -914,7 +914,12 @@ async function runEnsureChecks(dest: string, templatesDir: string, filesUpdated: /** Human-readable single-line description of an McpSpec for success() messages. */ export function describeMcpSpec(spec: SquadStateMcpSpec): string { - // After iter-7 all specs are `npx -y state-mcp`. + // Standalone bundles spawn their own launcher by absolute path rather than + // going through npx, so there is no package spec in args to report. + if (spec.source === 'standalone') { + return `${spec.command} (standalone bundle)`; + } + // Every other spec is `npx -y state-mcp`. const pkg = spec.args[1] ?? ''; return spec.source === 'insider' ? `${pkg} (@insider fallback)` : pkg; } diff --git a/scripts/build-standalone.mjs b/scripts/build-standalone.mjs index 8b5535efb..806c492d9 100644 --- a/scripts/build-standalone.mjs +++ b/scripts/build-standalone.mjs @@ -226,7 +226,16 @@ function writeLaunchers(bundleDir, platform, skipRuntime) { const nodeCmd = skipRuntime ? 'node' : '"%~dp0runtime\\node.exe"'; writeFileSync( path.join(bundleDir, 'squad.cmd'), - ['@echo off', 'setlocal', `${nodeCmd} "%~dp0${CLI_ENTRY.replace(/\//g, '\\')}" %*`, 'exit /b %ERRORLEVEL%', ''].join('\r\n'), + [ + '@echo off', + 'setlocal', + // 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, '\\')}" %*`, + 'exit /b %ERRORLEVEL%', + '', + ].join('\r\n'), ); // PowerShell shim so `squad` resolves for users whose PATHEXT excludes .CMD. writeFileSync( @@ -234,6 +243,7 @@ function writeLaunchers(bundleDir, platform, skipRuntime) { [ '$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', @@ -259,6 +269,10 @@ function writeLaunchers(bundleDir, platform, skipRuntime) { ' esac', 'done', 'root="$(cd "$(dirname "$target")" && pwd)"', + '# 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).', + 'SQUAD_STANDALONE_HOME="$root"', + 'export SQUAD_STANDALONE_HOME', skipRuntime ? 'node_bin="node"' : 'node_bin="$root/runtime/bin/node"', `exec "$node_bin" "$root/${CLI_ENTRY}" "$@"`, '', diff --git a/test/mcp-spec-init.test.ts b/test/mcp-spec-init.test.ts index 8778c63ff..0ae28ff01 100644 --- a/test/mcp-spec-init.test.ts +++ b/test/mcp-spec-init.test.ts @@ -10,8 +10,9 @@ * and tier-3 never fired. */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { readFileSync } from 'node:fs'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import path from 'node:path'; vi.mock( @@ -23,8 +24,11 @@ vi.mock( import { resolveSquadStateMcpSpec, + detectStandaloneLauncher, + STANDALONE_HOME_ENV, _resetMcpSpecCache, } from '../packages/squad-cli/src/cli/core/mcp-spec.js'; +import { describeMcpSpec } from '../packages/squad-cli/src/cli/core/upgrade.js'; import { isSquadCliVersionPublished } from '../packages/squad-cli/src/cli/core/npm-registry.js'; const mockIsPublished = vi.mocked(isSquadCliVersionPublished); @@ -102,6 +106,106 @@ describe('resolveSquadStateMcpSpec (iter-7: 2-tier resolver)', () => { }); }); +describe('resolveSquadStateMcpSpec — standalone bundle tier (#1593)', () => { + beforeEach(() => { + mockIsPublished.mockReset(); + _resetMcpSpecCache(); + }); + + it('spawns the bundle launcher by absolute path instead of npx', async () => { + const launcher = '/opt/squad/squad'; + const spec = await resolveSquadStateMcpSpec('0.11.0', { + standaloneCheck: () => launcher, + }); + expect(spec.source).toBe('standalone'); + expect(spec.command).toBe(launcher); + expect(spec.args).toEqual(['state-mcp']); + }); + + it('never touches the npm registry when running from a bundle', async () => { + // The whole point: a machine installed from a bundle may have no route to + // registry.npmjs.org at all, so the probe must not fire. + const spec = await resolveSquadStateMcpSpec('0.11.0', { + standaloneCheck: () => '/opt/squad/squad', + publishedCheck: async () => { + throw new Error('publishedCheck must not run in standalone mode'); + }, + }); + expect(spec.source).toBe('standalone'); + expect(mockIsPublished).not.toHaveBeenCalled(); + }); + + it('takes precedence over a published pinned version', async () => { + const spec = await resolveSquadStateMcpSpec('0.11.0', { + standaloneCheck: () => '/opt/squad/squad', + publishedCheck: async () => true, + }); + expect(spec.source).toBe('standalone'); + expect(spec.command).not.toBe('npx'); + }); + + it('falls through to the npx tiers when not running from a bundle', async () => { + const spec = await resolveSquadStateMcpSpec('0.11.0', { + standaloneCheck: () => null, + publishedCheck: async () => true, + }); + expect(spec.source).toBe('pinned'); + expect(spec.command).toBe('npx'); + }); + + it('emits a spec Copilot can spawn without PATH or env help', async () => { + // Copilot launches the MCP server in its own environment, so the written + // command has to be self-sufficient — no bare `squad`, no env expansion. + const spec = await resolveSquadStateMcpSpec('0.11.0', { + standaloneCheck: () => '/opt/squad/squad', + }); + expect(path.isAbsolute(spec.command)).toBe(true); + expect(spec.command).not.toContain('$'); + expect(spec.command).not.toContain('%'); + }); +}); + +describe('detectStandaloneLauncher (#1593)', () => { + const ORIGINAL = process.env[STANDALONE_HOME_ENV]; + + afterEach(() => { + if (ORIGINAL === undefined) delete process.env[STANDALONE_HOME_ENV]; + else process.env[STANDALONE_HOME_ENV] = ORIGINAL; + }); + + it('returns null when the env var is unset (normal npm install)', () => { + delete process.env[STANDALONE_HOME_ENV]; + expect(detectStandaloneLauncher()).toBeNull(); + }); + + it('returns null when the env var points at a directory with no launcher', () => { + process.env[STANDALONE_HOME_ENV] = mkdtempSync(path.join(tmpdir(), 'squad-nolauncher-')); + expect(detectStandaloneLauncher()).toBeNull(); + }); + + it('finds the launcher when the env var points at a real bundle', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'squad-bundle-')); + const name = process.platform === 'win32' ? 'squad.cmd' : 'squad'; + writeFileSync(path.join(dir, name), '#!/bin/sh\n'); + process.env[STANDALONE_HOME_ENV] = dir; + expect(detectStandaloneLauncher()).toBe(path.join(dir, name)); + }); +}); + +describe('describeMcpSpec — standalone specs (#1593)', () => { + it('reports the launcher path rather than ', () => { + // Standalone specs have no package name at args[1]; the describer must + // not fall through to the npx-shaped branch. + const described = describeMcpSpec({ + command: '/opt/squad/squad', + args: ['state-mcp'], + source: 'standalone', + }); + expect(described).toContain('/opt/squad/squad'); + expect(described).not.toContain(''); + }); +}); + describe('init.ts uses resolveSquadStateMcpSpec (asymmetry fix)', () => { // Source-level architectural check: init.ts must reference the shared // resolver to keep the npm-registry fallback consistent with upgrade.ts. From b121030304193a747fb7bed57c0c57682d4f180e Mon Sep 17 00:00:00 2001 From: Tamir Dresher <342800+tamirdresher@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:52:03 +0300 Subject: [PATCH 2/5] fix(action): run the bundled installer and fail on npx MCP specs Addresses both Copilot review comments on #1610. 1. The action fetched scripts/install.sh from the repository HEAD over the network. That is unpinned remote code execution, and the installer could drift from the release tag being installed. It now runs the install.sh that ships alongside the action, resolved from github.action_path. Consumers pin the action to a SHA, so the installer is pinned with it. 2. The npx check only wrote a warning to the step summary and exited 0, which did not match what the PR claimed. An npx-based squad_state entry is unrunnable on exactly the firewalled runners this action targets, and it fails later at Copilot start rather than here, so it now fails the step. Verified the action_path walk resolves correctly on Linux (alpine container: /repo/.github/actions/squad-init -> /repo/scripts/install.sh) rather than assuming the relative depth. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d2a1f506-af41-4d6d-b497-d0a5b255d4cf --- .github/actions/squad-init/action.yml | 43 +++++++++++++++++++-------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/.github/actions/squad-init/action.yml b/.github/actions/squad-init/action.yml index 175044215..8c73caa75 100644 --- a/.github/actions/squad-init/action.yml +++ b/.github/actions/squad-init/action.yml @@ -100,14 +100,29 @@ runs: INPUT_VERSION: ${{ inputs.version }} INPUT_PREFIX: ${{ inputs.install-prefix }} INPUT_REPO: ${{ inputs.repository }} + ACTION_PATH: ${{ github.action_path }} run: | set -euo pipefail prefix="${INPUT_PREFIX:-${HOME}/.squad-standalone}" - script_url="https://raw.githubusercontent.com/${INPUT_REPO}/HEAD/scripts/install.sh" - curl -fsSL "${script_url}" -o "${RUNNER_TEMP}/squad-install.sh" + # Run the installer that shipped with this action rather than fetching + # it from a branch head. Consumers pin this action to a SHA, so the + # installer is pinned with it — no unpinned remote code execution, and + # no drift between the installer and the release it is installing. + # + # github.action_path is /.github/actions/squad-init when the + # action is used from a repo reference, so the repo root is three + # levels up. Resolve it explicitly and verify rather than trusting the + # relative walk. + repo_root="$(cd "${ACTION_PATH}/../../.." && pwd)" + installer="${repo_root}/scripts/install.sh" + if [ ! -f "${installer}" ]; then + echo "::error::install.sh not found at ${installer}. Resolved repo root '${repo_root}' from action path '${ACTION_PATH}'." + exit 1 + fi + VERSION="${INPUT_VERSION}" PREFIX="${prefix}" REPO="${INPUT_REPO}" \ - sh "${RUNNER_TEMP}/squad-install.sh" + sh "${installer}" squad_bin="${prefix}/bin/squad" if [ ! -x "${squad_bin}" ]; then @@ -143,7 +158,7 @@ runs: set -euo pipefail "${SQUAD_BIN}" init --preset "${INPUT_PRESET}" --state-backend "${INPUT_BACKEND}" - - name: Summarize + - name: Verify npm-free MCP wiring if: inputs.skip-init != 'true' shell: bash working-directory: ${{ inputs.working-directory }} @@ -155,18 +170,20 @@ runs: echo "::error::squad init completed but .squad/team.md is missing" exit 1 fi + + # An npx-based squad_state entry is unrunnable on the firewalled + # runners this action exists for, and it fails at Copilot start rather + # than here — so fail the build now instead of warning. + if [ -f .mcp.json ] && grep -q '"npx"' .mcp.json; then + echo "::error::.mcp.json references npx — squad_state will not start without npm registry access. Expected the standalone launcher path." + cat .mcp.json + exit 1 + fi + { echo "### Squad ${SQUAD_VERSION} initialized" echo "" echo "Installed from a standalone release bundle — no npm registry access used." echo "" - # The MCP spec is the thing that silently breaks when a firewalled - # machine writes an npx-based launcher, so surface it here. - if [ -f .mcp.json ] && grep -q '"npx"' .mcp.json; then - echo "> [!WARNING]" - echo "> \`.mcp.json\` still references npx. squad_state will fail to start" - echo "> on a runner without npm registry access." - else - echo "\`.mcp.json\` points at the bundled launcher (no npx)." - fi + echo "\`.mcp.json\` points at the bundled launcher (no npx)." } >> "${GITHUB_STEP_SUMMARY}" From 4756266efda4d96d1754ecb8f85bf5bbe65ab6c3 Mon Sep 17 00:00:00 2001 From: Tamir Dresher <342800+tamirdresher@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:18:31 +0300 Subject: [PATCH 3/5] fix(dist): cross-build posix bundles from Windows, add build/install tests Found while running the installer end to end for the first time: building a linux or darwin bundle from a Windows host failed. node-v22.20.0-linux-x64/bin/npx: Can't create '...': Invalid argument tar: Error exit delayed from previous errors. The builder extracted the whole Node.js archive, and the posix archives carry symlinks (bin/npm, bin/npx, bin/corepack) that Windows cannot create. Only the node binary is ever shipped, so extraction is now scoped to that single member. Extraction failures also report the underlying tar error instead of a bare non-zero exit. This would have bitten anyone building non-linux targets from a Windows runner, and the release workflow's structural checks would not have caught it. Also adds test/standalone-build.test.ts (11 cases) covering the behaviors that break silently: selective extraction, SQUAD_STANDALONE_HOME in all three launchers, symlink resolution in the posix launcher, optional-dep omission, checksum verification, VERSION/PREFIX/REPO overrides, and an assertion that install.sh never reaches the npm registry. Verified the full install path in a container with no Node and no npm: served a packaged bundle over HTTP, ran the real install.sh against it, and got squad --version -> 0.11.0 through the PATH symlink. Corrupting the asset made the installer abort with a checksum mismatch, as intended. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d2a1f506-af41-4d6d-b497-d0a5b255d4cf --- .changeset/standalone-cross-build-fix.md | 5 + .../docs/features/standalone-install.md | 16 +++ scripts/build-standalone.mjs | 28 +++-- test/standalone-build.test.ts | 109 ++++++++++++++++++ 4 files changed, 148 insertions(+), 10 deletions(-) create mode 100644 .changeset/standalone-cross-build-fix.md create mode 100644 test/standalone-build.test.ts diff --git a/.changeset/standalone-cross-build-fix.md b/.changeset/standalone-cross-build-fix.md new file mode 100644 index 000000000..3960c9935 --- /dev/null +++ b/.changeset/standalone-cross-build-fix.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-cli": patch +--- + +Fix cross-building a POSIX standalone bundle from a Windows host. The builder extracted the entire Node.js runtime archive, which contains symlinks (`bin/npm`, `bin/npx`, `bin/corepack`) that Windows cannot create — so building a `linux` or `darwin` bundle from Windows failed with `Can't create ... Invalid argument`. It now extracts only the `node` binary it actually ships, and reports the underlying tar error when extraction fails. diff --git a/docs/src/content/docs/features/standalone-install.md b/docs/src/content/docs/features/standalone-install.md index 55bce6992..06eb083cd 100644 --- a/docs/src/content/docs/features/standalone-install.md +++ b/docs/src/content/docs/features/standalone-install.md @@ -188,3 +188,19 @@ npm is still used at *build* time to resolve the dependency tree. It is the 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. + +## Verifying a bundle locally + +You can exercise the whole install path without a published release by serving +an asset yourself: + +```sh +node scripts/build-standalone.mjs --platform linux --arch x64 --out-dir /tmp/out +cd /tmp/out && tar -czf squad-linux-x64.tar.gz squad-linux-x64 +sha256sum squad-linux-x64.tar.gz > SHA256SUMS.txt +``` + +Serve that directory under `/releases/download//` on a local HTTP +server, then point the installer at it with `REPO` and `VERSION`. The script +verifies the checksum before unpacking and aborts on a mismatch, so this also +exercises the tamper path. diff --git a/scripts/build-standalone.mjs b/scripts/build-standalone.mjs index 806c492d9..f6223e7db 100644 --- a/scripts/build-standalone.mjs +++ b/scripts/build-standalone.mjs @@ -186,7 +186,20 @@ function vendorRuntime(bundleDir, platform, arch, version, stagingDir) { run('curl', ['-fsSL', '-o', archivePath, url], stagingDir); step('Extracting runtime'); - run('tar', ['-xf', archivePath, '-C', stagingDir], stagingDir); + // 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 extracted = path.join(stagingDir, base); if (!existsSync(extracted)) { @@ -197,20 +210,15 @@ function vendorRuntime(bundleDir, platform, arch, version, stagingDir) { mkdirSync(runtimeDir, { recursive: true }); if (platform === 'win32') { - // Windows archives keep node.exe plus its ICU data at the archive root. - for (const entry of readdirSync(extracted)) { - if (entry === 'node.exe' || entry.endsWith('.dll') || entry.endsWith('.dat')) { - cpSync(path.join(extracted, entry), path.join(runtimeDir, entry)); - } - } - if (!existsSync(path.join(runtimeDir, 'node.exe'))) { - throw new Error('node.exe not found in downloaded runtime'); - } + const exeSrc = path.join(extracted, 'node.exe'); + if (!existsSync(exeSrc)) throw new Error('node.exe not found in downloaded runtime'); + cpSync(exeSrc, path.join(runtimeDir, 'node.exe')); } else { const binSrc = path.join(extracted, 'bin', 'node'); if (!existsSync(binSrc)) throw new Error('node binary not found in downloaded runtime'); mkdirSync(path.join(runtimeDir, 'bin'), { recursive: true }); cpSync(binSrc, path.join(runtimeDir, 'bin', 'node')); + // cpSync does not preserve the executable bit on every host filesystem. chmodSync(path.join(runtimeDir, 'bin', 'node'), 0o755); } diff --git a/test/standalone-build.test.ts b/test/standalone-build.test.ts new file mode 100644 index 000000000..3ed0667dc --- /dev/null +++ b/test/standalone-build.test.ts @@ -0,0 +1,109 @@ +/** + * Structural tests for scripts/build-standalone.mjs and scripts/install.sh. + * + * These are deliberately source-level rather than end-to-end: producing a real + * bundle downloads a Node runtime and runs a full npm install, which is far + * too slow and network-dependent for the unit suite. The behaviors asserted + * here are the ones that have actually broken (or would break silently). + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +const REPO_ROOT = process.cwd(); +const BUILD_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-standalone.mjs'); +const INSTALL_SCRIPT = path.join(REPO_ROOT, 'scripts', 'install.sh'); + +const build = readFileSync(BUILD_SCRIPT, 'utf-8'); +const install = readFileSync(INSTALL_SCRIPT, 'utf-8'); + +describe('build-standalone.mjs — runtime extraction', () => { + it('extracts only the node binary, not the whole archive', () => { + // Regression: extracting the full POSIX tarball fails on a Windows host + // 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'); + + // The bare full-archive extraction must not come back. + expect(build).not.toMatch(/run\('tar', \['-xf', archivePath, '-C', stagingDir\]/); + }); + + it('surfaces the underlying tar error instead of a bare exit code', () => { + expect(build).toMatch(/Failed to extract .* from/); + }); + + it('marks the vendored posix node binary executable', () => { + expect(build).toMatch(/chmodSync\(path\.join\(runtimeDir, 'bin', 'node'\), 0o755\)/); + }); +}); + +describe('build-standalone.mjs — launchers', () => { + it('exports SQUAD_STANDALONE_HOME from every launcher', () => { + // Without this the CLI cannot tell it is running from a bundle, and + // squad init falls back to writing an npx-based squad_state MCP spec + // that a firewalled machine cannot launch (#1593). + const posix = build.includes("'SQUAD_STANDALONE_HOME=\"$root\"'") + && build.includes("'export SQUAD_STANDALONE_HOME'"); + expect(posix).toBe(true); + expect(build).toContain('set "SQUAD_STANDALONE_HOME=%~dp0"'); + expect(build).toContain('$env:SQUAD_STANDALONE_HOME = $root'); + }); + + it('resolves symlinks in the posix launcher so PATH installs work', () => { + // install.sh symlinks /bin/squad -> /lib/squad/squad, so + // the launcher must resolve $0 before locating the runtime and app. + expect(build).toContain('while [ -L "$target" ]; do'); + expect(build).toContain('readlink "$target"'); + }); + + it('omits optional dependencies by default', () => { + // The transitive Copilot CLI platform binary is ~318 MB; including it + // takes a bundle from ~114 MB to ~546 MB for no benefit, since Squad + // requires the copilot CLI on PATH anyway. + expect(build).toContain("'--omit=dev', '--omit=optional'"); + expect(build).toContain('--include-optional'); + }); +}); + +describe('install.sh', () => { + it('verifies the release checksum before installing', () => { + expect(install).toContain('SHA256SUMS.txt'); + expect(install).toMatch(/Checksum mismatch/); + }); + + it('supports VERSION, PREFIX and REPO overrides', () => { + // REPO is what lets an air-gapped org point at an internal mirror. + expect(install).toMatch(/REPO="\$\{REPO:-bradygaster\/squad\}"/); + expect(install).toMatch(/VERSION="\$\{VERSION:-latest\}"/); + expect(install).toMatch(/PREFIX="\/usr\/local"/); + }); + + it('maps uname output to the published asset names', () => { + for (const target of ['linux', 'darwin', 'x64', 'arm64']) { + expect(install).toContain(target); + } + expect(install).toContain('squad-${target}.tar.gz'); + }); + + it('never fetches from the npm registry', () => { + // The entire point: installing must touch github.com only. Check the + // executable lines rather than the raw file, since the header comment + // legitimately mentions the registry when explaining what this avoids. + const code = install + .split('\n') + .filter((line) => !line.trim().startsWith('#')) + .join('\n'); + expect(code).not.toContain('registry.npmjs.org'); + expect(code).not.toMatch(/\bnpm install\b/); + expect(code).not.toMatch(/\bnpx\b/); + }); + + it('points users at Copilot CLI when it is missing', () => { + // Squad drives the copilot CLI and deliberately does not vendor it. + expect(install).toContain('gh.io/copilot-install'); + }); +}); From 944c9464ec32d738acc4bc052b72e911f49edeee Mon Sep 17 00:00:00 2001 From: Tamir Dresher <342800+tamirdresher@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:56:25 +0300 Subject: [PATCH 4/5] feat(dist): Homebrew and winget packaging, plus a real squad.exe (#1593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third slice of the distribution work. Adds the two package managers people actually expect, and the Windows executable that makes one of them possible. The blocker I found first: winget's portable installer only creates command aliases for .exe targets — .cmd and .bat are explicitly unsupported. Our Windows bundle's entry point was squad.cmd, so a winget manifest would have installed something that could not be invoked. Homebrew has no such restriction and works with the existing shell launcher. So Windows bundles now ship a real squad.exe, built with Node's single executable application support. It is the vendored runtime with a small launcher embedded, so it REPLACES runtime/node.exe rather than adding to the bundle — a Windows bundle is still 114 MB. The launcher resolves the bundle from its own location and follows a symlink if it finds one, because winget installs portables by symlinking the exe into a links directory. Also here: - scripts/generate-packaging.mjs renders the Homebrew cask and the three winget manifests from a release's SHA256SUMS.txt. Both ecosystems embed a version and a per-artifact SHA-256, so hand-maintaining them guarantees they go stale — this is exactly the standing cost flagged in #1593. - The release workflow generates them after publishing and attaches them as an artifact for a maintainer to submit. - mcp-spec now 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 to start squad_state. Two real bugs found while testing, both of which CI would have hit: 1. GNU tar cannot read zip archives; bsdtar (Windows, macOS) can. The release workflow builds every target on ubuntu-latest, so every win32 bundle would have failed to unpack its Node runtime. Now uses unzip on posix hosts, and the workflow installs it for win32 targets. 2. execFileSync with shell:true splits an unquoted command path on spaces, so invoking process.execPath from "C:\Program Files\nodejs" failed. The run() helper now quotes. Verified: - squad.exe, squad.cmd and squad.ps1 all return 0.11.0 from a real bundle - squad init from squad.exe scaffolds 5 agents and writes an npx-free .mcp.json pointing at the bundle launcher - SEA blob is version-locked: a blob built by Node 23.5 injected into the vendored 22.20.0 produced an exe that died with an access violation (0xC0000005). The builder now downloads a matching host Node to generate it. - Cross-build works: a Linux container unzipped the Windows runtime, generated the blob with Linux Node 22.20.0, injected it with postject, and the resulting PE ran correctly on Windows. That is the production path, since the workflow builds all six targets on ubuntu-latest. - Generator output parses as YAML, all three winget manifests agree on identifier/version/schema, and both installers point at .exe - 67 tests pass (18 new for the generator, 8 new for the exe and extraction) Dropped the shebang from generate-packaging.mjs so vitest can import it on Windows; scripts/pr-readiness.mjs, the repo's other importable script, has no shebang either. Worth noting separately: the same shebang interaction is why test/promote-insider-tag.test.ts is one of the pre-existing Windows failures. Left alone as out of scope. Not verified: no winget or Homebrew submission has been made, because neither can be tested without a published release carrying bundle assets. Code signing and notarization are still absent, and injecting the SEA blob invalidates the Node runtime's Authenticode signature, so SmartScreen will warn on Windows. That is the main gap before either package should be recommended widely. Refs #1593 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d2a1f506-af41-4d6d-b497-d0a5b255d4cf --- .changeset/standalone-prefer-exe.md | 5 + .../workflows/squad-standalone-release.yml | 73 ++++- cspell.json | 1 + .../docs/features/standalone-install.md | 70 +++- packages/squad-cli/src/cli/core/mcp-spec.ts | 5 +- scripts/build-standalone.mjs | 212 ++++++++++-- scripts/generate-packaging.mjs | 310 ++++++++++++++++++ test/mcp-spec-init.test.ts | 11 + test/packaging-manifests.test.ts | 152 +++++++++ test/standalone-build.test.ts | 55 +++- 10 files changed, 854 insertions(+), 40 deletions(-) create mode 100644 .changeset/standalone-prefer-exe.md create mode 100644 scripts/generate-packaging.mjs create mode 100644 test/packaging-manifests.test.ts 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..9ea302b11 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,58 @@ 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 "" + echo '- Homebrew — copy `homebrew/squad.rb` into the tap as `Casks/squad.rb`.' + echo '- winget — copy `winget/` into `winget-pkgs/manifests/b/bradygaster/Squad//`,' + 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', () => { From fffd33126ed5b40b9bb3d041a8a95108a84afa06 Mon Sep 17 00:00:00 2001 From: Brady Gaster <41929050+bradygaster@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:31:47 +0000 Subject: [PATCH 5/5] fix(ci): suppress shellcheck SC2016 on Actions expression in release workflow The single-quoted backticks are intentional markdown formatting in the step summary, not shell expressions. Disable SC2016 for those lines. Closes #1611 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/squad-standalone-release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/squad-standalone-release.yml b/.github/workflows/squad-standalone-release.yml index 9ea302b11..51e437e2b 100644 --- a/.github/workflows/squad-standalone-release.yml +++ b/.github/workflows/squad-standalone-release.yml @@ -244,8 +244,11 @@ jobs: 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}"