From 78d5a822ec8822df373ba5ade6d9fcc0c48c4e2d Mon Sep 17 00:00:00 2001 From: Tamir Dresher <342800+tamirdresher@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:10:44 +0300 Subject: [PATCH] feat(dist): standalone npm-free bundles for squad-cli (#1593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a build path that produces self-contained Squad bundles vendoring their own Node.js runtime, so installing Squad touches github.com only and never registry.npmjs.org. This is the foundation for the distribution work proposed in #1593, and it closes the limitation Brady documented and deferred in #1587: the gh-aw activation job currently requires npx, so it cannot run on a self-hosted runner behind a firewall that blocks the npm registry. What is here: - scripts/build-standalone.mjs — builds a bundle for a platform/arch pair. Packs the workspace packages, installs them with --omit=dev --omit=optional, vendors an official Node runtime, and writes a launcher. - scripts/install.sh — POSIX installer that resolves the platform, downloads the matching release asset, verifies it against SHA256SUMS.txt, and links squad onto PATH. Mirrors the shape of gh.io/copilot-install. - .github/workflows/squad-standalone-release.yml — 6-target matrix build that attaches archives plus a combined SHA256SUMS.txt to a release. - docs/src/content/docs/features/standalone-install.md — feature docs, with known limitations stated. Design notes: squad-cli and squad-sdk locate templates/, presets/builtin/ and package.json by walking up from import.meta.url at runtime. The bundle therefore preserves a real directory layout instead of compiling to a single executable, which is why this needs zero source changes. It is also the same shape GitHub's own Copilot CLI publishes. Optional dependencies are omitted by default. That drops the Copilot CLI platform binary pulled in transitively through @github/copilot-sdk (~318 MB), node-pty prebuilds, the OpenTelemetry SDK and sql.js — 546 MB down to 114 MB, in line with Copilot CLI's own ~110 MB archives. Squad already requires the copilot CLI on PATH and Copilot CLI ships npm-free installers of its own, so vendoring a second copy would only add weight and version skew. --ignore-scripts is safe here: the vscode-jsonrpc ESM fix that squad-cli's postinstall applies is also applied at runtime by cli-entry.js via a Module._resolveFilename hook, so the bundle does not depend on it having run. Verified locally on win32-x64: - squad --version from the bundle returns 0.11.0 on its own vendored runtime - squad init --preset default --state-backend local scaffolds 5 agents in a clean directory with no npm involved - generated .github/workflows/squad-heartbeat.yml is byte-identical (sha256 5CE0F5D1...) to the repo template source, confirming the bundle reproduces its source faithfully - docs-build test passes, cspell and markdownlint clean on the new page No packages/*/src changes, so no changeset is required. Refs #1593, #1587 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d2a1f506-af41-4d6d-b497-d0a5b255d4cf --- .gitattributes | 3 + .../workflows/squad-standalone-release.yml | 180 ++++++++++ .gitignore | 1 + .../docs/features/standalone-install.md | 140 ++++++++ docs/src/navigation.ts | 1 + scripts/build-standalone.mjs | 331 ++++++++++++++++++ scripts/install.sh | 121 +++++++ test/docs-build.test.ts | 1 + 8 files changed, 778 insertions(+) create mode 100644 .github/workflows/squad-standalone-release.yml create mode 100644 docs/src/content/docs/features/standalone-install.md create mode 100644 scripts/build-standalone.mjs create mode 100755 scripts/install.sh diff --git a/.gitattributes b/.gitattributes index 4654fe2e0..93b2b53e2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -17,3 +17,6 @@ # Squad: pin LF for YAML to prevent CRLF<->LF flip on Windows checkouts (PR #1200 concern G) *.yml text eol=lf *.yaml text eol=lf + +# Squad: shell scripts must stay LF — CRLF breaks the shebang on Linux/macOS. +*.sh text eol=lf diff --git a/.github/workflows/squad-standalone-release.yml b/.github/workflows/squad-standalone-release.yml new file mode 100644 index 000000000..71acb734e --- /dev/null +++ b/.github/workflows/squad-standalone-release.yml @@ -0,0 +1,180 @@ +name: Squad Standalone Bundles + +# Builds self-contained, npm-free Squad bundles for every supported platform and +# attaches them to a GitHub Release. +# +# Each bundle vendors its own Node.js runtime, so installing Squad from these +# artifacts touches github.com only — never registry.npmjs.org. This is what +# unblocks self-hosted runners and developer machines behind a firewall that +# blocks the npm registry (see #1593, and the deferred limitation in #1587). +# +# npm is still used at *build* time inside this workflow to resolve the +# dependency tree; it is the runtime dependency that is removed. + +on: + release: + types: [published] + workflow_dispatch: + inputs: + node_version: + description: 'Node.js runtime version to vendor into the bundles' + required: false + default: '22.20.0' + type: string + upload: + description: 'Attach the built bundles to the release matching the current tag' + required: false + default: false + type: boolean + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +env: + NODE_VERSION: ${{ inputs.node_version || '22.20.0' }} + +jobs: + build: + name: ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - target: linux-x64 + platform: linux + arch: x64 + runner: ubuntu-latest + - target: linux-arm64 + platform: linux + arch: arm64 + runner: ubuntu-latest + - target: darwin-x64 + platform: darwin + arch: x64 + runner: ubuntu-latest + - target: darwin-arm64 + platform: darwin + arch: arm64 + runner: ubuntu-latest + - target: win32-x64 + platform: win32 + arch: x64 + runner: ubuntu-latest + - target: win32-arm64 + platform: win32 + arch: arm64 + runner: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v6 + with: + node-version: 22 + + - name: Install workspace dependencies + run: npm ci --no-audit --no-fund + + - name: Build packages + run: npm run build + + - name: Build standalone bundle + env: + TARGET_PLATFORM: ${{ matrix.platform }} + TARGET_ARCH: ${{ matrix.arch }} + run: | + node scripts/build-standalone.mjs \ + --platform "${TARGET_PLATFORM}" \ + --arch "${TARGET_ARCH}" \ + --node-version "${NODE_VERSION}" + + - name: Package bundle + id: package + working-directory: dist-standalone + env: + TARGET: ${{ matrix.target }} + TARGET_PLATFORM: ${{ matrix.platform }} + run: | + set -euo pipefail + if [ "${TARGET_PLATFORM}" = "win32" ]; then + asset="squad-${TARGET}.zip" + zip -qr "${asset}" "squad-${TARGET}" + else + asset="squad-${TARGET}.tar.gz" + tar -czf "${asset}" "squad-${TARGET}" + fi + sha256sum "${asset}" > "${asset}.sha256" + echo "asset=${asset}" >> "${GITHUB_OUTPUT}" + ls -lh "${asset}" + + - name: Smoke test bundle + # The vendored runtime only executes on a matching host, so the CLI can + # only be invoked for the native target. Cross-built bundles are + # verified structurally instead. + env: + TARGET: ${{ matrix.target }} + TARGET_PLATFORM: ${{ matrix.platform }} + run: | + set -euo pipefail + bundle="dist-standalone/squad-${TARGET}" + if [ "${TARGET}" = "linux-x64" ]; then + actual="$("${bundle}/squad" --version)" + echo "squad --version -> ${actual}" + test -n "${actual}" + else + test -f "${bundle}/BUNDLE-INFO.json" + test -d "${bundle}/app/node_modules/@bradygaster/squad-cli" + if [ "${TARGET_PLATFORM}" = "win32" ]; then + test -f "${bundle}/runtime/node.exe" + test -f "${bundle}/squad.cmd" + else + test -f "${bundle}/runtime/bin/node" + test -f "${bundle}/squad" + fi + echo "Layout verified for ${TARGET} (cross-built, not executable here)" + fi + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7 + with: + name: squad-${{ matrix.target }} + path: | + dist-standalone/${{ steps.package.outputs.asset }} + dist-standalone/${{ steps.package.outputs.asset }}.sha256 + retention-days: 7 + if-no-files-found: error + + publish: + name: Attach bundles to release + needs: build + if: github.event_name == 'release' || inputs.upload + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c #v8 + with: + path: artifacts + merge-multiple: true + + - name: Build combined SHA256SUMS.txt + working-directory: artifacts + run: | + set -euo pipefail + cat ./*.sha256 > SHA256SUMS.txt + rm -f ./*.sha256 + cat SHA256SUMS.txt + + - name: Upload to release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.release.tag_name || github.ref_name }} + working-directory: artifacts + run: | + set -euo pipefail + gh release upload "${TAG}" ./* --clobber --repo "${GITHUB_REPOSITORY}" diff --git a/.gitignore b/.gitignore index 2d800910e..d690cb246 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +dist-standalone/ .env *.tsbuildinfo coverage/ diff --git a/docs/src/content/docs/features/standalone-install.md b/docs/src/content/docs/features/standalone-install.md new file mode 100644 index 000000000..855c2e75c --- /dev/null +++ b/docs/src/content/docs/features/standalone-install.md @@ -0,0 +1,140 @@ +# Standalone Install (no npm) + +> ⚠️ **Experimental** — Squad is alpha software. APIs, commands, and behavior may change between releases. + +Squad publishes self-contained bundles that vendor their own Node.js runtime. +Installing from these touches `github.com` only — nothing is fetched from +`registry.npmjs.org`, and neither Node.js nor npm needs to be installed first. + +This exists for environments where the npm registry is unreachable: self-hosted +CI runners behind a corporate firewall, locked-down build agents, and air-gapped +mirrors. It is also simply a faster install for anyone who does not otherwise +have a Node toolchain. + +`npm install -g @bradygaster/squad-cli` remains fully supported and is still the +recommended path for day-to-day development. + +## Install + +### macOS and Linux + +```sh +curl -fsSL https://raw.githubusercontent.com/bradygaster/squad/dev/scripts/install.sh | sh +``` + +The installer picks the right bundle for your platform, verifies it against the +release `SHA256SUMS.txt`, unpacks it into `$PREFIX/lib/squad`, and symlinks +`squad` into `$PREFIX/bin`. + +| Variable | Default | Purpose | +|----------|---------|---------| +| `VERSION` | latest release | Install a specific version | +| `PREFIX` | `/usr/local` as root, else `$HOME/.local` | Install location | +| `REPO` | `bradygaster/squad` | Source repository or internal mirror | + +```sh +# pin a version and install somewhere specific +curl -fsSL https://raw.githubusercontent.com/bradygaster/squad/dev/scripts/install.sh \ + | VERSION="v0.11.0" PREFIX="$HOME/tools" sh +``` + +### Windows + +Download `squad-win32-x64.zip` (or `squad-win32-arm64.zip`) from the +[releases page](https://github.com/bradygaster/squad/releases), unpack it, and +add the folder to your `PATH`. The bundle ships both `squad.cmd` and +`squad.ps1`. + +### Direct download + +Every release carries one archive per platform plus a `SHA256SUMS.txt`: + +``` +squad-linux-x64.tar.gz squad-darwin-x64.tar.gz squad-win32-x64.zip +squad-linux-arm64.tar.gz squad-darwin-arm64.tar.gz squad-win32-arm64.zip +SHA256SUMS.txt +``` + +## What is in a bundle + +``` +squad--/ +├── squad launcher (squad.cmd + squad.ps1 on Windows) +├── runtime/ vendored Node.js runtime +├── app/ squad-cli, squad-sdk, templates, presets +└── BUNDLE-INFO.json version, target, vendored Node version, build time +``` + +A bundle is roughly **110 MB** unpacked, comparable to GitHub Copilot CLI's own +platform archives. + +Squad locates its templates, presets and version by walking up from the module +path at runtime, so the bundle deliberately preserves a real directory layout +rather than compiling to a single executable. That keeps behavior identical to +an npm install with no source changes. + +## Prerequisite: Copilot CLI + +Squad drives the GitHub Copilot CLI, which is **not** included in the bundle. +Copilot CLI already ships its own npm-free installers, so vendoring a second +copy would only add ~318 MB and a version-skew problem: + +```sh +curl -fsSL https://gh.io/copilot-install | bash # macOS/Linux +winget install GitHub.Copilot # Windows +brew install --cask copilot-cli # Homebrew +``` + +## Using it in CI + +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. + +```yaml +- name: Install Squad + env: + SQUAD_VERSION: v0.11.0 + run: | + curl -fsSL https://raw.githubusercontent.com/bradygaster/squad/dev/scripts/install.sh \ + | VERSION="${SQUAD_VERSION}" PREFIX="${HOME}/.local" sh + echo "${HOME}/.local/bin" >> "${GITHUB_PATH}" + +- name: Initialize Squad + run: squad init --preset default --state-backend local +``` + +Set `REPO` to an internal mirror if your runners cannot reach `github.com` +either. + +## Building a bundle yourself + +```sh +npm run build +node scripts/build-standalone.mjs # host platform +node scripts/build-standalone.mjs --platform linux --arch arm64 +node scripts/build-standalone.mjs --skip-runtime # use system node +node scripts/build-standalone.mjs --include-optional # keep optional deps +``` + +Optional dependencies are omitted by default. That drops the Copilot CLI +platform binary pulled in transitively through `@github/copilot-sdk` +(~318 MB), plus `node-pty` prebuilds, the OpenTelemetry SDK and `sql.js` — +taking a bundle from roughly 546 MB to 114 MB. Pass `--include-optional` if you +need telemetry export or the `sql.js` state backend inside the bundle. + +npm is still used at *build* time to resolve the dependency tree. It is the +**runtime** dependency on the registry that these bundles remove. + +## Known limitations + +- **Cross-built bundles are not executed in CI.** A vendored runtime only runs + on a matching host, so the release workflow smoke-tests `linux-x64` by + invoking the CLI and verifies the other five targets structurally. +- **No code signing or notarization yet.** macOS Gatekeeper will quarantine the + downloaded bundle until it is signed and notarized. +- **Optional deps are excluded by default**, so the bundled CLI has no + OpenTelemetry exporter and no `sql.js` state backend unless it was built with + `--include-optional`. +- **The installer is POSIX-only.** Windows installs are download-and-unpack; + winget and Homebrew packaging are not part of this yet. diff --git a/docs/src/navigation.ts b/docs/src/navigation.ts index 192680ab6..c0f11d492 100644 --- a/docs/src/navigation.ts +++ b/docs/src/navigation.ts @@ -45,6 +45,7 @@ export const NAV_SECTIONS: NavSection[] = [ { title: 'GitLab Issues', slug: 'features/gitlab-issues' }, { title: 'Ralph — Work Monitor', slug: 'features/ralph' }, { title: '@copilot Coding Agent', slug: 'features/copilot-coding-agent' }, + { title: 'Standalone Install (no npm)', slug: 'features/standalone-install' }, { title: 'VS Code', slug: 'features/vscode' }, ], }, diff --git a/scripts/build-standalone.mjs b/scripts/build-standalone.mjs new file mode 100644 index 000000000..8b5535efb --- /dev/null +++ b/scripts/build-standalone.mjs @@ -0,0 +1,331 @@ +#!/usr/bin/env node +/** + * Build a self-contained, npm-free Squad bundle for a single platform/arch. + * + * Produces a directory tree that runs `squad` with no Node.js and no npm + * present on the target machine: + * + * squad--/ + * squad | squad.cmd launcher + * runtime/ vendored Node.js runtime + * app/node_modules/... squad-cli + squad-sdk + prod deps + * + * Why a directory tree rather than a single compiled executable: + * squad-cli and squad-sdk locate `templates/`, `presets/builtin/` and + * `package.json` by walking up from `import.meta.url` at runtime + * (see getTemplatesDir, getPackageVersion, getBuiltinPresetsDir). Preserving + * the real on-disk layout keeps every one of those lookups working, so this + * ships with zero source changes. It is also the same shape GitHub's own + * Copilot CLI publishes. + * + * Usage: + * node scripts/build-standalone.mjs # host platform + * node scripts/build-standalone.mjs --platform linux --arch x64 + * node scripts/build-standalone.mjs --node-version 22.20.0 + * node scripts/build-standalone.mjs --skip-runtime # app tree only + * node scripts/build-standalone.mjs --include-optional # fat bundle + */ + +import { execFileSync } from 'node:child_process'; +import { + cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, + renameSync, rmSync, writeFileSync, chmodSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +/** Node.js runtime vendored into the bundle. Must satisfy squad's engines field (>=22.5.0). */ +const DEFAULT_NODE_VERSION = '22.20.0'; + +/** Platform/arch pairs we publish, matching the GitHub Copilot CLI release matrix. */ +const SUPPORTED = [ + 'linux-x64', 'linux-arm64', + 'darwin-x64', 'darwin-arm64', + 'win32-x64', 'win32-arm64', +]; + +function parseArgs(argv) { + const args = { + platform: process.platform, + arch: process.arch, + nodeVersion: DEFAULT_NODE_VERSION, + outDir: path.join(REPO_ROOT, 'dist-standalone'), + skipRuntime: false, + includeOptional: false, + }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + const next = () => { + const value = argv[++i]; + if (value === undefined) throw new Error(`Missing value for ${arg}`); + return value; + }; + switch (arg) { + case '--platform': args.platform = next(); break; + case '--arch': args.arch = next(); break; + case '--node-version': args.nodeVersion = next().replace(/^v/, ''); break; + case '--out-dir': args.outDir = path.resolve(next()); break; + case '--skip-runtime': args.skipRuntime = true; break; + case '--include-optional': args.includeOptional = true; break; + case '--help': case '-h': printUsage(); process.exit(0); break; + default: throw new Error(`Unknown argument: ${arg}`); + } + } + const target = `${args.platform}-${args.arch}`; + if (!SUPPORTED.includes(target)) { + throw new Error(`Unsupported target "${target}". Supported: ${SUPPORTED.join(', ')}`); + } + return args; +} + +function printUsage() { + console.log(`Build a self-contained Squad bundle. + +Options: + --platform

linux | darwin | win32 (default: host) + --arch x64 | arm64 (default: host) + --node-version Node runtime to vendor (default: ${DEFAULT_NODE_VERSION}) + --out-dir

Output directory (default: dist-standalone/) + --skip-runtime Build the app tree without vendoring a Node runtime + --include-optional Keep optional dependencies (see note below) + -h, --help Show this message + +Optional dependencies are omitted by default. That drops the vendored Copilot +CLI platform binary (~318 MB) pulled in transitively by @github/copilot-sdk, +along with node-pty prebuilds, the OpenTelemetry SDK and sql.js. Squad already +requires the \`copilot\` CLI on PATH, and Copilot CLI ships its own npm-free +installers, so shipping a second copy inside this bundle is redundant. + +Targets: ${SUPPORTED.join(', ')}`); +} + +function run(cmd, cmdArgs, cwd) { + return execFileSync(cmd, cmdArgs, { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf-8', + shell: process.platform === 'win32', + }); +} + +function step(message) { + console.log(`\x1b[36m→\x1b[0m ${message}`); +} + +/** + * Pack the two workspace packages into tarballs so the bundle installs exactly + * what would be published, rather than whatever happens to be in node_modules. + */ +function packWorkspaces(stagingDir) { + const tarballs = {}; + for (const pkg of ['squad-sdk', 'squad-cli']) { + const pkgDir = path.join(REPO_ROOT, 'packages', pkg); + // --ignore-scripts: prepublishOnly would re-run the full build for each pack. + const output = run('npm', ['pack', '--pack-destination', stagingDir, '--ignore-scripts', '--silent'], pkgDir); + const name = output.trim().split('\n').filter(Boolean).pop(); + if (!name) throw new Error(`npm pack produced no tarball for ${pkg}`); + tarballs[pkg] = path.join(stagingDir, name); + if (!existsSync(tarballs[pkg])) throw new Error(`Expected tarball missing: ${tarballs[pkg]}`); + } + return tarballs; +} + +/** + * Install the packed tarballs into the bundle's app/ directory. + * + * --omit=dev keeps the tree to runtime dependencies. + * --omit=optional (default) drops the transitively-vendored Copilot CLI + * platform binary, node-pty prebuilds, the OpenTelemetry SDK and sql.js. + * --ignore-scripts skips squad-cli's postinstall patches. Those patch + * vscode-jsonrpc for strict-ESM resolution, but cli-entry.js already applies + * the same fix at runtime via a Module._resolveFilename hook, so the bundle + * does not depend on the install-time patch having run. + */ +function installApp(appDir, tarballs, version, includeOptional) { + mkdirSync(appDir, { recursive: true }); + writeFileSync( + path.join(appDir, 'package.json'), + `${JSON.stringify({ + name: 'squad-standalone', + version, + private: true, + type: 'module', + dependencies: { + '@bradygaster/squad-cli': `file:${path.basename(tarballs['squad-cli'])}`, + '@bradygaster/squad-sdk': `file:${path.basename(tarballs['squad-sdk'])}`, + }, + }, null, 2)}\n`, + ); + for (const tarball of Object.values(tarballs)) { + cpSync(tarball, path.join(appDir, path.basename(tarball))); + } + const omitFlags = includeOptional ? ['--omit=dev'] : ['--omit=dev', '--omit=optional']; + run('npm', ['install', ...omitFlags, '--ignore-scripts', '--no-audit', '--no-fund', '--loglevel=error'], appDir); + // The tarballs are only install inputs — no need to ship them. + for (const tarball of Object.values(tarballs)) { + rmSync(path.join(appDir, path.basename(tarball)), { force: true }); + } +} + +function nodeArtifactName(platform, arch, version) { + const slug = platform === 'win32' ? `win-${arch}` : `${platform}-${arch}`; + const ext = platform === 'win32' ? 'zip' : 'tar.gz'; + return { base: `node-v${version}-${slug}`, file: `node-v${version}-${slug}.${ext}`, ext }; +} + +/** Download the official Node.js build for the target and vendor just the runtime. */ +function vendorRuntime(bundleDir, platform, arch, version, stagingDir) { + const { base, file, ext } = nodeArtifactName(platform, arch, version); + const url = `https://nodejs.org/dist/v${version}/${file}`; + const archivePath = path.join(stagingDir, file); + + step(`Downloading Node ${version} for ${platform}-${arch}`); + run('curl', ['-fsSL', '-o', archivePath, url], stagingDir); + + step('Extracting runtime'); + run('tar', ['-xf', archivePath, '-C', stagingDir], stagingDir); + + const extracted = path.join(stagingDir, base); + if (!existsSync(extracted)) { + throw new Error(`Expected extracted runtime at ${extracted}`); + } + + const runtimeDir = path.join(bundleDir, 'runtime'); + 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'); + } + } 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')); + chmodSync(path.join(runtimeDir, 'bin', 'node'), 0o755); + } + + rmSync(archivePath, { force: true }); + rmSync(extracted, { recursive: true, force: true }); + return ext; +} + +const CLI_ENTRY = 'app/node_modules/@bradygaster/squad-cli/dist/cli-entry.js'; + +function writeLaunchers(bundleDir, platform, skipRuntime) { + if (platform === 'win32') { + 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'), + ); + // PowerShell shim so `squad` resolves for users whose PATHEXT excludes .CMD. + writeFileSync( + path.join(bundleDir, 'squad.ps1'), + [ + '$ErrorActionPreference = "Stop"', + '$root = Split-Path -Parent $MyInvocation.MyCommand.Path', + skipRuntime ? '$node = "node"' : '$node = Join-Path $root "runtime\\node.exe"', + `& $node (Join-Path $root "${CLI_ENTRY.replace(/\//g, '\\')}") @args`, + 'exit $LASTEXITCODE', + '', + ].join('\r\n'), + ); + return; + } + + const launcher = path.join(bundleDir, 'squad'); + writeFileSync( + launcher, + [ + '#!/bin/sh', + '# Squad standalone launcher — resolves symlinks so the bundle works via PATH.', + 'set -e', + 'target="$0"', + 'while [ -L "$target" ]; do', + ' link=$(readlink "$target")', + ' case "$link" in', + ' /*) target="$link" ;;', + ' *) target="$(dirname "$target")/$link" ;;', + ' esac', + 'done', + 'root="$(cd "$(dirname "$target")" && pwd)"', + skipRuntime ? 'node_bin="node"' : 'node_bin="$root/runtime/bin/node"', + `exec "$node_bin" "$root/${CLI_ENTRY}" "$@"`, + '', + ].join('\n'), + ); + chmodSync(launcher, 0o755); +} + +function directorySize(dir) { + let total = 0; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) total += directorySize(full); + else if (entry.isFile()) total += readFileSync(full).byteLength; + } + return total; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const target = `${args.platform}-${args.arch}`; + const version = JSON.parse( + readFileSync(path.join(REPO_ROOT, 'packages', 'squad-cli', 'package.json'), 'utf-8'), + ).version; + + const bundleName = `squad-${target}`; + const bundleDir = path.join(args.outDir, bundleName); + const stagingDir = mkdtempSync(path.join(tmpdir(), 'squad-standalone-')); + + console.log(`\nBuilding Squad ${version} standalone bundle for ${target}\n`); + + try { + rmSync(bundleDir, { recursive: true, force: true }); + mkdirSync(bundleDir, { recursive: true }); + + step('Packing workspace packages'); + const tarballs = packWorkspaces(stagingDir); + + step(`Installing runtime dependencies into app/${args.includeOptional ? ' (including optional)' : ''}`); + installApp(path.join(bundleDir, 'app'), tarballs, version, args.includeOptional); + + if (args.skipRuntime) { + step('Skipping Node runtime (--skip-runtime): bundle will use system node'); + } else { + vendorRuntime(bundleDir, args.platform, args.arch, args.nodeVersion, stagingDir); + } + + step('Writing launcher'); + writeLaunchers(bundleDir, args.platform, args.skipRuntime); + + writeFileSync( + path.join(bundleDir, 'BUNDLE-INFO.json'), + `${JSON.stringify({ + squadVersion: version, + target, + nodeVersion: args.skipRuntime ? null : args.nodeVersion, + optionalDependencies: args.includeOptional ? 'included' : 'omitted', + builtAt: new Date().toISOString(), + }, null, 2)}\n`, + ); + + const mb = (directorySize(bundleDir) / 1024 / 1024).toFixed(1); + console.log(`\n\x1b[32m✓\x1b[0m ${bundleName} (${mb} MB)`); + console.log(` ${bundleDir}\n`); + } finally { + rmSync(stagingDir, { recursive: true, force: true }); + } +} + +main(); diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 000000000..25ff424ab --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,121 @@ +#!/bin/sh +# Squad standalone installer. +# +# curl -fsSL https://raw.githubusercontent.com/bradygaster/squad/dev/scripts/install.sh | sh +# +# Downloads a self-contained Squad bundle from GitHub Releases. The bundle +# vendors its own Node.js runtime, so neither Node nor npm needs to be present. +# Nothing is fetched from registry.npmjs.org. +# +# Environment: +# VERSION Squad version to install (default: latest release) +# PREFIX Install prefix (default: /usr/local as root, else $HOME/.local) +# REPO Source repository (default: bradygaster/squad) +# +# Mirrors the shape of GitHub Copilot CLI's installer so the two feel the same. + +set -eu + +REPO="${REPO:-bradygaster/squad}" +VERSION="${VERSION:-latest}" + +if [ -z "${PREFIX:-}" ]; then + if [ "$(id -u)" = "0" ]; then PREFIX="/usr/local"; else PREFIX="$HOME/.local"; fi +fi + +err() { printf '\033[31merror:\033[0m %s\n' "$1" >&2; exit 1; } +info() { printf '\033[36m→\033[0m %s\n' "$1"; } + +need() { command -v "$1" >/dev/null 2>&1 || err "'$1' is required but was not found on PATH."; } +need tar + +if command -v curl >/dev/null 2>&1; then + fetch() { curl -fsSL "$1" -o "$2"; } + fetch_stdout() { curl -fsSL "$1"; } +elif command -v wget >/dev/null 2>&1; then + fetch() { wget -qO "$2" "$1"; } + fetch_stdout() { wget -qO- "$1"; } +else + err "Either curl or wget is required." +fi + +# ---------------------------------------------------------------- detect target +os="$(uname -s)" +case "$os" in + Linux) platform="linux" ;; + Darwin) platform="darwin" ;; + *) err "Unsupported operating system: $os. Windows users: install via winget or download the .zip from GitHub Releases." ;; +esac + +machine="$(uname -m)" +case "$machine" in + x86_64 | amd64) arch="x64" ;; + aarch64 | arm64) arch="arm64" ;; + *) err "Unsupported architecture: $machine" ;; +esac + +target="${platform}-${arch}" + +# ------------------------------------------------------------- resolve version +if [ "$VERSION" = "latest" ]; then + info "Resolving latest release" + VERSION="$(fetch_stdout "https://api.github.com/repos/${REPO}/releases/latest" \ + | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ + | head -n 1)" + [ -n "$VERSION" ] || err "Could not resolve the latest release tag for ${REPO}." +fi + +asset="squad-${target}.tar.gz" +url="https://github.com/${REPO}/releases/download/${VERSION}/${asset}" + +# -------------------------------------------------------------------- download +tmp="$(mktemp -d)" +# shellcheck disable=SC2064 +trap "rm -rf '$tmp'" EXIT INT TERM + +info "Downloading ${asset} (${VERSION})" +fetch "$url" "$tmp/$asset" || err "Download failed: $url" + +# Verify against the release checksum file when it is published. +if fetch "https://github.com/${REPO}/releases/download/${VERSION}/SHA256SUMS.txt" "$tmp/SHA256SUMS.txt" 2>/dev/null; then + if command -v sha256sum >/dev/null 2>&1; then + expected="$(grep " ${asset}\$" "$tmp/SHA256SUMS.txt" | awk '{print $1}')" + actual="$(sha256sum "$tmp/$asset" | awk '{print $1}')" + elif command -v shasum >/dev/null 2>&1; then + expected="$(grep " ${asset}\$" "$tmp/SHA256SUMS.txt" | awk '{print $1}')" + actual="$(shasum -a 256 "$tmp/$asset" | awk '{print $1}')" + else + expected=""; actual="" + fi + if [ -n "$expected" ] && [ "$expected" != "$actual" ]; then + err "Checksum mismatch for ${asset}. Expected ${expected}, got ${actual}." + fi + [ -n "$expected" ] && info "Checksum verified" +fi + +# --------------------------------------------------------------------- install +libdir="${PREFIX}/lib/squad" +bindir="${PREFIX}/bin" + +info "Installing to ${libdir}" +mkdir -p "$libdir" "$bindir" +rm -rf "${libdir:?}/"* +tar -xzf "$tmp/$asset" -C "$tmp" +# The archive contains a single squad-/ directory. +cp -R "$tmp/squad-${target}/." "$libdir/" +chmod +x "$libdir/squad" +[ -f "$libdir/runtime/bin/node" ] && chmod +x "$libdir/runtime/bin/node" + +ln -sf "$libdir/squad" "$bindir/squad" + +printf '\033[32m✓\033[0m Squad %s installed to %s\n' "$VERSION" "$bindir/squad" + +case ":${PATH}:" in + *":${bindir}:"*) ;; + *) printf '\n\033[33m!\033[0m %s is not on your PATH. Add it with:\n export PATH="%s:$PATH"\n' "$bindir" "$bindir" ;; +esac + +if ! command -v copilot >/dev/null 2>&1; then + printf '\n\033[33m!\033[0m GitHub Copilot CLI was not found on PATH. Squad drives it, so install it too:\n' + printf ' curl -fsSL https://gh.io/copilot-install | bash\n' +fi diff --git a/test/docs-build.test.ts b/test/docs-build.test.ts index 68e4ba0d7..2588f2f55 100644 --- a/test/docs-build.test.ts +++ b/test/docs-build.test.ts @@ -90,6 +90,7 @@ const EXPECTED_FEATURES = [ 'reviewer-protocol', 'routing', 'skills', + 'standalone-install', 'storage-provider', 'squad-rc', 'security-hardening',