Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/standalone-cross-build-fix.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/standalone-mcp-spec-1593.md
Original file line number Diff line number Diff line change
@@ -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@<version> 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.
15 changes: 15 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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
189 changes: 189 additions & 0 deletions .github/actions/squad-init/action.yml
Original file line number Diff line number Diff line change
@@ -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@<sha>
# 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 <prefix>/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 <checkout>/.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}"
71 changes: 71 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
72 changes: 69 additions & 3 deletions docs/src/content/docs/features/standalone-install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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@<sha>
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:
Expand All @@ -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

Expand Down Expand Up @@ -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 `<repo>/releases/download/<tag>/` 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.
Loading
Loading