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/.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..8c73caa75 --- /dev/null +++ b/.github/actions/squad-init/action.yml @@ -0,0 +1,189 @@ +# 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 }} + ACTION_PATH: ${{ github.action_path }} + run: | + set -euo pipefail + prefix="${INPUT_PREFIX:-${HOME}/.squad-standalone}" + + # 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 "${installer}" + + 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: Verify npm-free MCP wiring + 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 + + # 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 "" + echo "\`.mcp.json\` points at the bundled launcher (no npx)." + } >> "${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..06eb083cd 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,24 @@ 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. + +## 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/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..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); } @@ -226,7 +234,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 +251,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 +277,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. 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'); + }); +});