diff --git a/.agents/skills/release-openspec/SKILL.md b/.agents/skills/release-openspec/SKILL.md index 99d4f5df59..19c03a6c03 100644 --- a/.agents/skills/release-openspec/SKILL.md +++ b/.agents/skills/release-openspec/SKILL.md @@ -146,7 +146,8 @@ After the Version Packages PR merges: - `npm view @fission-ai/openspec@ version` - remote tag `v` points at the expected commit; - `gh release view v` exists and is not a prerelease. -3. If only some artifacts exist, report partial state and resume verification before retrying any publish action. Never republish a version already on npm. + - The release's assets include the 8 `openspec-*` standalone binaries and `SHA256SUMS`, attached by the Binaries workflow (`binaries.yml`) about 10 minutes after the release is published. Until `SHA256SUMS` is there, the install scripts keep installing the previous release, so users are never left without one. +3. If only some artifacts exist, report partial state and resume verification before retrying any publish action. Never republish a version already on npm. Missing binaries never need an npm action: re-run `binaries.yml` with `workflow_dispatch` and the release tag to attach them. 4. Once all artifacts exist, read [references/release-notes.md](references/release-notes.md), polish the GitHub Release, and verify the saved title/body. ## Cut a beta diff --git a/.changeset/standalone-binary.md b/.changeset/standalone-binary.md new file mode 100644 index 0000000000..b89f365938 --- /dev/null +++ b/.changeset/standalone-binary.md @@ -0,0 +1,5 @@ +--- +'@fission-ai/openspec': minor +--- + +OpenSpec now installs as a single file with no Node.js needed: `curl -fsSL https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary/install.sh | sh` on macOS and Linux, or `irm https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary/install.ps1 | iex` on Windows. Every release now includes binaries for macOS, Linux (glibc and musl) and Windows on x64 and arm64, with `SHA256SUMS`. npm and the other package managers keep working as before. diff --git a/.github/workflows/binaries.yml b/.github/workflows/binaries.yml new file mode 100644 index 0000000000..22cfec30fc --- /dev/null +++ b/.github/workflows/binaries.yml @@ -0,0 +1,295 @@ +name: Binaries + +# Standalone `openspec` executables for people without Node.js (#2055). +# One Linux job cross-compiles every asset; each is then tested on its own OS +# and CPU, and only then attached to a published release. Not run in the merge +# queue: the PR run covers the change, and a release re-verifies before upload. +on: + pull_request: + branches: [main] + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: Existing release tag to build and attach binaries to + required: true + +permissions: + contents: read + +concurrency: + group: binaries-${{ github.ref }}-${{ inputs.tag }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + BUN_VERSION: '1.4.3' + # The release's tag, or the commit under test. + REF: ${{ github.event.release.tag_name || inputs.tag || github.sha }} + +jobs: + build: + name: Build + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.REF }} + persist-credentials: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + # No dependency caches: this job builds release binaries, and a cache + # written by a pull request run could poison them. + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '20.19.0' + package-manager-cache: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: ${{ env.BUN_VERSION }} + no-cache: true + - run: pnpm install --frozen-lockfile + - run: pnpm run build + - run: bun scripts/binary/build.mjs --all + # The binaries report package.json's version (smoke.sh checks), so this + # keeps assets off a release they do not belong to. + - name: Check the version matches the release tag + if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' + run: | + version=$(node -p "require('./package.json').version") + # Tags are v1.2.3, or @fission-ai/openspec@1.2.3 as changesets first + # creates them. Exact match: a suffix match would let v11.0.0 pass for 1.0.0. + tag_version=${REF##*@} + tag_version=${tag_version#v} + [[ "$tag_version" == "$version" ]] || { + echo "::error::package.json version $version does not match tag $REF" + exit 1 + } + - name: Checksums and installer + run: | + cd dist-bin + sha256sum openspec-* > SHA256SUMS + cp ../scripts/binary/install.sh ../scripts/binary/install.ps1 . + cat SHA256SUMS + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: binaries + path: dist-bin/ + if-no-files-found: error + retention-days: 7 + + verify: + name: Verify ${{ matrix.asset }} + needs: build + runs-on: ${{ matrix.os }} + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + include: + - { asset: linux-x64, os: ubuntu-latest } + - { asset: linux-arm64, os: ubuntu-24.04-arm } + # Oldest Alpine whose libstdc++ the musl build links against. + - { asset: linux-x64-musl, os: ubuntu-latest, alpine: '3.17' } + - { asset: linux-arm64-musl, os: ubuntu-24.04-arm, alpine: '3.17' } + - { asset: darwin-x64, os: macos-15-intel } + - { asset: darwin-arm64, os: macos-latest } + - { asset: windows-x64, os: windows-latest } + - { asset: windows-arm64, os: windows-11-arm } + defaults: + run: + shell: bash + env: + ASSET: ${{ matrix.asset }} + ALPINE: ${{ matrix.alpine }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.REF }} + persist-credentials: false + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: binaries + path: dist-bin + - run: chmod +x dist-bin/openspec-* + + - name: Smoke test + run: | + if [ -n "$ALPINE" ]; then + docker run --rm -v "$PWD:/src" -w /src "alpine:$ALPINE" sh -c \ + "apk add --no-cache bash libstdc++ libgcc >/dev/null && bash scripts/binary/smoke.sh dist-bin/openspec-$ASSET" + elif [ "$RUNNER_OS" = Windows ]; then + bash scripts/binary/smoke.sh "dist-bin/openspec-$ASSET.exe" + else + bash scripts/binary/smoke.sh "dist-bin/openspec-$ASSET" + fi + + - name: Run from PowerShell and cmd + if: runner.os == 'Windows' + shell: pwsh + run: | + $exe = "dist-bin\openspec-$env:ASSET.exe" + & $exe --version + if ($LASTEXITCODE -ne 0) { exit 1 } + cmd /c "$exe --version" + if ($LASTEXITCODE -ne 0) { exit 1 } + + # Serves this build as a release and checks the installer picks exactly + # this asset on this machine, verifies it, and installs a working copy. + - name: Install script + if: runner.os != 'Windows' + run: | + python3 -m http.server 8765 --directory dist-bin >/dev/null 2>&1 & + for _ in $(seq 20); do curl -sf -o /dev/null http://127.0.0.1:8765/SHA256SUMS && break; sleep 0.5; done + if [ -n "$ALPINE" ]; then + docker run --rm --network host -v "$PWD/dist-bin:/dist:ro" "alpine:$ALPINE" sh -c \ + "apk add --no-cache libstdc++ libgcc >/dev/null && + OPENSPEC_DOWNLOAD_URL=http://127.0.0.1:8765 OPENSPEC_INSTALL_DIR=/opt/bin sh /dist/install.sh && + cmp /opt/bin/openspec /dist/openspec-$ASSET" + else + OPENSPEC_DOWNLOAD_URL=http://127.0.0.1:8765 OPENSPEC_INSTALL_DIR="$RUNNER_TEMP/bin" sh dist-bin/install.sh + cmp "$RUNNER_TEMP/bin/openspec" "dist-bin/openspec-$ASSET" + fi + + # Same as above for Windows, in both PowerShell 7 and the Windows + # PowerShell 5.1 every Windows 10/11 machine has, through `irm | iex`. + - name: Install script (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + Invoke-ScriptAnalyzer -Path scripts/binary/install.ps1 -Severity Warning,Error -ExcludeRule PSAvoidUsingWriteHost -EnableExit + $server = Start-Process python -ArgumentList '-m', 'http.server', '8765', '--directory', 'dist-bin' -PassThru -WindowStyle Hidden + foreach ($i in 1..40) { try { Invoke-WebRequest -UseBasicParsing http://127.0.0.1:8765/SHA256SUMS | Out-Null; break } catch { Start-Sleep -Milliseconds 500 } } + # A user PATH entry that relies on expansion must survive untouched. + $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true) + $before = [string]$key.GetValue('Path', '', 'DoNotExpandEnvironmentNames') + $key.SetValue('Path', "$before;%USERPROFILE%\openspec-path-probe", 'ExpandString') + try { + foreach ($shell in 'pwsh', 'powershell') { + $dir = Join-Path $env:RUNNER_TEMP "install $shell" + foreach ($run in 1, 2) { + & $shell -NoProfile -Command "`$env:OPENSPEC_DOWNLOAD_URL='http://127.0.0.1:8765'; `$env:OPENSPEC_INSTALL_DIR='$dir'; irm http://127.0.0.1:8765/install.ps1 | iex" + if ($LASTEXITCODE -ne 0) { throw "$shell install run $run failed" } + } + $exe = Join-Path $dir 'openspec.exe' + if ((Get-FileHash $exe).Hash -ne (Get-FileHash "dist-bin/openspec-$env:ASSET.exe").Hash) { throw "$shell installed the wrong file" } + & $exe --version + if ($LASTEXITCODE -ne 0) { throw "$shell's installed copy does not run" } + $raw = [string]$key.GetValue('Path', '', 'DoNotExpandEnvironmentNames') + if (@($raw -split ';' | Where-Object { $_ -eq $dir }).Count -ne 1) { throw "$shell did not add $dir to the user PATH exactly once: $raw" } + if ($raw -notlike '*%USERPROFILE%\openspec-path-probe*') { throw "$shell expanded the user PATH: $raw" } + if ($key.GetValueKind('Path') -ne 'ExpandString') { throw "$shell changed the user PATH type" } + } + # A relative folder is saved to PATH as an absolute one; a custom name is kept. + Push-Location $env:RUNNER_TEMP + pwsh -NoProfile -Command "`$env:OPENSPEC_DOWNLOAD_URL='http://127.0.0.1:8765'; `$env:OPENSPEC_INSTALL_DIR='relative install'; `$env:OPENSPEC_INSTALL_NAME='openspec-renamed.exe'; irm http://127.0.0.1:8765/install.ps1 | iex" + Pop-Location + if ($LASTEXITCODE -ne 0) { throw 'relative install failed' } + $abs = Join-Path $env:RUNNER_TEMP 'relative install' + if (-not (Test-Path (Join-Path $abs 'openspec-renamed.exe'))) { throw 'custom name not used' } + $raw = [string]$key.GetValue('Path', '', 'DoNotExpandEnvironmentNames') + if (($raw -split ';') -notcontains $abs) { throw "relative folder not saved as $abs : $raw" } + + # Mid-release: the newest release has no SHA256SUMS yet (still + # uploading, or the upload failed). Its previous release installs. + $site = Join-Path (Get-Location) 'dist-bin/site' + foreach ($tag in 'v1.1.0', 'v1.2.0') { + $d = Join-Path $site "releases/download/$tag" + New-Item -ItemType Directory -Force -Path $d | Out-Null + Copy-Item "dist-bin/openspec-$env:ASSET.exe" $d + if ($tag -eq 'v1.1.0') { Copy-Item dist-bin/SHA256SUMS $d } + } + Set-Content -Path (Join-Path $site 'releases.atom') -Value ('' + (('v1.2.0', 'v1.1.0' | ForEach-Object { "" }) -join '') + '') + foreach ($shell in 'pwsh', 'powershell') { + $dir = Join-Path $env:RUNNER_TEMP "fallback $shell" + $out = & $shell -NoProfile -Command "`$env:OPENSPEC_RELEASES_URL='http://127.0.0.1:8765/site/releases'; `$env:OPENSPEC_INSTALL_DIR='$dir'; irm http://127.0.0.1:8765/install.ps1 | iex" 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "$shell fallback install failed: $out" } + if ($out -notmatch 'v1\.2\.0 is still being prepared; installing v1\.1\.0\.') { throw "$shell did not fall back with a note: $out" } + if ((Get-FileHash (Join-Path $dir 'openspec.exe')).Hash -ne (Get-FileHash "dist-bin/openspec-$env:ASSET.exe").Hash) { throw "$shell fallback installed the wrong file" } + # A localhost-looking URL whose real host is remote is refused before any download. + $out = & $shell -NoProfile -Command "`$env:OPENSPEC_DOWNLOAD_URL='http://localhost:80@evil.example/x'; `$env:OPENSPEC_INSTALL_DIR='$dir-evil'; irm http://127.0.0.1:8765/install.ps1 | iex" 2>&1 | Out-String + if ($LASTEXITCODE -eq 0 -or $out -notmatch 'must use https') { throw "$shell accepted a remote host behind localhost: $out" } + if (Test-Path "$dir-evil/openspec.exe") { throw "$shell installed from a refused source" } + } + } finally { + $key.SetValue('Path', $before, 'ExpandString') + $key.Close() + Stop-Process $server + } + + # The repo's own CLI tests, run against this binary instead of Node. + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + if: matrix.alpine == '' + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + if: matrix.alpine == '' + with: + node-version: '20.19.0' + package-manager-cache: false + - name: CLI test suite and `openspec install` against the binary + run: | + # `openspec install` (install-check.sh) downloads from this build, served locally. + # On Windows, python3 can be the Microsoft Store stub; python is the real one. + py=python3; [ "$RUNNER_OS" = Windows ] && py=python + $py -m http.server 8766 --directory dist-bin >/dev/null 2>&1 & + for _ in $(seq 20); do curl -sf -o /dev/null http://127.0.0.1:8766/SHA256SUMS && break; sleep 0.5; done + if [ -n "$ALPINE" ]; then + # Copied in: the container installs its own (musl) dependencies. + # Run as a normal user; root can read files the tests make unreadable. + docker run --rm --network host -v "$PWD:/src:ro" node:22-alpine sh -c \ + "apk add -q --no-cache bash git libstdc++ libgcc && corepack enable && + mkdir /w && cd /src && tar --exclude=./node_modules -cf - . | tar -xf - -C /w && chown -R node /w && + su node -c 'cd /w && pnpm install --frozen-lockfile && pnpm run build && + bash scripts/binary/e2e.sh dist-bin/openspec-$ASSET && + OPENSPEC_DOWNLOAD_URL=http://127.0.0.1:8766 bash scripts/binary/install-check.sh dist-bin/openspec-$ASSET'" + else + pnpm install --frozen-lockfile + pnpm run build + bin="dist-bin/openspec-$ASSET" + [ "$RUNNER_OS" = Windows ] && bin="$bin.exe" + bash scripts/binary/e2e.sh "$bin" + OPENSPEC_DOWNLOAD_URL=http://127.0.0.1:8766 bash scripts/binary/install-check.sh "$bin" + fi + + release: + name: Attach to release + needs: verify + if: github.repository == 'Fission-AI/OpenSpec' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch') + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write # upload release assets + id-token: write # sign the provenance attestation + attestations: write # store it + artifact-metadata: write # record the attested files + steps: + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: binaries + path: dist-bin + - name: Upload + env: + GH_TOKEN: ${{ github.token }} + RELEASE_ID: ${{ github.event.release.id }} + run: | + cd dist-bin + sha256sum --check SHA256SUMS + # A release can be retagged while this runs (the scoped + # @fission-ai/openspec@x.y.z tag corrected to vx.y.z), so find it by + # id, which never changes, and upload to whatever tag it has now. + tag="$REF" + if [ -n "$RELEASE_ID" ]; then + tag=$(gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" --jq .tag_name) + fi + # SHA256SUMS goes last: installers treat a release that has it as + # complete and keep installing the previous one until then. The + # installers themselves are served from main, not from releases. + gh release upload "$tag" openspec-* --clobber --repo "$GITHUB_REPOSITORY" + gh release upload "$tag" SHA256SUMS --clobber --repo "$GITHUB_REPOSITORY" + # Signed proof each binary was built by this workflow from this tag, which + # anyone can check with `gh attestation verify`. The binaries are not + # notarized or code-signed yet, so this is the verifiable trail meanwhile. + # After the upload, so a first-time problem here never holds binaries back. + - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + continue-on-error: true + with: + subject-path: 'dist-bin/openspec-*' diff --git a/.gitignore b/.gitignore index 1fb5c4a26a..7ff3e1ecd3 100644 --- a/.gitignore +++ b/.gitignore @@ -166,3 +166,7 @@ opencode.json # Cursor .cursor/ + +# Standalone binaries (scripts/binary/build.mjs); Bun can leave .bun-build temp files +dist-bin/ +*.bun-build diff --git a/README.md b/README.md index 5839c5d783..ce794e0949 100644 --- a/README.md +++ b/README.md @@ -117,20 +117,22 @@ Solo, OpenSpec keeps you and your AI honest on a single repo. On a team, the har ## Quick Start -**Requires Node.js 20.19.0 or higher.** Homebrew installs it as a dependency. +Install OpenSpec. It is a single file and needs no Node.js (on Alpine Linux, first run `apk add libstdc++ libgcc`). -Install OpenSpec globally: +macOS and Linux: ```bash -npm install -g @fission-ai/openspec@latest +curl -fsSL https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary/install.sh | sh ``` -Or install the official [Homebrew formula](https://formulae.brew.sh/formula/openspec) on macOS or Linux: +Windows (PowerShell): -```bash -brew install openspec +```powershell +irm https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary/install.ps1 | iex ``` +Prefer a package manager? `npm install -g @fission-ai/openspec@latest` (needs Node.js 20.19.0 or higher) or the official [Homebrew formula](https://formulae.brew.sh/formula/openspec), `brew install openspec`. + Then navigate to your project directory and initialize: ```bash @@ -152,7 +154,7 @@ Both are in the default profile. If you want the expanded workflow (`/opsx:new`, > [!NOTE] > Not sure if your tool is supported? [View the full list](https://openspec.dev/docs/supported-tools) – we support 30+ tools and growing. > -> Also works with Homebrew, pnpm, yarn, bun, and Nix. [See installation options](https://openspec.dev/docs/installation). +> Also works with npm, Homebrew, pnpm, yarn, bun, and Nix. [See installation options](https://openspec.dev/docs/installation). ## Docs @@ -192,7 +194,9 @@ AI coding assistants are powerful but unpredictable when requirements live only ## Updating OpenSpec -**Upgrade the package** +**Upgrade the CLI** + +With the standalone install, run `openspec install`. If you installed with npm: ```bash npm install -g @fission-ai/openspec@latest diff --git a/docs-lab/reference/cli.md b/docs-lab/reference/cli.md index 5602e96d02..982acf456d 100644 --- a/docs-lab/reference/cli.md +++ b/docs-lab/reference/cli.md @@ -51,6 +51,7 @@ Your agent runs most of these during the workflow. | Command | What it does | |---|---| | [`openspec version`](#openspec-version) | Report the installed version and optionally check for an update. | +| [`openspec install`](#openspec-install) | Install the standalone `openspec`, which runs without Node.js. | | [`openspec feedback`](#openspec-feedback) | Submit feedback about OpenSpec. | | [`openspec completion`](#openspec-completion) | Install or generate shell completions. | @@ -2266,7 +2267,7 @@ For a global npm install: OpenSpec 1.13.2 (npm, global) ``` -The install scope is `global`, `project`, `temporary` for an ephemeral runner such as npx, or `source` for a checkout. OpenSpec omits details it cannot identify instead of guessing. +The install scope is `global`, `project`, `temporary` for an ephemeral runner such as npx, `source` for a checkout, or `standalone` for the [standalone install](../start/installation.md#install). OpenSpec omits details it cannot identify instead of guessing. `--json` keeps unknown details as explicit `null` values: @@ -2317,6 +2318,8 @@ Update status values: | `disabled` | An existing privacy or update-check setting blocked registry access. `latest` is `null`. | | `offline` | The registry was unavailable or returned an unusable response. `latest` is `null`. | +For the standalone install, the update command is `openspec install`. + `DO_NOT_TRACK`, telemetry opt-outs, `OPENSPEC_NO_UPDATE_CHECK`, CI detection, and rejected non-HTTPS registry overrides disable the check. Disabled and offline checks still exit 0 because update availability is advisory. This command never upgrades OpenSpec; `canSelfUpgrade` only reports whether the existing `openspec update` path could safely upgrade this copy. **Exit codes** @@ -2324,6 +2327,35 @@ Update status values: - `0`: the local report printed, including disabled or offline update checks. - `1`: command syntax was invalid, such as the unsupported `--upgrade` option. +## openspec install + +Installs the standalone `openspec`, a single file that runs without Node.js. Run it from an npm or Homebrew install to switch, or from the standalone install to upgrade it in place. + +```bash +openspec install # newest release +openspec install 1.15.0 # a specific release +``` + +It runs the [install script](../start/installation.md#install), which picks the newest release whose files are all attached and the file for your machine, and checks it against that release's `SHA256SUMS`: + +- **From a package manager**: installs to `~/.local/bin` (Windows: `%LOCALAPPDATA%\Programs\openspec`) and prints the command that removes the old copy, such as `npm uninstall -g @fission-ai/openspec`. It never removes that copy itself. +- **From the standalone install**: replaces the running file where it is, under its own name. + +Afterwards it says which copy `openspec` now runs, since a package-manager copy earlier on your PATH keeps running until you remove it. + +**Arguments** + +| Argument | What it is | +|---|---| +| `version` | Release to install, like `1.15.0`. Optional; defaults to the latest. | + +`OPENSPEC_INSTALL_DIR` and `OPENSPEC_INSTALL_NAME` choose another folder or file name. + +**Exit codes** + +- `0`: installed. +- `1`: the version isn't a release version, or the download or install failed. Your current copy is left as it was. + ## openspec feedback Submits feedback about OpenSpec. diff --git a/docs-lab/start/installation.md b/docs-lab/start/installation.md index 2ffdd53aa1..1e228cf821 100644 --- a/docs-lab/start/installation.md +++ b/docs-lab/start/installation.md @@ -3,24 +3,58 @@ > Install the `openspec` CLI on your machine, update it, and uninstall it. -## Prerequisites +## Install -OpenSpec runs on Node.js 20.19.0 or newer. Homebrew installs Node.js as a -dependency, and the Nix package includes the runtime. Check your installed version -before using another install method. +OpenSpec installs as a single `openspec` file. It needs no Node.js. -In your terminal: +On macOS or Linux, in your terminal: ```bash -node --version +curl -fsSL https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary/install.sh | sh ``` -If that prints `v20.19.0` or higher, you're set. If not, install a newer Node from -[nodejs.org](https://nodejs.org) or through your version manager (nvm, fnm, asdf, -volta). You can skip this check when you install with Homebrew or Nix. +On Windows, in PowerShell: + +```powershell +irm https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary/install.ps1 | iex +``` + +- **Where it goes**: `~/.local/bin`; on Windows, `%LOCALAPPDATA%\Programs\openspec`, which it adds to your user PATH. +- **What it checks**: it picks the file for your machine and verifies it against the release's `SHA256SUMS` before installing. +- **Which release**: the newest one whose files are all attached. For the few minutes after a release is published while its files upload, it installs the previous one and says so. +- **Alpine Linux**: run `apk add libstdc++ libgcc` first. +- **Options**: set `OPENSPEC_INSTALL_DIR` to install elsewhere, or `OPENSPEC_VERSION=v1.15.0` to pin a release. + +If the installer says the folder isn't on your PATH, add it as it shows. Then [check it worked](#check-it-worked). The workflow itself runs inside an AI coding tool: Claude Code, Cursor, or any other tool on the [supported list](../reference/supported-tools.md). +### Download it yourself + +Each [release](https://github.com/Fission-AI/OpenSpec/releases/latest) has one file per platform: + +| Platform | File | +|---|---| +| macOS 13+ (Apple silicon / Intel) | `openspec-darwin-arm64` / `openspec-darwin-x64` | +| Linux, glibc 2.17+ (arm64 / x64) | `openspec-linux-arm64` / `openspec-linux-x64` | +| Alpine 3.17+ (arm64 / x64) | `openspec-linux-arm64-musl` / `openspec-linux-x64-musl` | +| Windows 10+ (arm64 / x64) | `openspec-windows-arm64.exe` / `openspec-windows-x64.exe` | + +Rename it to `openspec` (`openspec.exe` on Windows), run `chmod +x openspec` on macOS or Linux, and put it in a folder on your PATH. + +> [!NOTE] +> macOS blocks a file downloaded in a browser until you run `xattr -d com.apple.quarantine openspec`. The install command above doesn't need this. + +### Verify a download + +Each release binary comes with a signed record of the workflow run that built it. With the [GitHub CLI](https://cli.github.com), in your terminal: + +```bash +gh attestation verify openspec-darwin-arm64 --repo Fission-AI/OpenSpec +``` + +It confirms the file is the one that release's workflow built, or fails. This is optional; the installer already checks every download against `SHA256SUMS`. + ## Install with your AI assistant Paste this into your AI chat: @@ -37,26 +71,32 @@ curl -fsSL https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/install.md That fetches [install.md at the repo root](https://github.com/Fission-AI/OpenSpec/blob/main/install.md), a prompt written for any agent that can run shell commands (a few IDE integrations can't). Expect your assistant to: -1. Check your Node version, and stop if it's older than 20.19.0. -2. Skip the install if the CLI is already on your machine. Otherwise, show you the install command and wait for your confirmation before running it. +1. Skip the install if the CLI is already on your machine, and offer to update it the way it was installed. If that was npm or Homebrew, mention once that `openspec install` switches to the standalone install, without pushing it. Otherwise, show you the install command above and wait for your confirmation before running it. +2. Use npm instead only if you ask for it or the installer fails, and stop if your Node is older than 20.19.0. 3. Verify `openspec` is on your PATH. 4. Name the folder it thinks you mean, suggest the AI tool you're already talking to, and ask which others you use, then run `openspec init` there (the [project setup](setup.md) step). 5. Report what init created and the exact spelling to invoke OpenSpec in your tool. -It stops before anything privileged and never edits your shell startup files. The [manual methods below](#install-methods) are the source of truth, and the prompt runs them for you. +It stops before anything privileged and never edits your shell startup files. The [install command above](#install) and the [package managers below](#package-managers) are the source of truth, and the prompt runs them for you. This install method is new and can have varying results depending on model used. Only use if you're comfortable correcting AI mistakes. Otherwise we recommend following the standard method below. -## Install methods +## Package managers + +These install OpenSpec as a Node.js package, so they need Node.js 20.19.0 or newer (Homebrew and Nix bring their own). In your terminal: -Install the CLI globally; [setting up your project](setup.md) comes after. +```bash +node --version +``` -In your terminal: +If that prints `v20.19.0` or higher, install with npm: ```npm npm install -g @fission-ai/openspec@latest ``` +If not, install a newer Node from [nodejs.org](https://nodejs.org) or through your version manager (nvm, fnm, asdf, volta), or use the [standalone install](#install) instead. + ### Homebrew Homebrew installs OpenSpec and its Node.js dependency on macOS or Linux. In your terminal: @@ -73,7 +113,7 @@ The formula is published in [homebrew-core](https://formulae.brew.sh/formula/ope ### Bun -Bun installs OpenSpec but doesn't run it, so you still need Node on your machine (the [prerequisite](#prerequisites) above). Without it, every command fails with `env: node: No such file or directory`. Bun treats [every Node CLI](https://bun.com/docs/pm/bunx#shebangs) this way. +Bun installs OpenSpec but doesn't run it, so you still need Node on your machine (see [Package managers](#package-managers)). Without it, every command fails with `env: node: No such file or directory`. Bun treats [every Node CLI](https://bun.com/docs/pm/bunx#shebangs) this way. ### Deno @@ -113,7 +153,7 @@ locations (`share/bash-completion/completions`, `share/fish/vendor_completions.d `share/zsh/site-functions`), so they load with the package and there is no need to run `openspec completion install`. -### Check it worked +## Check it worked Whichever method you used, in your terminal: @@ -125,6 +165,34 @@ If that prints a version number, the CLI is on your PATH. It installs once per m Next, [set up your project](setup.md). If your assistant already ran init, that page shows what it wrote and how to adjust it. +## Switch from npm to the standalone install + +Switching is optional; npm keeps working. Your settings and your projects carry over; only the program changes. + +**1. Install the standalone copy.** In your terminal: + +```bash +openspec install +``` + +It installs the standalone `openspec` and prints the command that removes your npm or Homebrew copy. + +**2. Remove the old copy.** It usually comes first on your PATH, so until it's gone it keeps running instead. Run the command step 1 printed, for example: + +```npm +npm uninstall -g @fission-ai/openspec +``` + +**3. Check which copy runs.** Open a new terminal: + +```bash +openspec version +``` + +It should print `OpenSpec (standalone)`. If it names a package manager instead, another copy is still on your PATH: `which -a openspec` (Windows: `where.exe openspec`) lists every copy, so remove the others. + +No project needs re-initializing. To switch back, delete the file and run the npm install again. + ## Updating In your terminal, in each project where you ran init: @@ -137,7 +205,7 @@ When a newer CLI is out, [`openspec update`](../reference/cli.md#openspec-update > [!WARNING] -> On Homebrew, run `brew upgrade openspec`. On Deno, re-run the [Deno install](#deno) with `-f`; it won't overwrite the installed command without it. On Nix, use `nix profile upgrade openspec`. +> With the standalone install, run `openspec install` to replace your copy in place; `openspec update` tells you when a newer one is out. On Homebrew, run `brew upgrade openspec`. On Deno, re-run the [Deno install](#deno) with `-f`; it won't overwrite the installed command without it. On Nix, use `nix profile upgrade openspec`. > [!NOTE] > A global npm install belongs to one Node installation. Switch Node versions with nvm and the `openspec` command doesn't come along, so install it again under the new version. @@ -152,7 +220,7 @@ To uninstall OpenSpec, run through the steps below; none of them touch your sour openspec completion uninstall ``` -**2. Remove the package.** In your terminal: +**2. Remove the CLI.** For the standalone install, delete `~/.local/bin/openspec` (Windows: `%LOCALAPPDATA%\Programs\openspec`, and that folder from your user PATH). For npm, in your terminal: ```npm npm uninstall -g @fission-ai/openspec @@ -165,4 +233,4 @@ On Homebrew: `brew uninstall openspec`. On Deno: `deno uninstall --global opensp - Generated agent files: `openspec-*` skills and `opsx` commands under directories like `.claude/` or `.agents/`, per project. [Supported tools](../reference/supported-tools.md) lists each tool's paths; MiniMax Code keeps skills in `~/.minimax/skills`. - Leftovers from older versions: marker blocks in `CLAUDE.md` or `AGENTS.md` (delete the block, keep the file) and `opsx-*.md` prompts in `~/.codex/prompts`. - The `openspec/` folder: pause first. `specs/` and `changes/archive/` are your record of the system, plain Markdown that reads fine without OpenSpec. -- Per-machine state: settings and the telemetry id in `~/.config/openspec/`; schema overrides and store registrations in `~/.local/share/openspec/` (Windows: `%APPDATA%\openspec`, `%LOCALAPPDATA%\openspec`). Registrations are pointers; the store repos they point to are untouched. +- Per-machine state: settings and the telemetry id in `~/.config/openspec/`; schema overrides, store registrations, and the standalone install's built-in schemas (`binary-schemas/`) in `~/.local/share/openspec/` (Windows: `%APPDATA%\openspec`, `%LOCALAPPDATA%\openspec`). Registrations are pointers; the store repos they point to are untouched. diff --git a/docs/README.md b/docs/README.md index 0b3f1f2135..8c5df9d12e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -99,7 +99,7 @@ That second one matters more than it looks. OpenSpec has two halves: a command l ## The thirty-second version ```text -1. Install npm install -g @fission-ai/openspec@latest +1. Install curl -fsSL https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary/install.sh | sh 2. Initialize cd your-project && openspec init 3. Explore (in your AI chat) /opsx:explore ← optional, but a great habit 4. Propose (in your AI chat) /opsx:propose add-dark-mode @@ -107,7 +107,7 @@ That second one matters more than it looks. OpenSpec has two halves: a command l 6. Archive (in your AI chat) /opsx:archive ``` -Steps 1 and 2 happen in your terminal. The rest happen in your AI assistant's chat. That split is the one thing worth memorizing, and [How Commands Work](how-commands-work.md) explains exactly why. Step 3 is optional, but starting with `/opsx:explore` when you're unsure is the habit most worth forming. +Steps 1 and 2 happen in your terminal (for Windows or npm, see [Installation](installation.md)). The rest happen in your AI assistant's chat. That split is the one thing worth memorizing, and [How Commands Work](how-commands-work.md) explains exactly why. Step 3 is optional, but starting with `/opsx:explore` when you're unsure is the habit most worth forming. ## Where else to get help diff --git a/docs/getting-started.md b/docs/getting-started.md index 373efa186d..2112a65e4c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -14,7 +14,7 @@ This guide explains how OpenSpec works after you've installed and initialized it The whole loop, with each step labeled by where it happens: ```text -TERMINAL $ npm install -g @fission-ai/openspec@latest +TERMINAL $ curl -fsSL https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary/install.sh | sh TERMINAL $ cd your-project && openspec init AI CHAT /opsx:explore (optional: think it through first) AI CHAT /opsx:propose add-dark-mode (AI drafts the plan; you review it) @@ -22,7 +22,7 @@ AI CHAT /opsx:apply (AI builds it) AI CHAT /opsx:archive (specs updated, change filed away) ``` -Two terminal steps to set up, then you live in chat. The rest of this guide unpacks what each step does and what you'll see. +Two terminal steps to set up, then you live in chat. (Windows, npm, and other ways to install are in [Installation](installation.md).) The rest of this guide unpacks what each step does and what you'll see. **Don't want to do the terminal part yourself?** Paste the [setup prompt](installation.md#install-with-your-ai-assistant) into your assistant and it handles both lines, then reports what it created. diff --git a/docs/installation.md b/docs/installation.md index a42f026dc9..759d29414e 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,24 +1,62 @@ # Installation -## Prerequisites +## Install -- **Node.js 20.19.0 or higher** — Check your version: `node --version` +OpenSpec is a single file. It needs no Node.js or anything else (on Alpine Linux, first run `apk add libstdc++ libgcc`). + +macOS and Linux: + +```bash +curl -fsSL https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary/install.sh | sh +``` + +Windows (PowerShell): + +```powershell +irm https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary/install.ps1 | iex +``` + +The installer picks the newest release whose files are all attached and the right file for your machine, checks it against the release's `SHA256SUMS`, and installs it to `~/.local/bin` (Windows: `%LOCALAPPDATA%\Programs\openspec`, added to your user `PATH`). Set `OPENSPEC_INSTALL_DIR` to choose another folder, or `OPENSPEC_VERSION=v1.15.0` to pin a release. Prefer npm or another package manager? See [Package Managers](#package-managers). + +To install by hand, download your platform's file from the [latest release](https://github.com/Fission-AI/OpenSpec/releases/latest), rename it to `openspec` (`openspec.exe` on Windows), make it executable on macOS and Linux (`chmod +x openspec`), and put it on your `PATH`: + +| Platform | File | +|---|---| +| macOS 13+ (Apple Silicon / Intel) | `openspec-darwin-arm64` / `openspec-darwin-x64` | +| Linux (glibc 2.17+) | `openspec-linux-x64` / `openspec-linux-arm64` | +| Alpine 3.17+ (needs `apk add libstdc++ libgcc`) | `openspec-linux-x64-musl` / `openspec-linux-arm64-musl` | +| Windows 10+ | `openspec-windows-x64.exe` / `openspec-windows-arm64.exe` | + +On macOS, a file downloaded with a browser is blocked until you run `xattr -d com.apple.quarantine openspec`; the install script avoids this. On Windows, SmartScreen may warn the first time you run it from Explorer. ## Install with your AI assistant Rather not do this by hand? Paste the prompt below into any coding assistant that can run shell commands — Claude Code, Codex, Cursor, Gemini CLI, Copilot, and the rest of the [supported tools](supported-tools.md). It installs the CLI, initializes this project, and reports back what actually happened. -The manual steps below are the source of truth — the prompt just runs them for you. If your assistant stops and hands something back, that's by design: it asks before anything privileged and never edits your shell startup files. Finish those bits yourself with [Package Managers](#package-managers) and [Troubleshooting](troubleshooting.md). +The manual steps below are the source of truth — the prompt just runs them for you. If your assistant stops and hands something back, that's by design: it asks before anything privileged and never edits your shell startup files. Finish those bits yourself with [Install](#install) and [Troubleshooting](troubleshooting.md). ```text Install OpenSpec in this project and set it up for me. Follow these steps in order, and stop where a step tells you to stop. -1. RUNTIME. Run `node --version`. OpenSpec needs Node.js 20.19.0 or higher. If - Node is missing or older, say so and stop — don't install Node, switch - versions, or reconfigure my version manager for me. - -2. INSTALL. Use whichever package manager is already on my PATH, preferring npm: +1. INSTALL. Run `openspec --version` first. If it prints a version, OpenSpec is + already installed: run `openspec version --check`, which prints the update + command for how it was installed when it can tell (otherwise update it the + way it was installed, e.g. `brew upgrade openspec`), offer to run that, and + skip to step 3. If I installed it with a package manager, mention once that + `openspec install` switches to the standalone install, but only switch if I + ask; never switch it to a different install method on your own. Otherwise install + the standalone OpenSpec binary, which needs no Node.js. Show me the command + for my OS and run it only once I confirm, then skip to step 3: + macOS / Linux: curl -fsSL https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary/install.sh | sh + Windows (PowerShell): irm https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary/install.ps1 | iex + If I'd rather use a package manager, or the installer fails, go to step 2. + +2. PACKAGE MANAGER (only if step 1 did not install it). Run `node --version`: + this needs Node.js 20.19.0 or higher. If Node is missing or older, say so and + stop — don't install Node, switch versions, or reconfigure my version + manager for me. Otherwise use whichever package manager is already on my + PATH, preferring npm: npm install -g @fission-ai/openspec@latest pnpm add -g @fission-ai/openspec@latest bun add -g @fission-ai/openspec@latest @@ -37,9 +75,11 @@ order, and stop where a step tells you to stop. me — show me the change and let me make it. 3. PATH. Run `openspec --version`. If the command isn't found, it may just be - missing from this shell: tell me where the package manager installed it and - how to add that directory to PATH for my shell and OS, then stop until I - confirm. If it prints an older version than the one the install just + missing from this shell: tell me where it was installed (the standalone + installer prints the folder, ~/.local/bin by default; a package manager has + its own global bin folder) and how to add that folder to PATH for my shell + and OS, then stop until I confirm. On Windows the standalone installer adds + it for me; a new terminal picks it up. If it prints an older version than the one the install just reported, an earlier copy is shadowing it on PATH — tell me both versions instead of continuing. If I use a version manager, say so rather than editing PATH around it: with nvm or fnm the CLI is tied to the Node version that was @@ -78,6 +118,8 @@ Nothing in the prompt is vendor-specific: it's plain instructions plus the same ## Package Managers +Installing with a package manager needs **Node.js 20.19.0 or higher** (check with `node --version`). + ### npm ```bash @@ -161,27 +203,36 @@ Or add to your development environment in `flake.nix`: openspec --version ``` +## Switch from npm to the standalone install + +Your settings and projects carry over; only the program changes. + +1. Run `openspec install`. It installs the standalone copy and prints the command that removes your old one. +2. Run that command, for example `npm uninstall -g @fission-ai/openspec` (Homebrew: `brew uninstall openspec`). Until then the old copy keeps running first on your PATH. +3. In a new terminal, run `openspec version`. It should print `OpenSpec (standalone)`. If it names a package manager, another copy is still on your PATH: `which -a openspec` (Windows: `where.exe openspec`) lists them. + ## Updating -Upgrade the package, then refresh each project's generated files: +Upgrade the CLI by running the [install command](#install) again (with a package manager: `npm install -g @fission-ai/openspec@latest` or its pnpm/yarn/bun equivalent). Then refresh each project's generated files: ```bash -npm install -g @fission-ai/openspec@latest # or pnpm/yarn/bun equivalent -openspec update # run inside each project +openspec update # run inside each project ``` `openspec update` regenerates the skill and command files for the tools you've configured, so your slash commands stay current with the installed version. It also checks whether a newer CLI has been published and offers to upgrade, since upgrading is what makes new workflows available in the first place — see [CLI Reference](cli.md#openspec-update). ## Uninstalling -There's no `openspec uninstall` command, because OpenSpec is just a global package plus some files in your project. Removing it is a few manual steps, and nothing here touches your source code. +There's no `openspec uninstall` command, because OpenSpec is just one program plus some files in your project. Removing it is a few manual steps, and nothing here touches your source code. -**1. Remove the global package:** +**1. Remove the CLI.** Delete the file the installer put in place: `~/.local/bin/openspec`, or on Windows `%LOCALAPPDATA%\Programs\openspec\openspec.exe` (and that folder from your user `PATH`). If you installed with a package manager: ```bash npm uninstall -g @fission-ai/openspec # or: pnpm rm -g / yarn global remove / bun rm -g ``` +The standalone binary also keeps, when that directory is writable, a copy of its built-in schemas under your OpenSpec data directory (`~/.local/share/openspec/binary-schemas` on macOS and Linux, `%LOCALAPPDATA%\openspec\binary-schemas` on Windows), which you can delete too. + **2. Remove OpenSpec from a project (optional).** Delete the `openspec/` directory if you no longer want its specs and changes: ```bash diff --git a/install.md b/install.md index a37c0fe402..51e73bacc4 100644 --- a/install.md +++ b/install.md @@ -14,19 +14,32 @@ DONE WHEN: TODO: -- [ ] Check the Node.js runtime -- [ ] Install the CLI globally, with the user's confirmation +- [ ] Install the standalone CLI, with the user's confirmation (npm only as a fallback) - [ ] Verify `openspec` is on PATH - [ ] Confirm which folder to set up, then run `openspec init --tools ` there - [ ] Report what init actually created and how to invoke it -## 1. Runtime +## 1. Install -Run `node --version`. OpenSpec needs Node.js 20.19.0 or higher. If Node is missing or older, say so and stop, but leave the user with their next move: point them at https://nodejs.org, or if they use a version manager like nvm, name the command that would switch to a newer version. Don't install Node, switch versions, or reconfigure the user's version manager yourself. +Run `openspec --version` first. If it already prints a version, the CLI is installed: say so, run `openspec version --check` (it prints the update command for how OpenSpec was installed when it can tell; otherwise update it the way it was installed, e.g. `brew upgrade openspec`), offer to run that command, and skip ahead to step 3. If it was installed with npm or another package manager, mention once that `openspec install` switches to the standalone install, which runs without Node.js. Only offer it: the user may have chosen npm on purpose, so don't repeat it or switch unless they ask. Never switch an install to a different method on your own; that leaves two copies competing on PATH. -## 2. Install +Install the standalone OpenSpec binary. It is a single file and needs no Node.js (on Alpine Linux, `apk add libstdc++ libgcc` first). Show the command for the user's OS and run it only once they confirm, then skip to step 3: -Run `openspec --version` first. If it already prints a version, the CLI is installed: say so, offer to update it with the install command below, and if the user declines skip ahead to step 4. +```bash +# macOS / Linux +curl -fsSL https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary/install.sh | sh +``` + +```powershell +# Windows (PowerShell) +irm https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary/install.ps1 | iex +``` + +If the user would rather use a package manager, or the installer fails, continue with step 2. + +## 2. Package manager (only if step 1 did not install it) + +Run `node --version`. Installing with a package manager needs Node.js 20.19.0 or higher. If Node is missing or older, don't install Node, switch versions, or reconfigure the user's version manager yourself; stop and leave them with their next move: https://nodejs.org, or, if they use a version manager like nvm, the command that would switch to a newer version. Use whichever package manager is already on PATH, preferring npm: @@ -45,7 +58,7 @@ Stop and ask again if the install needs sudo or admin rights, fails with a permi ## 3. PATH -Run `openspec --version`. If the command isn't found, it may only be missing from this shell: say where the package manager installed it and how to add that directory to PATH for the user's shell and OS, then stop until they confirm. If it prints an older version than the one the install just reported, an earlier copy is shadowing it on PATH; report both versions instead of continuing. If the user uses a version manager, say so rather than editing PATH around it: with nvm or fnm the CLI is tied to the Node version that was active at install time, and with asdf or volta a shim may need regenerating. +Run `openspec --version`. If the command isn't found, it may only be missing from this shell: say where it was installed (the standalone installer prints the folder, `~/.local/bin` by default; a package manager has its own global bin folder) and how to add that folder to PATH for the user's shell and OS, then stop until they confirm. On Windows the standalone installer adds its folder to the user PATH itself; a new terminal picks it up. If it prints an older version than the one the install just reported, an earlier copy is shadowing it on PATH; report both versions instead of continuing. If the user uses a version manager, say so rather than editing PATH around it: with nvm or fnm the CLI is tied to the Node version that was active at install time, and with asdf or volta a shim may need regenerating. ## 4. Initialize diff --git a/scripts/README.md b/scripts/README.md index 0fb25f7747..2d1d82842f 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -70,3 +70,26 @@ test file mid-suite. ## pack-version-check.mjs Validates package version consistency before publishing. + +## binary/ + +Builds the standalone `openspec` executables (no Node.js needed), the default +way users install OpenSpec. `.github/workflows/binaries.yml` cross-compiles +every asset on Linux, tests each on its own OS and CPU, and attaches them, +`SHA256SUMS`, `install.sh` and `install.ps1` to each published release. + +| File | What it is | +|---|---| +| `build.mjs` | Builds assets into `dist-bin/` (run `pnpm build` first) | +| `entry.mjs` | The binary's entry point: embedded schemas, upgrade advice | +| `install.sh` / `install.ps1` | The installers users run (macOS/Linux, Windows) | +| `smoke.sh` | Quick check of one binary | +| `e2e.sh` | Runs every CLI test against one binary instead of Node | + +```bash +pnpm build +bun scripts/binary/build.mjs # this machine +bun scripts/binary/build.mjs --all # every release asset +bash scripts/binary/smoke.sh dist-bin/openspec- +bash scripts/binary/e2e.sh dist-bin/openspec- +``` diff --git a/scripts/binary/build.mjs b/scripts/binary/build.mjs new file mode 100644 index 0000000000..937a79ab3a --- /dev/null +++ b/scripts/binary/build.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env bun +// Builds standalone `openspec` executables with `bun build --compile`, so the +// CLI can be installed without Node.js. Run `pnpm build` first. +// +// bun scripts/binary/build.mjs # this machine +// bun scripts/binary/build.mjs --all # every release asset +// bun scripts/binary/build.mjs linux-x64-musl # named assets +// +// Output: dist-bin/openspec-[.exe] +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +// Release asset → Bun target. x64 uses Bun's "baseline" builds, which drop +// the AVX2 requirement of the default ones so pre-2013 CPUs and some VMs work. +// The -musl assets are for Alpine and other non-glibc Linux. +export const ASSETS = { + 'linux-x64': 'bun-linux-x64-baseline', + 'linux-arm64': 'bun-linux-arm64', + 'linux-x64-musl': 'bun-linux-x64-musl-baseline', + 'linux-arm64-musl': 'bun-linux-arm64-musl', + 'darwin-x64': 'bun-darwin-x64-baseline', + 'darwin-arm64': 'bun-darwin-arm64', + 'windows-x64': 'bun-windows-x64-baseline', + 'windows-arm64': 'bun-windows-arm64', +}; + +const root = path.resolve(import.meta.dir, '../..'); +const args = process.argv.slice(2); +const assets = args.includes('--all') + ? Object.keys(ASSETS) + : args.length > 0 + ? args + : [`${process.platform === 'win32' ? 'windows' : process.platform}-${process.arch}`]; +for (const asset of assets) { + if (!ASSETS[asset]) { + console.error(`Unknown asset "${asset}". Known: ${Object.keys(ASSETS).join(', ')}`); + process.exit(1); + } +} + +// Node resolves `createRequire(import.meta.url)(...)` at runtime against the +// package directory, which a binary does not have. Turning those calls into +// plain `require` lets Bun bundle package.json and cross-spawn instead. +const staticRequire = { + name: 'static-require', + setup(build) { + build.onLoad({ filter: /[\\/]dist[\\/].*\.js$/ }, async (args) => { + const contents = (await Bun.file(args.path).text()) + .replace(/const require = createRequire\(import\.meta\.url\);/g, '') + .replaceAll('createRequire(import.meta.url)(', 'require('); + // A new call shape would otherwise ship a binary that fails at runtime. + if (contents.includes('createRequire(import.meta.url)')) { + throw new Error(`Unhandled createRequire(import.meta.url) in ${args.path}`); + } + return { contents, loader: 'js' }; + }); + }, +}; + +// Sorted, so the same schemas give the same hash on every filesystem. +const files = {}; +const schemasDir = path.join(root, 'schemas'); +const schemaFiles = fs + .readdirSync(schemasDir, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => path.relative(schemasDir, path.join(entry.parentPath, entry.name)).split(path.sep).join('/')) + .sort(); +for (const relativePath of schemaFiles) { + const bytes = fs.readFileSync(path.join(schemasDir, relativePath)); + const content = bytes.toString('utf8'); + // Embedded as text; anything else would be silently corrupted. + if (!Buffer.from(content, 'utf8').equals(bytes)) { + console.error(`schemas/${relativePath} is not UTF-8 text and cannot be embedded`); + process.exit(1); + } + files[relativePath] = content; +} +const hash = createHash('sha256').update(JSON.stringify(files)).digest('hex').slice(0, 16); + +for (const asset of assets) { + const outfile = path.join(root, 'dist-bin', `openspec-${asset}${asset.startsWith('windows') ? '.exe' : ''}`); + const result = await Bun.build({ + entrypoints: [path.join(import.meta.dir, 'entry.mjs')], + compile: { target: ASSETS[asset], outfile }, + // Unminified, a crash names the real function and shows readable code + // (about 1 MB more), so bug reports from binary users are actionable. + minify: false, + define: { OPENSPEC_EMBEDDED_SCHEMAS: JSON.stringify({ hash, files }) }, + plugins: [staticRequire], + }); + if (!result.success) { + for (const log of result.logs) console.error(log); + process.exit(1); + } + // Bun resolves some paths at build time; one baked into the binary points + // at this machine and would mislead the CLI everywhere else. + if (fs.readFileSync(outfile).includes(root)) { + console.error(`${path.relative(root, outfile)} embeds the build path ${root}`); + process.exit(1); + } + console.log(`Built ${path.relative(root, outfile)}`); +} diff --git a/scripts/binary/e2e.sh b/scripts/binary/e2e.sh new file mode 100755 index 0000000000..0d3bf022df --- /dev/null +++ b/scripts/binary/e2e.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Runs every CLI test (each test file that uses runCLI) against a standalone +# binary instead of `node dist/cli/index.js`. Needs `pnpm install`. +# +# bash scripts/binary/e2e.sh dist-bin/openspec- +set -euo pipefail + +files=() +while IFS= read -r file; do files+=("$file"); done < <(grep -rl 'runCLI' test | grep '\.test\.ts$' | sort) +# Without files vitest would run the whole suite and prove nothing about $1. +[ "${#files[@]}" -gt 0 ] || { echo "No CLI test files found" >&2; exit 1; } +echo "Running ${#files[@]} CLI test files against $1" +OPENSPEC_TEST_BINARY="$1" pnpm exec vitest run "${files[@]}" diff --git a/scripts/binary/entry.mjs b/scripts/binary/entry.mjs new file mode 100644 index 0000000000..dee947b6db --- /dev/null +++ b/scripts/binary/entry.mjs @@ -0,0 +1,86 @@ +// Entry point of the standalone `openspec` binary built by build.mjs. The +// binary has no package directory on disk, so the built-in schemas are +// embedded at build time and written to a real directory before the CLI runs. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { getGlobalDataDir } from '../../dist/core/global-config.js'; +import { setPackageSchemasDir } from '../../dist/core/artifact-graph/resolver.js'; +import { markStandaloneBinary } from '../../dist/core/version-check.js'; + +// Replaced by build.mjs: { hash, files: { 'spec-driven/schema.yaml': '...' } } +const { hash, files } = OPENSPEC_EMBEDDED_SCHEMAS; + +// Checked on every start, so a copy someone edited or partly deleted is +// replaced instead of breaking the CLI until they find and remove it. +function isIntact(dir) { + try { + return Object.entries(files).every( + ([relativePath, content]) => fs.readFileSync(path.join(dir, relativePath), 'utf8') === content + ); + } catch { + return false; + } +} + +function writeFiles(dir) { + for (const [relativePath, content] of Object.entries(files)) { + const file = path.join(dir, relativePath); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); + } + return dir; +} + +// Keyed by content and created only by an atomic rename of a fully written +// copy, so concurrent first runs never see a half-written directory. +function extractToDataDir() { + const dir = path.join(getGlobalDataDir(), 'binary-schemas', hash); + if (isIntact(dir)) return dir; + + fs.mkdirSync(path.dirname(dir), { recursive: true }); + const tmp = writeFiles(fs.mkdtempSync(`${dir}.tmp-`)); + if (fs.existsSync(dir)) { + // A rename cannot replace a non-empty directory, so move the damaged + // copy aside first. Another process may already have done it. + try { + fs.renameSync(dir, `${tmp}-stale`); + } catch {} + fs.rmSync(`${tmp}-stale`, { recursive: true, force: true }); + } + try { + fs.renameSync(tmp, dir); + } catch (error) { + fs.rmSync(tmp, { recursive: true, force: true }); + // Another process finished first; its copy is identical. + if (!isIntact(dir)) throw error; + } + return dir; +} + +function extractSchemas() { + try { + return extractToDataDir(); + } catch { + // Read-only home, locked-down CI: use a private copy for this run only. + const dir = writeFiles(fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-schemas-'))); + process.on('exit', () => fs.rmSync(dir, { recursive: true, force: true })); + return dir; + } +} + +markStandaloneBinary(process.execPath); + +let schemasDir; +setPackageSchemasDir(() => { + try { + schemasDir ??= extractSchemas(); + } catch (error) { + throw new Error(`Could not write the built-in schemas to disk: ${error.message}`); + } + return schemasDir; +}); +// No runCli() call: process.argv[1] and import.meta.url are both fixed at +// build time to the same virtual path, so the CLI's "run when executed +// directly" check always fires, whatever the user renames the binary to. +await import('../../dist/cli/index.js'); diff --git a/scripts/binary/install-check.sh b/scripts/binary/install-check.sh new file mode 100755 index 0000000000..df29c581f4 --- /dev/null +++ b/scripts/binary/install-check.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Checks `openspec install` against a release served at OPENSPEC_DOWNLOAD_URL: +# run from the Node build, as an npm user would, and by the binary on itself, +# which reinstalls in place. Needs `pnpm build`. +# +# OPENSPEC_DOWNLOAD_URL=http://127.0.0.1:8766 bash scripts/binary/install-check.sh dist-bin/openspec- +set -euo pipefail + +asset=$1 +ext="" +case "$asset" in *.exe) ext=".exe" ;; esac +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +export XDG_CONFIG_HOME="$work/config" XDG_DATA_HOME="$work/data" OPENSPEC_TELEMETRY=0 OPENSPEC_NO_UPDATE_CHECK=1 +fail() { echo "FAIL: $*" >&2; exit 1; } + +# 1. From the Node build, into a fresh folder. +OPENSPEC_INSTALL_DIR="$work/from-node" node bin/openspec.js install +cmp "$work/from-node/openspec$ext" "$asset" || fail "install from Node wrote the wrong file" +"$work/from-node/openspec$ext" version | grep -q '(standalone)' || fail "installed copy is not standalone" + +# 2. The binary replacing itself where it is, under its own (custom) name. +# Twice: on Windows the running exe is set aside, and the next run removes it. +mkdir "$work/self" +self="$work/self/my-openspec$ext" +cp "$asset" "$self" +for run in 1 2; do + out=$(env -u OPENSPEC_INSTALL_DIR -u OPENSPEC_INSTALL_NAME "$self" install 2>&1) || fail "self-install run $run failed: $out" + case "$out" in *"Installed openspec"*"my-openspec$ext"*) ;; *) fail "self-install run $run did not replace itself: $out" ;; esac + cmp "$self" "$asset" || fail "self-install run $run wrote the wrong file" +done +"$self" version | grep -q '(standalone)' || fail "reinstalled copy is not standalone" +leftovers=$(find "$work/self" -mindepth 1 -maxdepth 1 ! -name "my-openspec$ext" | wc -l | tr -d ' ') +[ "$leftovers" -le 1 ] || fail "files pile up next to the binary: $(ls -A "$work/self")" + +echo "OK: openspec install ($asset)" diff --git a/scripts/binary/install.ps1 b/scripts/binary/install.ps1 new file mode 100644 index 0000000000..685fb7e9b5 --- /dev/null +++ b/scripts/binary/install.ps1 @@ -0,0 +1,150 @@ +# Installs the standalone `openspec.exe` (no Node.js needed) on Windows. +# +# irm https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary/install.ps1 | iex +# +# $env:OPENSPEC_VERSION release to install (default: the newest complete one), e.g. v1.15.0 +# $env:OPENSPEC_INSTALL_DIR where to put it (default: %LOCALAPPDATA%\Programs\openspec) +# $env:OPENSPEC_INSTALL_NAME file name to install as (default: openspec.exe) +# $env:OPENSPEC_RELEASES_URL releases page to install from (default: GitHub's; mirrors, testing) +# $env:OPENSPEC_DOWNLOAD_URL one folder holding the release files (mirrors, testing) +# +# Runs in Windows PowerShell 5.1 and PowerShell 7. It throws instead of +# calling exit, which would close the window of anyone who piped it into iex. +& { + $ErrorActionPreference = 'Stop' + # Windows PowerShell 5.1 may default to TLS 1.0, which GitHub refuses, and + # draws a progress bar that slows downloads many times over. + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + $ProgressPreference = 'SilentlyContinue' + + # The OS architecture, not this PowerShell's: an x64 shell on an ARM PC + # should still get the native ARM build. + $arch = switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture) { + 'X64' { 'x64' } + 'Arm64' { 'arm64' } + default { throw "openspec install: unsupported CPU $_. Install with npm instead: npm install -g @fission-ai/openspec" } + } + $asset = "openspec-windows-$arch.exe" + + $version = if ($env:OPENSPEC_VERSION) { $env:OPENSPEC_VERSION } else { 'latest' } + if ($version -ne 'latest' -and -not $version.StartsWith('v')) { $version = "v$version" } + # The binary and SHA256SUMS come from the same place, so a remote plain HTTP + # source could swap both: only https, or http to this machine. [Uri] parses + # the host, so http://localhost:80@evil.example/ is evil.example, not loopback. + function Assert-Source($label, $value) { + $uri = $null + if (-not [Uri]::TryCreate($value, 'Absolute', [ref]$uri) -or $uri.UserInfo -or + -not ($uri.Scheme -eq 'https' -or ($uri.Scheme -eq 'http' -and $uri.IsLoopback))) { + throw "openspec install: $label must use https (or http to localhost): $value" + } + } + $releases = if ($env:OPENSPEC_RELEASES_URL) { $env:OPENSPEC_RELEASES_URL.TrimEnd('/') } else { 'https://github.com/Fission-AI/OpenSpec/releases' } + Assert-Source 'OPENSPEC_RELEASES_URL' $releases + if ($env:OPENSPEC_DOWNLOAD_URL) { Assert-Source 'OPENSPEC_DOWNLOAD_URL' $env:OPENSPEC_DOWNLOAD_URL } + $npmHint = 'Or install with npm (Node.js 20.19+): npm install -g @fission-ai/openspec' + $dir = if ($env:OPENSPEC_INSTALL_DIR) { $env:OPENSPEC_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'Programs\openspec' } + # Absolute, so the user PATH entry works from any directory. + $dir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($dir) + $name = if ($env:OPENSPEC_INSTALL_NAME) { $env:OPENSPEC_INSTALL_NAME } else { 'openspec.exe' } + + # Staged inside the install dir so the final move is a same-volume rename. + New-Item -ItemType Directory -Force -Path $dir | Out-Null + # Copies set aside by an earlier upgrade, once nothing runs them any more. + Get-ChildItem -Path $dir -Filter "$name.old-*" -ErrorAction SilentlyContinue | + Remove-Item -Force -ErrorAction SilentlyContinue + $tmp = Join-Path $dir ('.openspec-install-' + [Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $tmp | Out-Null + try { + $exe = Join-Path $tmp $asset + $sums = Join-Path $tmp 'SHA256SUMS' + if ($env:OPENSPEC_DOWNLOAD_URL) { + $base = $env:OPENSPEC_DOWNLOAD_URL.TrimEnd('/') + } elseif ($version -ne 'latest') { + $base = "$releases/download/$version" + } else { + # The newest stable release whose files are all attached. GitHub calls a + # release "latest" the moment it is published, minutes before the + # Binaries workflow attaches the files (and for good, if that fails), so + # "latest" is not trusted. SHA256SUMS is uploaded last: a release that + # has it is complete. + $feed = '' + try { $feed = (Invoke-WebRequest -UseBasicParsing -Uri "$releases.atom").Content } catch { Write-Verbose "No release feed: $_" } + $tags = @([regex]::Matches($feed, '/releases/tag/(v\d+\.\d+\.\d+)"') | ForEach-Object { $_.Groups[1].Value } | Select-Object -Unique) + foreach ($tag in $tags) { + try { + Invoke-WebRequest -UseBasicParsing -Uri "$releases/download/$tag/SHA256SUMS" -OutFile $sums + $version = $tag + break + } catch { + Write-Verbose "$tag is not complete yet: $_" + } + } + if ($version -eq 'latest') { throw "openspec install: found no release with the standalone files at $releases. $npmHint" } + if ($tags.Count -gt 0 -and $version -ne $tags[0]) { Write-Host "$($tags[0]) is still being prepared; installing $version." } + $base = "$releases/download/$version" + } + + Write-Host "Downloading $asset ($version)..." + try { + Invoke-WebRequest -UseBasicParsing -Uri "$base/$asset" -OutFile $exe + if (-not (Test-Path $sums)) { Invoke-WebRequest -UseBasicParsing -Uri "$base/SHA256SUMS" -OutFile $sums } + } catch { + throw "openspec install: could not download from ${base}: $($_.Exception.Message). $npmHint" + } + + $line = Get-Content $sums | Where-Object { ($_ -split '\s+')[1] -eq $asset } | Select-Object -First 1 + if (-not $line) { throw "openspec install: $asset is not listed in SHA256SUMS" } + $expected = ($line -split '\s+')[0] + $actual = (Get-FileHash -Algorithm SHA256 -Path $exe).Hash + if ($actual -ne $expected) { throw "openspec install: checksum mismatch for $asset; refusing to install" } + + $installed = $null + # A binary that cannot start at all throws instead of setting an exit code. + try { $installed = & $exe --version } catch { Write-Verbose "openspec --version failed: $_" } + if ($LASTEXITCODE -ne 0 -or -not $installed) { + throw "openspec install: the downloaded $asset does not run on this machine (needs Windows 10 or later)" + } + + $target = Join-Path $dir $name + # Windows cannot overwrite a running exe, and `openspec install` runs this + # from the very exe it replaces. Renaming a running exe is allowed, so the + # old copy moves aside: deleted now, or by the next install if still running. + $old = $null + if (Test-Path $target) { + $old = "$target.old-" + [Guid]::NewGuid().ToString('N') + Rename-Item -Path $target -NewName (Split-Path $old -Leaf) + } + try { + Move-Item -Path $exe -Destination $target + } catch { + if ($old) { Rename-Item -Path $old -NewName $name -ErrorAction SilentlyContinue } + throw "openspec install: could not install to ${target}: $($_.Exception.Message)" + } + if ($old) { Remove-Item -Force -Path $old -ErrorAction SilentlyContinue } + # Clear any "downloaded from the internet" mark so Windows does not block it. + Unblock-File -Path $target -ErrorAction SilentlyContinue + Write-Host "Installed openspec $installed to $target" + } finally { + Remove-Item -Recurse -Force -Path $tmp -ErrorAction SilentlyContinue + } + + # Unlike a shell profile, the user PATH is the standard place for this on + # Windows. Only appended to, and only when missing. Read and written raw: + # [Environment]::SetEnvironmentVariable would save it expanded, turning + # entries like %USERPROFILE%\bin into fixed paths for good. + $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true) + try { + $raw = [string]$key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + $entries = @($raw -split ';' | Where-Object { $_ }) + if ($entries -notcontains $dir) { + $key.SetValue('Path', (($entries + $dir) -join ';'), [Microsoft.Win32.RegistryValueKind]::ExpandString) + # Setting any user variable broadcasts the change, so new terminals see it. + [Environment]::SetEnvironmentVariable('OPENSPEC_INSTALL_PATH_CHANGED', '1', 'User') + [Environment]::SetEnvironmentVariable('OPENSPEC_INSTALL_PATH_CHANGED', $null, 'User') + $env:Path = "$env:Path;$dir" + Write-Host "Added $dir to your user PATH. Open a new terminal to use openspec everywhere." + } + } finally { + $key.Close() + } +} diff --git a/scripts/binary/install.sh b/scripts/binary/install.sh new file mode 100644 index 0000000000..956b5088cf --- /dev/null +++ b/scripts/binary/install.sh @@ -0,0 +1,173 @@ +#!/bin/sh +# Installs the standalone `openspec` binary (no Node.js needed) on Linux or macOS. +# +# curl -fsSL https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary/install.sh | sh +# +# OPENSPEC_VERSION release to install (default: the newest complete one), e.g. v1.15.0 +# OPENSPEC_INSTALL_DIR where to put it (default: ~/.local/bin) +# OPENSPEC_INSTALL_NAME file name to install as (default: openspec) +# OPENSPEC_RELEASES_URL releases page to install from (default: GitHub's; mirrors, testing) +# OPENSPEC_DOWNLOAD_URL one folder holding the release files (mirrors, testing) +set -eu + +fail() { echo "openspec install: $*" >&2; exit 1; } + +# The binary and SHA256SUMS come from the same place, so a remote plain HTTP +# source could swap both: only https, or http to this machine. The host is +# parsed, not prefix-matched: http://localhost:80@evil.example/ names +# evil.example, so anything with a user part is refused. +check_source() { + case "$2" in + https://*) scheme=https authority=${2#https://} ;; + http://*) scheme=http authority=${2#http://} ;; + *) fail "$1 must use https (or http to localhost): $2" ;; + esac + authority=${authority%%/*} + authority=${authority%%\?*} + authority=${authority%%#*} + case "$authority" in *@*) fail "$1 must use https (or http to localhost): $2" ;; esac + [ "$scheme" = https ] && return 0 + host=$authority + case "$authority" in + "[::1]:"* | localhost:* | 127.0.0.1:*) + host=${authority%:*} + case "${authority##*:}" in "" | *[!0-9]*) fail "$1 has an invalid port: $2" ;; esac + ;; + esac + case "$host" in + localhost | 127.0.0.1 | "[::1]") return 0 ;; + *) fail "$1 must use https (or http to localhost): $2" ;; + esac +} + +# Everything runs from main, so a download cut short while piped into sh +# runs nothing instead of half a script. +main() { + case "$(uname -s)" in + Linux) os=linux ;; + Darwin) os=darwin ;; + *) fail "unsupported OS $(uname -s). On Windows, download openspec-windows-x64.exe from the release page." ;; + esac + case "$(uname -m)" in + x86_64 | amd64) arch=x64 ;; + arm64 | aarch64) arch=arm64 ;; + *) fail "unsupported CPU $(uname -m). Install with npm instead: npm install -g @fission-ai/openspec" ;; + esac + # An x64 shell under Rosetta still runs on an Apple Silicon Mac. + if [ "$os" = darwin ] && [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)" = 1 ]; then + arch=arm64 + fi + asset="openspec-$os-$arch" + # Alpine and other musl distros cannot run the glibc build. Ask glibc first: + # a glibc system can have musl installed alongside, loader and all. + if [ "$os" = linux ] && ! getconf GNU_LIBC_VERSION >/dev/null 2>&1 && + { ldd --version 2>&1 | grep -qi musl || ls /lib/ld-musl-* >/dev/null 2>&1; }; then + asset="$asset-musl" + fi + + version="${OPENSPEC_VERSION:-latest}" + case "$version" in latest | v*) ;; *) version="v$version" ;; esac + releases="${OPENSPEC_RELEASES_URL:-https://github.com/Fission-AI/OpenSpec/releases}" + releases=${releases%/} + check_source OPENSPEC_RELEASES_URL "$releases" + if [ -n "${OPENSPEC_DOWNLOAD_URL:-}" ]; then + check_source OPENSPEC_DOWNLOAD_URL "$OPENSPEC_DOWNLOAD_URL" + fi + if [ -n "${OPENSPEC_INSTALL_DIR:-}" ]; then + dir="$OPENSPEC_INSTALL_DIR" + elif [ -n "${HOME:-}" ]; then + dir="$HOME/.local/bin" + else + fail "HOME is not set; set OPENSPEC_INSTALL_DIR to choose where to install" + fi + + # Staged inside the install dir, not /tmp: /tmp is often mounted noexec, and + # a same-filesystem mv replaces an existing copy atomically. + mkdir -p "$dir" || fail "cannot create $dir" + # Absolute, so the PATH advice below works from any directory. + dir=$(cd "$dir" && pwd) || fail "cannot use $dir" + name="${OPENSPEC_INSTALL_NAME:-openspec}" + tmp=$(mktemp -d "$dir/.openspec-install.XXXXXX") || fail "cannot write to $dir" + trap 'rm -rf "$tmp"' EXIT + download() { + if command -v curl >/dev/null 2>&1; then + curl -fsSL --retry 3 -o "$2" "$1" + elif command -v wget >/dev/null 2>&1; then + wget -q -O "$2" "$1" + else + fail "curl or wget is required" + fi + } + fetch() { + if command -v curl >/dev/null 2>&1; then + curl -fsSL --retry 3 "$1" + elif command -v wget >/dev/null 2>&1; then + wget -q -O - "$1" + else + fail "curl or wget is required" + fi + } + + npm_hint="or install with npm (Node.js 20.19+): npm install -g @fission-ai/openspec" + if [ -n "${OPENSPEC_DOWNLOAD_URL:-}" ]; then + base=${OPENSPEC_DOWNLOAD_URL%/} + elif [ "$version" != latest ]; then + base="$releases/download/$version" + else + # The newest stable release whose files are all attached. GitHub calls a + # release "latest" the moment it is published, minutes before the Binaries + # workflow attaches the files (and for good, if that fails), so "latest" + # is not trusted. SHA256SUMS is uploaded last: a release that has it is + # complete. + newest="" + tags=$(fetch "$releases.atom" 2>/dev/null | + grep -o '/releases/tag/v[0-9]*\.[0-9]*\.[0-9]*"' | sed 's#.*/tag/##; s/"$//' | awk '!seen[$0]++') || tags="" + for tag in $tags; do + [ -n "$newest" ] || newest=$tag + if download "$releases/download/$tag/SHA256SUMS" "$tmp/SHA256SUMS" 2>/dev/null; then + version=$tag + break + fi + done + if [ "$version" = latest ]; then + fail "found no release with the standalone files at $releases; $npm_hint" + fi + if [ "$version" != "$newest" ]; then + echo "$newest is still being prepared; installing $version." + fi + base="$releases/download/$version" + fi + + echo "Downloading $asset ($version)..." + download "$base/$asset" "$tmp/$asset" || fail "could not download $base/$asset; $npm_hint" + if [ ! -s "$tmp/SHA256SUMS" ]; then + download "$base/SHA256SUMS" "$tmp/SHA256SUMS" || fail "could not download $base/SHA256SUMS; $npm_hint" + fi + + expected=$(awk -v f="$asset" '$2 == f { print $1 }' "$tmp/SHA256SUMS") + [ -n "$expected" ] || fail "$asset is not listed in SHA256SUMS" + if command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum "$tmp/$asset" | awk '{ print $1 }') + else + actual=$(shasum -a 256 "$tmp/$asset" | awk '{ print $1 }') + fi + [ "$actual" = "$expected" ] || fail "checksum mismatch for $asset; refusing to install" + + chmod 755 "$tmp/$asset" + if ! installed=$("$tmp/$asset" --version); then + case "$asset" in + *-musl) fail "$asset needs the C++ runtime (Alpine 3.17+): apk add libstdc++ libgcc" ;; + openspec-darwin-*) fail "$asset needs macOS 13 or later" ;; + *) fail "the downloaded $asset does not run on this machine" ;; + esac + fi + mv -f "$tmp/$asset" "$dir/$name" + echo "Installed openspec $installed to $dir/$name" + + case ":$PATH:" in + *":$dir:"*) ;; + *) echo "Add $dir to your PATH to run it, e.g.: export PATH=\"$dir:\$PATH\"" ;; + esac +} + +main "$@" diff --git a/scripts/binary/smoke.sh b/scripts/binary/smoke.sh new file mode 100755 index 0000000000..2d117bacdb --- /dev/null +++ b/scripts/binary/smoke.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Smoke-tests a standalone binary from build.mjs: version, built-in schemas, +# and a real init → change → status → instructions run in a scratch project. +# +# bash scripts/binary/smoke.sh dist-bin/openspec-- +set -euo pipefail + +expected=$(sed -nE 's/^ "version": "([^"]+)".*/\1/p' "$(dirname "$0")/../../package.json") +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +# Run it from, and keep its data under, paths with a space and non-ASCII +# characters, as on a machine whose user name has them. Renamed too, as a +# user would after downloading. +mkdir -p "$work/bin dir ü" +bin="$work/bin dir ü/openspec" +case "$1" in *.exe) bin="$bin.exe" ;; esac +cp "$1" "$bin" + +# Keep the run off the user's real config and data, and offline. +export XDG_CONFIG_HOME="$work/config ü" XDG_DATA_HOME="$work/data ü" +export OPENSPEC_TELEMETRY=0 OPENSPEC_NO_UPDATE_CHECK=1 NO_COLOR=1 +mkdir "$work/project" && cd "$work/project" + +fail() { echo "FAIL: $*" >&2; exit 1; } + +version=$("$bin" --version) +[ "$version" = "$expected" ] || fail "--version printed '$version', expected '$expected'" + +"$bin" schemas --json | grep -q '"source": "package"' || fail "built-in schema not found" +"$bin" init --tools claude > /dev/null +[ -f openspec/config.yaml ] || fail "init did not write openspec/config.yaml" +"$bin" new change demo > /dev/null +"$bin" status --change demo --json | grep -q '"schemaName": "spec-driven"' || fail "status did not resolve the schema" +"$bin" instructions proposal --change demo | grep -q '