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',