Repository navigation
feat(dist): Homebrew and winget packaging, plus a real squad.exe (#1593) - #1611
Conversation
…#1593) 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 '<unknown>'. 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=<bundle>/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
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
…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
Third slice of the distribution work. Adds the two package managers people actually expect, and the Windows executable that makes one of them possible. The blocker I found first: winget's portable installer only creates command aliases for .exe targets — .cmd and .bat are explicitly unsupported. Our Windows bundle's entry point was squad.cmd, so a winget manifest would have installed something that could not be invoked. Homebrew has no such restriction and works with the existing shell launcher. So Windows bundles now ship a real squad.exe, built with Node's single executable application support. It is the vendored runtime with a small launcher embedded, so it REPLACES runtime/node.exe rather than adding to the bundle — a Windows bundle is still 114 MB. The launcher resolves the bundle from its own location and follows a symlink if it finds one, because winget installs portables by symlinking the exe into a links directory. Also here: - scripts/generate-packaging.mjs renders the Homebrew cask and the three winget manifests from a release's SHA256SUMS.txt. Both ecosystems embed a version and a per-artifact SHA-256, so hand-maintaining them guarantees they go stale — this is exactly the standing cost flagged in #1593. - The release workflow generates them after publishing and attaches them as an artifact for a maintainer to submit. - mcp-spec now prefers squad.exe over squad.cmd on Windows. Since the fix for CVE-2024-27980 Node refuses to spawn a .cmd without shell:true, so an MCP client spawning the command directly would fail to start squad_state. Two real bugs found while testing, both of which CI would have hit: 1. GNU tar cannot read zip archives; bsdtar (Windows, macOS) can. The release workflow builds every target on ubuntu-latest, so every win32 bundle would have failed to unpack its Node runtime. Now uses unzip on posix hosts, and the workflow installs it for win32 targets. 2. execFileSync with shell:true splits an unquoted command path on spaces, so invoking process.execPath from "C:\Program Files\nodejs" failed. The run() helper now quotes. Verified: - squad.exe, squad.cmd and squad.ps1 all return 0.11.0 from a real bundle - squad init from squad.exe scaffolds 5 agents and writes an npx-free .mcp.json pointing at the bundle launcher - SEA blob is version-locked: a blob built by Node 23.5 injected into the vendored 22.20.0 produced an exe that died with an access violation (0xC0000005). The builder now downloads a matching host Node to generate it. - Cross-build works: a Linux container unzipped the Windows runtime, generated the blob with Linux Node 22.20.0, injected it with postject, and the resulting PE ran correctly on Windows. That is the production path, since the workflow builds all six targets on ubuntu-latest. - Generator output parses as YAML, all three winget manifests agree on identifier/version/schema, and both installers point at .exe - 67 tests pass (18 new for the generator, 8 new for the exe and extraction) Dropped the shebang from generate-packaging.mjs so vitest can import it on Windows; scripts/pr-readiness.mjs, the repo's other importable script, has no shebang either. Worth noting separately: the same shebang interaction is why test/promote-insider-tag.test.ts is one of the pre-existing Windows failures. Left alone as out of scope. Not verified: no winget or Homebrew submission has been made, because neither can be tested without a published release carrying bundle assets. Code signing and notarization are still absent, and injecting the SEA blob invalidates the Node runtime's Authenticode signature, so SmartScreen will warn on Windows. That is the main gap before either package should be recommended widely. Refs #1593 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d2a1f506-af41-4d6d-b497-d0a5b255d4cf
🏗️ Architectural Review
Automated architectural review — informational only. |
🟠 Impact Analysis — PR #1611Risk tier: 🟠 HIGH 📊 Summary
🎯 Risk Factors
📦 Modules Affectedci-workflows (1 file)
docs (1 file)
root (2 files)
scripts (2 files)
squad-cli (1 file)
tests (3 files)
This report is generated automatically for every PR. See #733 for details. |
🛫 PR Readiness Check
PR Scope: 📦🔧 Mixed (product + infrastructure)
|
| Status | Check | Details |
|---|---|---|
| ❌ | Single commit | 6 commits — consider squashing before review |
| ✅ | Not in draft | Ready for review |
| ❌ | Branch up to date | dev is 1 commit(s) ahead — rebase recommended |
| ❌ | Copilot review | No Copilot review yet — it may still be processing |
| ✅ | Changeset present | Changeset file found |
| ✅ | Scope clean | No .squad/ or docs/proposals/ files |
| ✅ | No merge conflicts | Merge status unknown — GitHub is still computing |
| ✅ | Copilot threads resolved | 0 active Copilot thread(s) resolved (2 outdated skipped) |
| ❌ | CI passing | 12 check(s) still running |
Files Changed (10 files, +857 −40)
| File | +/− |
|---|---|
.changeset/standalone-prefer-exe.md |
+5 −0 |
.github/workflows/squad-standalone-release.yml |
+75 −1 |
cspell.json |
+1 −0 |
docs/src/content/docs/features/standalone-install.md |
+60 −10 |
packages/squad-cli/src/cli/core/mcp-spec.ts |
+4 −1 |
scripts/build-standalone.mjs |
+185 −27 |
scripts/generate-packaging.mjs |
+310 −0 |
test/mcp-spec-init.test.ts |
+11 −0 |
test/packaging-manifests.test.ts |
+152 −0 |
test/standalone-build.test.ts |
+54 −1 |
Total: +857 −40
This check runs automatically on every push. Fix any ❌ items and push again.
See CONTRIBUTING.md and PR Requirements for details.
🔒 Security Review🔒 Security review: 1 warning(s).
Automated security review — informational only. |
There was a problem hiding this comment.
Pull request overview
This PR extends Squad’s standalone distribution story by adding generated Homebrew cask and winget manifests, and by updating the Windows standalone bundle to ship a real squad.exe (SEA-based) so winget portable installs are invokable. It also updates MCP wiring so standalone installs prefer an executable launcher and avoid npx/registry dependencies.
Changes:
- Build Windows standalone bundles with a SEA-based
squad.exeand adjust launchers + workflow assertions accordingly. - Add a packaging-manifest generator (
scripts/generate-packaging.mjs) plus CI workflow job to emit Homebrew/winget manifests fromSHA256SUMS.txt. - Add/extend tests and docs to cover standalone extraction, MCP standalone tier behavior, and packaging output.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| test/standalone-build.test.ts | Adds structural tests covering standalone build/extraction and Windows SEA behavior. |
| test/packaging-manifests.test.ts | Adds unit tests for checksum parsing and manifest rendering for Homebrew/winget. |
| test/mcp-spec-init.test.ts | Extends MCP spec tests for standalone tier and Windows .exe preference; covers describer output. |
| scripts/generate-packaging.mjs | New script to render Homebrew cask + winget manifests from release checksums. |
| scripts/build-standalone.mjs | Updates runtime extraction, Windows SEA squad.exe build, launcher env export, and quoting for Windows shell execution. |
| packages/squad-cli/src/cli/core/upgrade.ts | Updates MCP spec description logic for standalone specs. |
| packages/squad-cli/src/cli/core/mcp-spec.ts | Adds standalone detection tier, launcher detection helper, and .exe preference on Windows. |
| docs/src/content/docs/features/standalone-install.md | Documents winget/Homebrew install paths, Windows squad.exe rationale, container notes, and MCP spec behavior. |
| Dockerfile | Adds bundle-based container build that avoids npm registry at runtime. |
| cspell.json | Adds “pkgs” word for spell-checking. |
| .github/workflows/squad-standalone-release.yml | Ensures unzip availability for win32 cross-builds; adds packaging-manifest generation job/artifact. |
| .github/actions/squad-init/action.yml | Adds composite action to install from release bundle and run squad init, verifying npm-free MCP wiring. |
| .gitattributes | Enforces LF endings for Docker-related files to avoid Linux build issues. |
| .dockerignore | Minimizes Docker build context and avoids copying host artifacts. |
| .changeset/standalone-prefer-exe.md | Changeset for preferring squad.exe in standalone MCP spec on Windows. |
| .changeset/standalone-mcp-spec-1593.md | Changeset for emitting npx-free MCP spec when running from a standalone bundle. |
| .changeset/standalone-cross-build-fix.md | Changeset for extraction behavior that enables cross-building bundles reliably. |
| 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 { |
| set -euo pipefail | ||
| # These values reach a shell command below, so constrain them tightly. |
…workflow The single-quoted backticks are intentional markdown formatting in the step summary, not shell expressions. Disable SC2016 for those lines. Closes #1611 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…hannels # Conflicts: # docs/src/content/docs/features/standalone-install.md # packages/squad-cli/src/cli/core/mcp-spec.ts # scripts/build-standalone.mjs # test/mcp-spec-init.test.ts # test/standalone-build.test.ts
What
Third slice of the distribution work: Homebrew and winget, plus the Windows executable that makes winget possible at all.
Based on
dev, but note it builds on the standalone bundle work in #1610 (still open). The two touchscripts/build-standalone.mjsandmcp-spec.ts, so #1610 should merge first.Why this wasn't just two config files
I started by reading the winget spec rather than writing a manifest, and hit a blocker immediately:
Our Windows bundle's entry point was
squad.cmd. A winget manifest pointing at it would have shipped a package that installs cleanly and then doesn't work. Homebrew has no such restriction and works fine with the existing shell launcher.For reference,
GitHub.Copilotsolves this the same way —InstallerType: zip+NestedInstallerType: portablewith a realcopilot.exein the archive.How
Windows bundles now ship a real
squad.exe, built with Node's single executable application support. It is the vendored runtime with a small launcher embedded, so it replacesruntime/node.exerather than adding to the bundle — a Windows bundle is still 114 MB.squad.cmdandsquad.ps1stay for people who unpack and run in place.The embedded launcher resolves the bundle from its own location and follows a symlink if it finds one, since winget installs portables by symlinking the exe into a links directory.
Manifests are generated, not hand-written. Both ecosystems embed a version and a per-artifact SHA-256, so maintaining them by hand guarantees they go stale — exactly the standing cost I flagged in #1593.
scripts/generate-packaging.mjsrenders the cask and all three winget manifests from the release's ownSHA256SUMS.txt, and the release workflow runs it and attaches the output as an artifact..mcp.jsonnow preferssquad.exeoversquad.cmd. Since the fix for CVE-2024-27980, Node refuses tospawn()a.cmdwithoutshell: true— so an MCP client spawning the command directly would fail to startsquad_state.Two bugs found while testing — both would have hit CI
1. GNU tar cannot read zip archives. bsdtar (Windows/macOS) can, which is why this worked locally. The release workflow builds every target on
ubuntu-latest, so all six win32 bundles would have failed to unpack their Node runtime. Now usesunzipon POSIX hosts, and the workflow installs it.2.
execFileSyncwithshell: truesplits an unquoted path on spaces, so invokingprocess.execPathfromC:\Program Files\nodejsfailed outright. Therun()helper now quotes.Verification
SEA blob is version-locked — this one cost me a debugging cycle worth recording. A blob built by my host Node 23.5.0 and injected into the vendored 22.20.0 produced an exe that died with
0xC0000005(access violation) and no other diagnostic. The builder now downloads a matching host Node purely to generate the blob.Cross-build verified end to end, because this is the production path:
node.exeMZA Windows executable built entirely on Linux runs on Windows. That was the riskiest assumption in the winget path.
Also:
squad.exe/squad.cmd/squad.ps1all return0.11.0from a real bundle ·squad initfromsquad.exescaffolds 5 agents and writes an npx-free.mcp.json· generator output parses as YAML, all three winget manifests agree on identifier/version/schema, both installers point at.exe· 67 tests pass (18 new for the generator, 8 new for the exe and extraction) · lint, cspell, markdownlint clean.What is NOT verified
homebrew-core/a tap or towinget-pkgs. Neither can be tested without a published release carrying bundle assets — the same chicken-and-egg as feat(dist): standalone npm-free bundles for squad-cli (#1593) #1595 and feat(dist): npm-free MCP spec, squad-init action, and container image (#1593) #1610.realpathSyncfallback is defensive rather than proven.Incidental
Dropped the shebang from
generate-packaging.mjsso vitest can import it on Windows —scripts/pr-readiness.mjs, the repo's other importable script, has none either. Worth noting separately: the same shebang interaction is whytest/promote-insider-tag.test.tsis one of the pre-existing Windows test failures. Left alone as out of scope, but it's a one-line fix if you want it..changeset/standalone-prefer-exe.md)PR Readiness Checklist
Branch & Commit
devbuild-standalone.mjsandmcp-spec.tsBuild & Test
npm run buildpassesnpm test— 41 test files fail identically on cleandevon Windows (pre-existing, measured in feat(dist): standalone npm-free bundles for squad-cli (#1593) #1595). This branch adds none. Deferring to CI.npm run lintpassesnpm run lint:eslintpasses (0 errors)Docs
Exports
Breaking Changes
None for npm users. For anyone using a previous standalone Windows bundle,
runtime/node.exeno longer exists —squad.exereplaces it. Bothsquad.cmdandsquad.ps1continue to work unchanged, and no released bundle has shipped yet, so nothing in the wild is affected.Waivers
None requested. Flagging rather than waiving: the SEA-signed-binary question is a genuine open risk. Blob injection breaks the Authenticode signature, and I would not submit to winget until that is resolved. Happy to split the winget half out if you'd rather land Homebrew first.