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/3] 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/3] 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/3] 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'); + }); +});