diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c0613fd..c69c727 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -13,15 +13,71 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - uses: dtolnay/rust-toolchain@stable
+ with:
+ submodules: recursive
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 10.11.0
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: .nvmrc
+ cache: pnpm
+ cache-dependency-path: uhura/web/pnpm-lock.yaml
+ - name: build the canonical Uhura provider
+ run: |
+ pnpm -C uhura/web install --frozen-lockfile
+ pnpm -C uhura/web build:provider
+ - uses: dtolnay/rust-toolchain@1.92.0
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- name: fmt
run: cargo fmt --all --check
- name: clippy
- run: cargo clippy --workspace --all-targets -- -D warnings
+ run: cargo clippy --locked --workspace --all-targets -- -D warnings
- name: test
- run: cargo test --workspace
+ run: cargo test --locked --workspace
- name: check the example
- run: cargo run -p spock-cli -- check examples/instagram/v0.spock
+ run: cargo run --locked -p spock-cli -- check examples/instagram/v0.spock
+
+ uhura:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: uhura
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: recursive
+ - uses: dtolnay/rust-toolchain@1.92.0
+ with:
+ components: rustfmt, clippy
+ - uses: Swatinem/rust-cache@v2
+ with:
+ key: uhura
+ workspaces: uhura -> target
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 10.11.0
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: .nvmrc
+ cache: pnpm
+ cache-dependency-path: uhura/web/pnpm-lock.yaml
+ # Canonical host integration consumes the ignored provider bundle built
+ # from authoritative TypeScript, so build browser products before Rust.
+ - name: check the Uhura browser and provider
+ run: |
+ pnpm -C web install --frozen-lockfile
+ pnpm -C web check
+ - name: fmt
+ run: cargo fmt --all --check
+ - name: clippy
+ run: cargo clippy --locked --workspace --all-targets -- -D warnings
+ - name: test
+ run: cargo test --locked --workspace --all-targets
+ - name: check the canonical Uhura project
+ run: |
+ cargo run --locked -p uhura-cli -- fmt --check examples/instagram-uhura
+ cargo run --locked -p uhura-cli -- check examples/instagram-uhura --deny-warnings
+ cargo run --locked -p uhura-cli -- trace examples/instagram-uhura --script=demo >/dev/null
diff --git a/.github/workflows/npm.yml b/.github/workflows/npm.yml
index 27912dc..802b385 100644
--- a/.github/workflows/npm.yml
+++ b/.github/workflows/npm.yml
@@ -1,22 +1,23 @@
name: npm
-# Distribution of the `spock` CLI over npm (RFD 0020). One package bundles a
-# prebuilt binary per platform; a Node shim (npm/bin/spock.js) execs the one
+# Distribution of the `spock` framework CLI over npm (RFDs 0020 and 0022). One
+# package bundles a prebuilt binary per platform and one platform-independent
+# Uhura web/Wasm sidecar; a Node shim (npm/bin/spock.js) owns the binary
# matching the host. Publishing is tokenless via npm Trusted Publishing (OIDC):
# this workflow file name (npm.yml) is what the trusted-publisher config on
# npmjs.com is pinned to, and `id-token: write` lets `npm publish` authenticate
# without a token.
#
# Trigger it manually (Actions -> npm -> Run workflow) to dry-run or publish a
-# prerelease under a dist-tag; push a `vX.Y.Z` tag to cut a real `latest`.
+# prerelease under a dist-tag; stable tags publish `latest` and prerelease tags
+# publish `next`.
on:
workflow_dispatch:
inputs:
version:
- description: "Version to publish (e.g. 0.1.0 or 0.1.0-rc.1)"
- required: true
- default: "0.1.0-rc.1"
+ description: "Optional version assertion (defaults to Cargo.toml)"
+ required: false
dist_tag:
description: "npm dist-tag (keeps prereleases off `latest`)"
required: true
@@ -32,6 +33,9 @@ on:
permissions:
contents: read
+env:
+ NPM_CLI_VERSION: 11.6.2
+
concurrency:
group: npm-publish
cancel-in-progress: false
@@ -41,19 +45,104 @@ defaults:
shell: bash
jobs:
+ assets:
+ name: build shared framework assets
+ runs-on: ubuntu-22.04
+ timeout-minutes: 45
+ outputs:
+ manifest_sha256: ${{ steps.sidecar.outputs.manifest_sha256 }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 10.11.0
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: .nvmrc
+ cache: pnpm
+ cache-dependency-path: uhura/web/pnpm-lock.yaml
+
+ - name: self-test sidecar tooling
+ run: node npm/scripts/sidecar.mjs self-test
+
+ - uses: dtolnay/rust-toolchain@1.92.0
+ with:
+ targets: wasm32-unknown-unknown
+ - uses: Swatinem/rust-cache@v2
+ with:
+ key: npm-uhura-wasm
+ workspaces: uhura -> target
+ cache-directories: uhura/target/tools
+
+ - name: check and build Uhura Editor, Play, and provider
+ run: |
+ pnpm -C uhura/web install --frozen-lockfile
+ pnpm -C uhura/web check
+
+ - name: install lockfile-exact wasm-bindgen CLI
+ run: |
+ version="$(sed -n '/name = "wasm-bindgen"/{n;s/version = "\([^"]*\)"/\1/p;q;}' uhura/Cargo.lock)"
+ test -n "$version"
+ installed=""
+ if [ -x uhura/target/tools/bin/wasm-bindgen ]; then
+ installed="$(uhura/target/tools/bin/wasm-bindgen --version | awk '{print $2}')"
+ fi
+ if [ "$installed" != "$version" ]; then
+ cargo install wasm-bindgen-cli \
+ --version "$version" \
+ --locked \
+ --force \
+ --root uhura/target/tools
+ fi
+ test "$(uhura/target/tools/bin/wasm-bindgen --version | awk '{print $2}')" = "$version"
+
+ - name: build Uhura WebAssembly bundle
+ run: WASM_BINDGEN="$GITHUB_WORKSPACE/uhura/target/tools/bin/wasm-bindgen" bash uhura/scripts/build-wasm.sh
+
+ - name: assemble and guard shared sidecar
+ id: sidecar
+ run: |
+ node npm/scripts/sidecar.mjs assemble \
+ --web-dir uhura/web/dist \
+ --wasm-dir uhura/crates/uhura-wasm/pkg/web \
+ --out-dir npm/share/spock/uhura \
+ --spock-commit "$(git rev-parse HEAD)" \
+ --uhura-commit "$(git -C uhura rev-parse HEAD)"
+ node npm/scripts/sidecar.mjs verify --root npm/share/spock/uhura
+ manifest_sha256="$(sha256sum npm/share/spock/uhura/manifest.json | awk '{print $1}')"
+ if [[ ! "$manifest_sha256" =~ ^[0-9a-f]{64}$ ]]; then
+ echo "::error::could not derive the sidecar manifest SHA-256"
+ exit 1
+ fi
+ printf 'manifest_sha256=%s\n' "$manifest_sha256" >> "$GITHUB_OUTPUT"
+ echo "sidecar manifest SHA-256: $manifest_sha256"
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: framework-assets
+ path: npm/share/spock/uhura/
+ if-no-files-found: error
+ retention-days: 1
+
build:
name: build ${{ matrix.key }}
+ needs: assets
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- { key: darwin-arm64, os: macos-14, target: aarch64-apple-darwin, smoke: true }
- - { key: darwin-x64, os: macos-14, target: x86_64-apple-darwin, smoke: false }
+ - { key: darwin-x64, os: macos-14, target: x86_64-apple-darwin, smoke: false, rosetta: true }
- { key: linux-x64, os: ubuntu-22.04, target: x86_64-unknown-linux-gnu, smoke: true }
- { key: win32-x64, os: windows-2025, target: x86_64-pc-windows-msvc, smoke: true }
steps:
- uses: actions/checkout@v4
+ with:
+ submodules: recursive
- uses: pnpm/action-setup@v4
with:
@@ -77,7 +166,7 @@ jobs:
test "$bytes" -gt 200 || { echo "::error::$f is only $bytes bytes"; exit 1; }
echo "studio dist/index.html: $bytes bytes"
- - uses: dtolnay/rust-toolchain@stable
+ - uses: dtolnay/rust-toolchain@1.92.0
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
@@ -85,7 +174,14 @@ jobs:
key: ${{ matrix.target }}
- name: cargo build --release
- run: cargo build --release -p spock-cli --target ${{ matrix.target }}
+ env:
+ SPOCK_PACKAGED_UHURA_MANIFEST_SHA256: ${{ needs.assets.outputs.manifest_sha256 }}
+ run: |
+ if [[ ! "$SPOCK_PACKAGED_UHURA_MANIFEST_SHA256" =~ ^[0-9a-f]{64}$ ]]; then
+ echo "::error::missing or malformed executable-bound sidecar manifest identity"
+ exit 1
+ fi
+ cargo build --locked --release -p spock-cli --target ${{ matrix.target }}
- name: smoke test (native arch only)
if: matrix.smoke
@@ -95,6 +191,24 @@ jobs:
"$bin" --version
"$bin" check examples/instagram/v0.spock
+ - name: smoke test (macOS x64 through Rosetta)
+ if: matrix.rosetta
+ run: |
+ bin="target/${{ matrix.target }}/release/spock"
+ if ! file "$bin" | grep -q 'Mach-O 64-bit executable x86_64'; then
+ echo "::error::$bin is not an x86_64 Mach-O executable"
+ file "$bin"
+ exit 1
+ fi
+ if ! arch -x86_64 "$bin" --version; then
+ echo "::error::the advertised macOS x64 binary did not run through Rosetta"
+ exit 1
+ fi
+ if ! arch -x86_64 "$bin" check examples/instagram/v0.spock; then
+ echo "::error::the advertised macOS x64 binary could not load and check a Spock program through Rosetta"
+ exit 1
+ fi
+
- name: stage binary
run: |
exe=spock; [ "${{ runner.os }}" = "Windows" ] && exe=spock.exe
@@ -110,43 +224,93 @@ jobs:
publish:
name: publish
- needs: build
+ needs: [assets, build]
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # required for OIDC trusted publishing
outputs:
version: ${{ steps.resolve.outputs.version }}
+ cargo_version: ${{ steps.resolve.outputs.cargo_version }}
dist_tag: ${{ steps.resolve.outputs.dist_tag }}
dry_run: ${{ steps.resolve.outputs.dry_run }}
steps:
- uses: actions/checkout@v4
+ with:
+ submodules: recursive
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
registry-url: "https://registry.npmjs.org"
- - name: use latest npm (Trusted Publishing needs >= 11.5.1)
- run: npm install -g npm@latest
+ - name: install pinned Trusted-Publishing-capable npm
+ run: npm install -g "npm@$NPM_CLI_VERSION"
- name: resolve version, tag, dry_run
id: resolve
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ INPUT_VERSION: ${{ inputs.version }}
+ INPUT_DIST_TAG: ${{ inputs.dist_tag }}
+ INPUT_DRY_RUN: ${{ inputs.dry_run }}
run: |
- if [ "${{ github.event_name }}" = "push" ]; then
- v="${GITHUB_REF_NAME#v}"; tag="latest"; dry="false"
+ cargo_version="$(awk '
+ /^\[workspace.package\]$/ { section = 1; next }
+ /^\[/ { section = 0 }
+ section && /^version = / {
+ gsub(/^version = "|"$/, ""); print; exit
+ }
+ ' Cargo.toml)"
+ test -n "$cargo_version"
+ if [ "$EVENT_NAME" = "push" ]; then
+ v="${GITHUB_REF_NAME#v}"; dry="false"
else
- v="${{ inputs.version }}"; tag="${{ inputs.dist_tag }}"; dry="${{ inputs.dry_run }}"
+ v="${INPUT_VERSION:-$cargo_version}"; tag="$INPUT_DIST_TAG"; dry="$INPUT_DRY_RUN"
fi
- printf 'version=%s\n' "$v" >> "$GITHUB_OUTPUT"
- printf 'dist_tag=%s\n' "$tag" >> "$GITHUB_OUTPUT"
- printf 'dry_run=%s\n' "$dry" >> "$GITHUB_OUTPUT"
- echo "resolved: version=$v dist_tag=$tag dry_run=$dry"
+ # Every successful matrix build has already made Cargo parse this
+ # workspace version. Keep Cargo authoritative instead of maintaining
+ # a second, inevitably incomplete SemVer grammar in bash.
+ if [ "$v" != "$cargo_version" ]; then
+ echo "::error::release version $v does not match Cargo workspace version $cargo_version"
+ exit 1
+ fi
+ package_version="${cargo_version%%+*}"
+ if [[ "$package_version" == *-* ]]; then
+ prerelease="true"
+ else
+ prerelease="false"
+ fi
+ if [ "$EVENT_NAME" = "push" ]; then
+ if [ "$prerelease" = "true" ]; then tag="next"; else tag="latest"; fi
+ fi
+ if [[ ! "$tag" =~ ^[A-Za-z][A-Za-z0-9._-]*$ ]]; then
+ echo "::error::invalid npm dist-tag: $tag"
+ exit 1
+ fi
+ if [ "$prerelease" = "true" ] && [ "$tag" = "latest" ]; then
+ echo "::error::prerelease version $v may not publish to the latest dist-tag"
+ exit 1
+ fi
+ if [ "$dry" != "true" ] && [ "$dry" != "false" ]; then
+ echo "::error::dry_run must be true or false"
+ exit 1
+ fi
+ printf 'version=%s\n' "$package_version" >> "$GITHUB_OUTPUT"
+ printf 'cargo_version=%s\n' "$cargo_version" >> "$GITHUB_OUTPUT"
+ printf 'dist_tag=%s\n' "$tag" >> "$GITHUB_OUTPUT"
+ printf 'dry_run=%s\n' "$dry" >> "$GITHUB_OUTPUT"
+ echo "resolved: cargo_version=$cargo_version package_version=$package_version dist_tag=$tag dry_run=$dry"
- uses: actions/download-artifact@v4
with:
path: artifacts
pattern: bin-*
+ - uses: actions/download-artifact@v4
+ with:
+ name: framework-assets
+ path: npm/share/spock/uhura
+
- name: assemble platform binaries into the package
run: |
set -e
@@ -155,9 +319,10 @@ jobs:
exe=spock; [ "$key" = "win32-x64" ] && exe=spock.exe
mkdir -p "npm/binaries/$key"
cp "$d/$exe" "npm/binaries/$key/$exe"
- [ "$key" = "win32-x64" ] || chmod +x "npm/binaries/$key/$exe"
+ [ "$key" = "win32-x64" ] || chmod 0755 "npm/binaries/$key/$exe"
done
- echo "=== assembled ==="; ls -laR npm/binaries
+ echo "=== assembled ==="
+ ls -laR npm/binaries
- name: guard — all four platforms present
run: |
@@ -167,12 +332,99 @@ jobs:
test -s "npm/binaries/$key/$exe" || { echo "::error::missing binary for $key"; exit 1; }
done
+ - name: guard — shared framework sidecar is complete
+ run: node npm/scripts/sidecar.mjs verify --root npm/share/spock/uhura
+
- name: stamp version
- run: cd npm && npm version "${{ steps.resolve.outputs.version }}" --no-git-tag-version --allow-same-version
+ env:
+ PACKAGE_VERSION: ${{ steps.resolve.outputs.version }}
+ run: cd npm && npm version "$PACKAGE_VERSION" --no-git-tag-version --allow-same-version
+
+ - name: guard — packed package is complete and within 25 MiB
+ env:
+ PACKAGE_VERSION: ${{ steps.resolve.outputs.version }}
+ run: |
+ mkdir -p "$RUNNER_TEMP/npm-pack"
+ (cd npm && npm pack --json --pack-destination "$RUNNER_TEMP/npm-pack") \
+ > "$RUNNER_TEMP/npm-pack.json"
+ shopt -s nullglob
+ packages=("$RUNNER_TEMP/npm-pack/"*.tgz)
+ if [ "${#packages[@]}" -ne 1 ]; then
+ echo "::error::expected exactly one guarded npm tarball; found ${#packages[@]}"
+ exit 1
+ fi
+ node - "$RUNNER_TEMP/npm-pack.json" <<'NODE'
+ const fs = require("node:fs");
+ const [entry] = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
+ if (!entry || !Number.isSafeInteger(entry.size)) {
+ throw new Error("npm pack did not report a package size");
+ }
+ if (entry.version !== process.env.PACKAGE_VERSION) {
+ throw new Error(
+ `packed npm version ${entry.version} does not match ${process.env.PACKAGE_VERSION}`,
+ );
+ }
+ const manifest = JSON.parse(
+ fs.readFileSync("npm/share/spock/uhura/manifest.json", "utf8"),
+ );
+ const expected = new Set([
+ "LICENSE",
+ "README.md",
+ "THIRD_PARTY_NOTICES.md",
+ "bin/spock.js",
+ "binaries/darwin-arm64/spock",
+ "binaries/darwin-x64/spock",
+ "binaries/linux-x64/spock",
+ "binaries/win32-x64/spock.exe",
+ "package.json",
+ "share/spock/uhura/manifest.json",
+ ...manifest.files.map((file) => `share/spock/uhura/${file.path}`),
+ ]);
+ const packed = new Map(entry.files.map((file) => [file.path, file]));
+ for (const required of expected) {
+ if (!packed.has(required)) throw new Error(`packed package is missing ${required}`);
+ }
+ for (const actual of packed.keys()) {
+ if (!expected.has(actual)) throw new Error(`packed package has unexpected ${actual}`);
+ }
+ for (const path of [
+ "bin/spock.js",
+ "binaries/darwin-arm64/spock",
+ "binaries/darwin-x64/spock",
+ "binaries/linux-x64/spock",
+ ]) {
+ const mode = packed.get(path)?.mode;
+ if (!Number.isSafeInteger(mode) || (mode & 0o777) !== 0o755) {
+ throw new Error(`packed executable must have mode 0755: ${path} (mode ${mode})`);
+ }
+ }
+ const limit = 25 * 1024 * 1024;
+ if (entry.size > limit) {
+ throw new Error(`packed package is ${entry.size} bytes; limit is ${limit} bytes`);
+ }
+ console.log(`packed package: ${entry.size} bytes (${entry.unpackedSize} unpacked)`);
+ NODE
+
+ - name: upload guarded npm tarball
+ uses: actions/upload-artifact@v4
+ with:
+ name: npm-package
+ path: ${{ runner.temp }}/npm-pack/*.tgz
+ if-no-files-found: error
+ retention-days: 1
- name: npm publish (dry run)
if: steps.resolve.outputs.dry_run == 'true'
- run: cd npm && npm publish --dry-run --tag "${{ steps.resolve.outputs.dist_tag }}"
+ env:
+ DIST_TAG: ${{ steps.resolve.outputs.dist_tag }}
+ run: |
+ shopt -s nullglob
+ packages=("$RUNNER_TEMP/npm-pack/"*.tgz)
+ if [ "${#packages[@]}" -ne 1 ]; then
+ echo "::error::expected exactly one guarded npm tarball; found ${#packages[@]}"
+ exit 1
+ fi
+ npm publish "${packages[0]}" --dry-run --tag "$DIST_TAG"
- name: npm publish
if: steps.resolve.outputs.dry_run != 'true'
@@ -180,12 +432,20 @@ jobs:
# races the tarball PUT and returns a false "cannot publish over
# previously published version" (npm/cli). Tokenless OIDC auth is
# independent of provenance and still applies.
- run: cd npm && npm publish --tag "${{ steps.resolve.outputs.dist_tag }}" --no-provenance
+ env:
+ DIST_TAG: ${{ steps.resolve.outputs.dist_tag }}
+ run: |
+ shopt -s nullglob
+ packages=("$RUNNER_TEMP/npm-pack/"*.tgz)
+ if [ "${#packages[@]}" -ne 1 ]; then
+ echo "::error::expected exactly one guarded npm tarball; found ${#packages[@]}"
+ exit 1
+ fi
+ npm publish "${packages[0]}" --tag "$DIST_TAG" --no-provenance
verify:
name: verify ${{ matrix.key }}
needs: publish
- if: needs.publish.outputs.dry_run != 'true'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@@ -196,18 +456,60 @@ jobs:
- { key: win32-x64, os: windows-2025 }
steps:
- uses: actions/checkout@v4
+ with:
+ submodules: recursive
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- - name: install spock from npm
+ - name: download guarded npm tarball
+ if: needs.publish.outputs.dry_run == 'true'
+ uses: actions/download-artifact@v4
+ with:
+ name: npm-package
+ path: packed-npm
+
+ - name: install spock from guarded tarball
+ if: needs.publish.outputs.dry_run == 'true'
+ run: |
+ shopt -s nullglob
+ packages=(packed-npm/*.tgz)
+ if [ "${#packages[@]}" -ne 1 ]; then
+ echo "::error::expected exactly one guarded npm tarball; found ${#packages[@]}"
+ exit 1
+ fi
+ package_path="$(pwd)/${packages[0]}"
+ npm install -g "$package_path"
+ echo "installed $package_path"
+
+ - name: install spock from npm registry
+ if: needs.publish.outputs.dry_run != 'true'
+ env:
+ PACKAGE_VERSION: ${{ needs.publish.outputs.version }}
run: |
- v="${{ needs.publish.outputs.version }}"
+ installed=""
for i in $(seq 1 12); do
- if npm install -g "spock@$v"; then echo "installed spock@$v"; break; fi
- echo "attempt $i: spock@$v not visible yet; waiting"; sleep 6
+ if npm install -g "spock@$PACKAGE_VERSION"; then installed=1; echo "installed spock@$PACKAGE_VERSION"; break; fi
+ echo "attempt $i: spock@$PACKAGE_VERSION not visible yet; waiting"; sleep 6
done
- spock --version
+ test -n "$installed" || { echo "::error::spock@$PACKAGE_VERSION was not installable"; exit 1; }
+
+ - name: verify installed npm and Cargo versions
+ env:
+ PACKAGE_VERSION: ${{ needs.publish.outputs.version }}
+ CARGO_VERSION: ${{ needs.publish.outputs.cargo_version }}
+ run: |
+ package_root="$(npm root -g)"
+ installed_version="$(node -e '
+ const path = require("node:path");
+ const manifest = require(path.join(process.argv[1], "spock", "package.json"));
+ process.stdout.write(manifest.version);
+ ' "$package_root")"
+ test "$installed_version" = "$PACKAGE_VERSION"
+ test "$(spock --version)" = "spock $CARGO_VERSION"
+
+ - name: verify installed framework sidecar
+ run: node npm/scripts/sidecar.mjs verify --root "$(npm root -g)/spock/share/spock/uhura"
- name: spock check
run: spock check examples/instagram/v0.spock
@@ -225,3 +527,97 @@ jobs:
if [ -z "$ok" ]; then echo "::error::server did not start"; kill "$pid" 2>/dev/null || true; exit 1; fi
curl -fsS http://127.0.0.1:4123/~studio | grep -qi "/dev/null || true
+
+ - name: spock framework routes + shared assets served
+ if: runner.os != 'Windows'
+ run: |
+ smoke_root="$RUNNER_TEMP/spock-framework-smoke"
+ mkdir -p "$smoke_root"
+ (cd "$smoke_root" && spock new npm-smoke)
+ spock start "$smoke_root/npm-smoke" --port 4124 >"$smoke_root/server.log" 2>&1 &
+ pid=$!
+ cleanup() {
+ kill "$pid" 2>/dev/null || true
+ wait "$pid" 2>/dev/null || true
+ }
+ trap cleanup EXIT
+ ok=""
+ for i in $(seq 1 40); do
+ if curl -fsS http://127.0.0.1:4124/~health >/dev/null 2>&1; then ok=1; break; fi
+ if ! kill -0 "$pid" 2>/dev/null; then break; fi
+ sleep 0.25
+ done
+ if [ -z "$ok" ]; then
+ echo "::error::framework server did not become ready"
+ cat "$smoke_root/server.log"
+ exit 1
+ fi
+ curl -fsS http://127.0.0.1:4124/ | grep -qi "/dev/null
+ curl -fsS http://127.0.0.1:4124/api/play/wasm/uhura_wasm_bg.wasm >/dev/null
+ curl -fsS http://127.0.0.1:4124/~project/status | grep -q '"protocol":"spock-project-status/1"'
+ curl -fsS http://127.0.0.1:4124/~project/environment | grep -q '"protocol":"spock-host-environment/1"'
+
+ - name: spock framework routes + shared assets served (Windows)
+ if: runner.os == 'Windows'
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = "Stop"
+ $smokeRoot = Join-Path $env:RUNNER_TEMP "spock-framework-smoke"
+ $project = Join-Path $smokeRoot "npm-smoke"
+ $stdout = Join-Path $smokeRoot "server.stdout.log"
+ $stderr = Join-Path $smokeRoot "server.stderr.log"
+ New-Item -ItemType Directory -Force -Path $smokeRoot | Out-Null
+
+ $packageRoot = (& npm root -g).Trim()
+ $shim = Join-Path $packageRoot "spock/bin/spock.js"
+ Push-Location $smokeRoot
+ try {
+ & node $shim new npm-smoke
+ if ($LASTEXITCODE -ne 0) { throw "spock new failed with exit code $LASTEXITCODE" }
+ } finally {
+ Pop-Location
+ }
+
+ $node = (Get-Command node -ErrorAction Stop).Source
+ $arguments = @("`"$shim`"", "start", "`"$project`"", "--port", "4124")
+ $process = Start-Process -FilePath $node -ArgumentList $arguments -PassThru `
+ -RedirectStandardOutput $stdout -RedirectStandardError $stderr
+ try {
+ $ready = $false
+ for ($attempt = 0; $attempt -lt 40; $attempt += 1) {
+ if ($process.HasExited) { break }
+ try {
+ $health = Invoke-RestMethod -Uri "http://127.0.0.1:4124/~health" -TimeoutSec 1
+ if ($health.ready -eq $true) { $ready = $true; break }
+ } catch {
+ Start-Sleep -Milliseconds 250
+ }
+ }
+ if (-not $ready) {
+ if (Test-Path $stdout) { Get-Content $stdout }
+ if (Test-Path $stderr) { Get-Content $stderr }
+ throw "framework server did not become ready"
+ }
+
+ $editor = (Invoke-WebRequest -Uri "http://127.0.0.1:4124/").Content
+ $play = (Invoke-WebRequest -Uri "http://127.0.0.1:4124/play").Content
+ if ($editor -notmatch " It's only logical.
-Spock is an early programming language for prototyping application backends as
-a small, inspectable source of truth.
+Spock is an early meta-framework for prototyping an application as one small,
+inspectable source of truth: a Spock authority backend plus an optional Uhura
+client, checked and served by one `spock` command on one origin. The Spock
+language remains independently usable for backend-only experiments.
Most application backends spread the same intent across too many layers:
database schema, API serializers, mutation handlers, validation, authorization,
@@ -19,11 +21,24 @@ the spec.
## Install
-Spock ships as a single npm package with a prebuilt binary for macOS, Linux,
-and Windows — no build step, no toolchain:
+Spock ships as a single npm package with prebuilt binaries for macOS arm64/x64,
+GNU-libc Linux x64, and Windows x64 — no build step, no toolchain. Alpine and
+other musl-based Linux distributions are not supported yet. Framework commands
+begin with `0.5.0`; registry releases through `0.4.0` expose only the standalone
+language commands.
+
+With the `0.5.0` framework npm release:
+
+```sh
+npx spock new demo
+cd demo
+npx spock dev
+```
+
+Standalone programs keep the same entry points:
```sh
-# run without installing
+npx spock check app.spock
npx spock run app.spock
# or install globally
@@ -31,11 +46,64 @@ npm i -g spock
```
```sh
-spock check app.spock # parse + check a program
-spock run app.spock # materialize + serve (GraphQL, REST, /~studio)
+spock new demo # create backend + Uhura client + spock.toml
+spock check # check the whole nearest project
+spock dev # watch client; observe backend as restart-required
+spock start # serve one fixed combined generation
+
+# standalone language escape hatches stay available
+spock check app.spock # parse + fully load-check one program
+spock run app.spock # materialize + serve GraphQL, REST, and Studio
spock gen types app.spock # emit TypeScript types
```
+## Framework projects
+
+`spock new NAME` creates this canonical topology; `--backend-only` omits the
+client. `spock init [PATH]` adopts unambiguous existing sources without moving
+or overwriting them.
+
+```text
+demo/
+├── spock.toml
+├── backend/
+│ └── app.spock
+└── client/
+ ├── uhura.toml
+ └── ...
+```
+
+The manifest is required, as is its configured `.spock` entry. A project whose
+authority is not designed yet should keep that file empty; the client, health,
+status, contract metadata, Editor, and Play can still run.
+
+`spock dev` deliberately has asymmetric reload semantics today. Valid Uhura
+client saves publish live. Invalid saves retain the last good Play generation
+when one exists; an initially invalid client is reported as `cold_invalid`
+while Editor diagnostics remain available. Backend inputs—including the
+`.spock` source and referenced seed assets—and topology-affecting `spock.toml`
+saves are detected and reported as `restart_required`, but they never reopen,
+reseed, migrate, or replace the active database. Restarting reconstructs
+backend state from seed. This keeps the current behavior honest while the
+development-state model in
+[RFD 0023](docs/rfd/0023-development-state-reload.md) remains open.
+
+Framework composition changes the operational envelope, not the doctrine.
+Spock still owns durable product truth, policy, and guarded mutations; Uhura
+owns presentation, experience transitions, and non-authoritative UI-session
+state. `spock.toml` only composes their roots and lifecycle—it does not merge
+their languages or move authority into the client. No fact may become
+authoritative in both systems just because one command serves them together.
+
+For a project with a configured client, both framework commands serve Uhura
+Editor at `/` and Play at `/play`. Both modes serve Spock Studio at `/~studio`
+and the authority protocols on the same origin; the combined host grants no
+cross-origin CORS access by default. A backend-only project's `/` redirects to
+Studio and its client routes return structured 404 responses.
+`/~project/status` makes the active and merely observed generations explicit;
+`/~health` remains ready-but-degraded when a client candidate is rejected or a
+backend restart is required.
+
## The picture
Today, one product rule — say, *"a member may publish a draft post"* — is
@@ -623,8 +691,11 @@ types` turns the contract into TypeScript — row and write shapes plus the
derived error codes as literal unions — and `spock gen graphql-schema`
prints the SDL for offline schema tooling.
-The npm package metadata lives under `npm/` only to reserve the package name.
-It is not the primary implementation target.
+The package source under `npm/` is the primary no-checkout distribution path.
+Starting with `0.5.0`, it carries all four native binaries plus one
+platform-independent Uhura Editor/Play and WebAssembly sidecar; the small Node
+shim only selects and owns the matching native process. Releases through
+`0.4.0` predate that framework package and remain standalone-only.
## Uhura, the client language
@@ -637,43 +708,57 @@ UI-session state and experience behavior, with Spock as its canonical
provider. No fact may be authoritative in both languages.
Uhura is a subsystem of the Spock project: its canonical source lives in its
-own repository and is included here as a git submodule at `uhura/`. Spock's
-core workspace, default build, and CI remain independent of the submodule, so
-`cargo` builds work without it; clone with `--recurse-submodules` for the
-explicit composition runner below. Once Uhura is minimally stable, its tooling
-ships through the unified `spock` toolchain, and the runtime integration lands
-as contract projection plus a provider adapter.
+own repository and is included here as a git submodule at `uhura/`. The root
+Cargo workspace deliberately excludes Uhura's own workspace, while
+`spock-host` consumes `uhura-host` through a path dependency. Consequently a
+source build of the framework requires an initialized recursive submodule;
+framework npm releases contain the resulting runtime and assets.
-From an umbrella checkout, the general composition runner accepts any Spock
-program and Uhura project. For the Instagram example:
+For a source checkout, build the Uhura web and WebAssembly assets, then provide
+both roots together when running the framework host. Build Studio first too:
+its `dist/` directory exists in a clean checkout but is intentionally empty
+until the SPA build runs.
```sh
-./scripts/spock-uhura.sh \
- examples/instagram-poc/app.spock \
- uhura/examples/instagram-uhura
+git submodule update --init --recursive
+corepack pnpm@10.11.0 -C crates/spock-runtime/studio install --frozen-lockfile
+corepack pnpm@10.11.0 -C crates/spock-runtime/studio build
+corepack pnpm@10.11.0 -C uhura/web install --frozen-lockfile
+corepack pnpm@10.11.0 -C uhura/web check
+bash uhura/scripts/build-wasm.sh
+
+cargo run --locked -p spock-cli -- new demo
+
+SPOCK_UHURA_WEB_DIST="$PWD/uhura/web/dist" \
+SPOCK_UHURA_WASM_DIST="$PWD/uhura/crates/uhura-wasm/pkg/web" \
+cargo run --locked -p spock-cli -- dev demo
```
-It builds the two Rust launchers, starts the requested Spock authority on port
-4000, waits for it to become ready, and opens the requested Uhura project
-through its read-only Editor on . The Editor's Play
-button enters the live prototype at `/play` without starting another process.
-`--spock-port` and `--uhura-port` override those defaults; the project's
-provider configuration must address the same Spock port. `Ctrl-C` stops both
-runtimes. Contributor frontend tooling in Spock and Uhura uses the same Node 24
-LTS pin from their respective `.nvmrc` files and pnpm 10.11.0; the runner uses
-them to build Uhura's web application before launch, but neither running server
-depends on a Node process.
+`build-wasm.sh` reports the lockfile-exact `wasm-bindgen-cli` install command
+when that tool is missing. The two asset overrides are a pair: setting only one
+is rejected, so a source host cannot accidentally combine incompatible web and
+Wasm generations. A distributed `spock` finds the sidecar beside its installed
+executable, verifies the executable-bound manifest identity and its file
+inventory, and needs neither override nor a Node process at runtime. The paired
+overrides are an explicit unanchored source/test trust boundary.
+
+The historical `scripts/spock-uhura.sh` two-process runner remains a transition
+and comparison oracle for examples whose backend and client still live in
+separate roots. It is not the canonical framework topology; `spock start` and
+`spock dev` own one project, listener, origin, and lifecycle.
## Repository Layout
-- `examples/` contains product requirements and current-valid Spock examples.
-- `docs/rfd/` contains discussion drafts and proposal-only language ideas.
-- `npm/` contains package metadata for npm name reservation.
-- `scripts/` contains umbrella composition tooling that keeps Spock and Uhura
- independently buildable.
-- `uhura/` is the Uhura client language (git submodule of
- [gridaco/uhura](https://github.com/gridaco/uhura); not yet wired into the
- build).
+- `crates/spock-lang/` parses and checks the Spock language and contract IR.
+- `crates/spock-runtime/` materializes and serves one authority backend.
+- `crates/spock-project/` owns `spock.toml`, discovery, and project validation.
+- `crates/spock-host/` coordinates combined generations, routes, and assets.
+- `crates/spock-cli/` exposes framework, project, and language commands as the
+ single `spock` binary.
+- `npm/` is the real four-platform distribution package and shared sidecar.
+- `uhura/` is the wired Uhura client-language submodule with its own workspace.
+- `scripts/` retains transition and comparison tooling, not a second canonical
+ runtime topology.
## References and prior work
@@ -698,8 +783,9 @@ complete mediation, information hiding, and the rule of least power.
## Status
-Spock is currently a design-stage proposal. There is no compiler, runtime, or
-stable specification yet.
+Spock is an early prototype with a working compiler, embedded runtime,
+standalone language server, and combined framework host. Its language and host
+protocols remain pre-1.0 and may change; it is for prototyping, not production.
The older, more ambitious draft has been moved to
`docs/rfd/0000-vision.spock`. It is a sketch of possible direction, not the v0
diff --git a/crates/spock-cli/Cargo.toml b/crates/spock-cli/Cargo.toml
index 18c1ff9..33b5432 100644
--- a/crates/spock-cli/Cargo.toml
+++ b/crates/spock-cli/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "spock-cli"
-description = "The spock command: check, build, run"
+description = "The Spock framework, project, and language command"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
@@ -14,11 +14,25 @@ path = "src/main.rs"
[dependencies]
spock-lang = { path = "../spock-lang" }
spock-runtime = { path = "../spock-runtime" }
+spock-project = { path = "../spock-project" }
+spock-host = { path = "../spock-host" }
serde_json.workspace = true
clap.workspace = true
anyhow.workspace = true
tokio.workspace = true
+thiserror.workspace = true
+
+[target.'cfg(unix)'.dependencies]
+rustix = { version = "1.1", features = ["fs"] }
+
+[target.'cfg(windows)'.dependencies]
+cap-fs-ext = "4.0.2"
+cap-std = "4.0.2"
+fs_at = "0.2.1"
+same-file.workspace = true
+windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem", "Wdk_Storage_FileSystem"] }
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
+tempfile.workspace = true
diff --git a/crates/spock-cli/src/lib.rs b/crates/spock-cli/src/lib.rs
new file mode 100644
index 0000000..d5248f6
--- /dev/null
+++ b/crates/spock-cli/src/lib.rs
@@ -0,0 +1,491 @@
+#![forbid(unsafe_code)]
+
+//! Reusable implementation of Spock's project and standalone-language commands.
+//!
+//! This crate deliberately keeps argument parsing and terminal presentation in
+//! the `spock` binary. It owns the command-layer workflows for discovering,
+//! checking, creating, and adopting framework projects, while retaining the
+//! language-level escape hatches that load one `.spock` file, derive
+//! build/codegen artifacts, or prepare the historical standalone server.
+//! Callers can reuse those workflows without going through Clap or spawning a
+//! child process; runtime generation and HTTP hosting remain in `spock-host`.
+
+mod project_commands;
+mod write_plan;
+
+pub use project_commands::{
+ check_target, create_project, init_project, resolve_project_for_serve, CheckTargetError,
+ CheckTargetSummary, NewProjectNameError, ProjectWriteError, ProjectWriteOperation,
+ ProjectWriteSummary, ResolveProjectForServeError,
+};
+
+pub use write_plan::{
+ ApplyError, ApplyStage, ApplySummary, CreatedPathKind, RollbackReport, RollbackResidual,
+};
+
+use std::fmt;
+use std::future::Future;
+use std::io;
+use std::path::{Path, PathBuf};
+use std::pin::Pin;
+use std::sync::Arc;
+
+use spock_lang::diag::Diagnostic;
+use spock_lang::ir::Contract;
+
+/// A source file and the checked contract derived from its exact contents.
+#[derive(Debug)]
+pub struct FileProgram {
+ /// The caller's spelling, retained for byte-compatible diagnostics.
+ path: PathBuf,
+ /// The path used for I/O and relative `file(...)` seed assets.
+ read_path: PathBuf,
+ source: String,
+ contract: Contract,
+}
+
+impl FileProgram {
+ /// Read and compile one `.spock` source file.
+ pub fn load(path: impl AsRef) -> Result {
+ let path = path.as_ref();
+ Self::load_from(path, path)
+ }
+
+ /// Load a caller-spelled path as though the process were running in `cwd`.
+ ///
+ /// Project target resolution canonicalizes directories, but standalone
+ /// file mode must not canonicalize the final `.spock` component: doing so
+ /// changes both rendered diagnostics and the directory used by seed
+ /// `file(...)` references when the source itself is a symlink.
+ pub(crate) fn load_from_cwd(path: &Path, cwd: &Path) -> Result {
+ let read_path = if path.is_absolute() {
+ path.to_path_buf()
+ } else {
+ cwd.join(path)
+ };
+ Self::load_from(path, &read_path)
+ }
+
+ fn load_from(display_path: &Path, read_path: &Path) -> Result {
+ let path = display_path.to_path_buf();
+ let read_path = read_path.to_path_buf();
+ let source =
+ std::fs::read_to_string(&read_path).map_err(|error| ProgramLoadError::Read {
+ path: path.clone(),
+ error,
+ })?;
+ let contract =
+ spock_lang::compile(&source).map_err(|diagnostics| ProgramLoadError::Diagnostics {
+ path: path.clone(),
+ source: source.clone(),
+ diagnostics,
+ })?;
+ Ok(Self {
+ path,
+ read_path,
+ source,
+ contract,
+ })
+ }
+
+ pub fn path(&self) -> &Path {
+ &self.path
+ }
+
+ pub fn source(&self) -> &str {
+ &self.source
+ }
+
+ pub fn source_dir(&self) -> PathBuf {
+ source_dir(&self.read_path)
+ }
+
+ pub fn contract(&self) -> &Contract {
+ &self.contract
+ }
+
+ pub fn into_contract(self) -> Contract {
+ self.contract
+ }
+}
+
+/// Failure to read or compile a file program.
+#[derive(Debug)]
+pub enum ProgramLoadError {
+ Read {
+ path: PathBuf,
+ error: std::io::Error,
+ },
+ Diagnostics {
+ path: PathBuf,
+ source: String,
+ diagnostics: Vec,
+ },
+}
+
+impl ProgramLoadError {
+ pub fn diagnostics(&self) -> Option<&[Diagnostic]> {
+ match self {
+ Self::Diagnostics { diagnostics, .. } => Some(diagnostics),
+ Self::Read { .. } => None,
+ }
+ }
+}
+
+impl fmt::Display for ProgramLoadError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Read { path, error } => {
+ write!(f, "error: could not read {}: {error}", path.display())
+ }
+ Self::Diagnostics {
+ path,
+ source,
+ diagnostics,
+ } => {
+ let display_path = path.display().to_string();
+ for diagnostic in diagnostics {
+ writeln!(f, "{}", diagnostic.render(source, &display_path))?;
+ }
+ write!(
+ f,
+ "error: {} diagnostic(s), contract not produced",
+ diagnostics.len()
+ )
+ }
+ }
+ }
+}
+
+impl std::error::Error for ProgramLoadError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Read { error, .. } => Some(error),
+ Self::Diagnostics { .. } => None,
+ }
+ }
+}
+
+/// The directory a `.spock` file lives in: the root for seed `file("...")`
+/// assets. A path with no parent retains the historical empty, cwd-relative
+/// base directory.
+pub fn source_dir(file: impl AsRef) -> PathBuf {
+ file.as_ref()
+ .parent()
+ .map(Path::to_path_buf)
+ .unwrap_or_default()
+}
+
+/// Successful result of the full `check` load proof.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct CheckSummary {
+ pub tables: usize,
+ pub records: usize,
+ pub functions: usize,
+ pub unchecked_escapes: usize,
+ pub seed_rows: usize,
+}
+
+impl fmt::Display for CheckSummary {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let functions = if self.functions == 0 {
+ "0 fn(s)".to_string()
+ } else {
+ format!(
+ "{} fn(s) ({} unchecked escapes)",
+ self.functions, self.unchecked_escapes
+ )
+ };
+ write!(
+ f,
+ "ok: {} table(s), {} record(s), {functions}, {} seed row(s)",
+ self.tables, self.records, self.seed_rows
+ )
+ }
+}
+
+/// Materialize in memory, validate function bodies and checks, prove defaults,
+/// and replay seed data: everything `spock run` would reject before serving.
+pub fn full_load_check(
+ contract: &Contract,
+ base_dir: impl AsRef,
+) -> anyhow::Result {
+ spock_runtime::engine::open(contract, None, Some(base_dir.as_ref()))?;
+ Ok(CheckSummary {
+ tables: contract.tables.len(),
+ records: contract.records.len(),
+ functions: contract.fns.len(),
+ unchecked_escapes: contract.fns.iter().map(|function| function.sql.len()).sum(),
+ seed_rows: contract.seed.len(),
+ })
+}
+
+/// Pretty JSON emitted by `spock build`.
+pub fn build_artifact(contract: &Contract) -> String {
+ serde_json::to_string_pretty(contract).expect("contract serializes")
+}
+
+/// A derived `spock gen` artifact.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum GenerationTarget {
+ Types,
+ GraphqlSchema,
+}
+
+pub fn generate_artifact(contract: &Contract, target: GenerationTarget) -> anyhow::Result {
+ match target {
+ GenerationTarget::Types => {
+ spock_lang::typescript::typescript(contract).map_err(anyhow::Error::from)
+ }
+ GenerationTarget::GraphqlSchema => graphql_sdl(contract),
+ }
+}
+
+/// Derive the runtime's GraphQL SDL without letting seed-data failures gate a
+/// data-independent artifact.
+fn graphql_sdl(contract: &Contract) -> anyhow::Result {
+ let mut contract = contract.clone();
+ contract.seed.clear();
+ let conn = spock_runtime::engine::open(&contract, None, None)?;
+ let app = Arc::new(spock_runtime::App::new(contract, conn));
+ Ok(spock_runtime::graphql::schema(app)?.sdl())
+}
+
+/// Counts and capabilities reported when a standalone run has materialized.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct StandaloneRunSummary {
+ pub tables: usize,
+ pub functions: usize,
+ pub seed_rows: usize,
+ pub storage: bool,
+}
+
+impl fmt::Display for StandaloneRunSummary {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(
+ f,
+ "spock v0 — contract loaded: {} table(s), {} fn(s), {} seed row(s) replayed",
+ self.tables, self.functions, self.seed_rows
+ )
+ }
+}
+
+/// A fully materialized historical standalone Spock server.
+///
+/// Construction is independent of Clap and terminal output. The app accessor
+/// also lets an embedding host reuse the exact runtime state without binding a
+/// second listener.
+pub struct StandaloneRun {
+ app: Arc,
+ summary: StandaloneRunSummary,
+ // Declared last so the database-backed app is dropped before its
+ // process-lifetime advisory lock is released.
+ _named_state_lock: Option,
+}
+
+impl StandaloneRun {
+ pub fn construct(
+ contract: Contract,
+ database_path: Option<&Path>,
+ base_dir: impl AsRef,
+ ) -> anyhow::Result {
+ // `engine::open` deliberately deletes an existing database, WAL, and
+ // SHM before reconstructing from seed. Acquire the shared framework
+ // lock first so a second standalone/framework process can never race
+ // that destructive boundary.
+ let named_state_lock = database_path
+ .map(spock_host::NamedStateLock::acquire)
+ .transpose()?;
+ let resolved_database_path = named_state_lock
+ .as_ref()
+ .map(spock_host::NamedStateLock::resolved_database_path);
+ let conn = spock_runtime::engine::open(
+ &contract,
+ resolved_database_path,
+ Some(base_dir.as_ref()),
+ )?;
+ let summary = StandaloneRunSummary {
+ tables: contract.tables.len(),
+ functions: contract.fns.len(),
+ seed_rows: contract.seed.len(),
+ storage: spock_runtime::storage::storage_active(&contract),
+ };
+ Ok(Self {
+ app: Arc::new(spock_runtime::App::new(contract, conn)),
+ summary,
+ _named_state_lock: named_state_lock,
+ })
+ }
+
+ pub fn app(&self) -> Arc {
+ Arc::clone(&self.app)
+ }
+
+ pub fn summary(&self) -> StandaloneRunSummary {
+ self.summary
+ }
+
+ /// Bind the historical loopback listener and serve until an interactive
+ /// process-shutdown signal or a server failure. `on_listening` runs after
+ /// a successful bind so the binary can retain its exact presentation
+ /// without coupling it here.
+ pub fn serve_until_ctrl_c(self, port: u16, on_listening: impl FnOnce()) -> anyhow::Result<()> {
+ let runtime = tokio::runtime::Runtime::new()?;
+ let shutdown = {
+ let _runtime_guard = runtime.enter();
+ install_standalone_shutdown_signal()?
+ };
+ runtime.block_on(async move {
+ let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await?;
+ on_listening();
+ tokio::select! {
+ result = spock_runtime::http::serve(self.app, listener) => result?,
+ result = shutdown => result?,
+ }
+ Ok::<(), anyhow::Error>(())
+ })
+ }
+}
+
+type StandaloneShutdownSignal = Pin> + Send>>;
+
+#[cfg(unix)]
+fn install_standalone_shutdown_signal() -> io::Result {
+ use tokio::signal::unix::{signal, SignalKind};
+
+ let mut interrupt = signal(SignalKind::interrupt())?;
+ let mut terminate = signal(SignalKind::terminate())?;
+ Ok(Box::pin(async move {
+ tokio::select! {
+ received = interrupt.recv() => require_standalone_signal(received, "SIGINT"),
+ received = terminate.recv() => require_standalone_signal(received, "SIGTERM"),
+ }
+ }))
+}
+
+#[cfg(windows)]
+fn install_standalone_shutdown_signal() -> io::Result {
+ use tokio::signal::windows::{ctrl_break, ctrl_c, ctrl_close};
+
+ let mut interrupt = ctrl_c()?;
+ let mut break_signal = ctrl_break()?;
+ let mut close = ctrl_close()?;
+ Ok(Box::pin(async move {
+ tokio::select! {
+ received = interrupt.recv() => require_standalone_signal(received, "Ctrl-C"),
+ received = break_signal.recv() => require_standalone_signal(received, "Ctrl-Break"),
+ received = close.recv() => require_standalone_signal(received, "console close"),
+ }
+ }))
+}
+
+#[cfg(not(any(unix, windows)))]
+fn install_standalone_shutdown_signal() -> io::Result {
+ Ok(Box::pin(tokio::signal::ctrl_c()))
+}
+
+#[cfg(any(unix, windows))]
+fn require_standalone_signal(received: Option<()>, name: &str) -> io::Result<()> {
+ received.ok_or_else(|| {
+ io::Error::new(
+ io::ErrorKind::BrokenPipe,
+ format!("{name} signal stream closed before delivering a signal"),
+ )
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn load_error_owns_stably_rendered_diagnostics() {
+ let path = std::env::temp_dir().join(format!(
+ "spock-cli-library-diagnostic-{}.spock",
+ std::process::id()
+ ));
+ std::fs::write(&path, "table a { x: nope }").expect("write source");
+
+ let error = FileProgram::load(&path).expect_err("source must fail checking");
+ let rendered = error.to_string();
+ assert!(rendered.contains("error[E003]"), "{rendered}");
+ assert!(rendered.contains("error[E005]"), "{rendered}");
+ assert!(rendered.ends_with("error: 2 diagnostic(s), contract not produced"));
+
+ let _ = std::fs::remove_file(path);
+ }
+
+ #[test]
+ fn full_load_summary_and_build_artifact_are_reusable() {
+ let contract = spock_lang::compile(
+ "table user { key id: uuid = auto\n username: text unique }\n\
+ seed { user { username: \"maya\" } }",
+ )
+ .expect("contract");
+
+ let summary = full_load_check(&contract, "").expect("full load proof");
+ assert_eq!(
+ summary.to_string(),
+ "ok: 1 table(s), 0 record(s), 0 fn(s), 1 seed row(s)"
+ );
+ let artifact: serde_json::Value =
+ serde_json::from_str(&build_artifact(&contract)).expect("contract JSON");
+ assert_eq!(artifact["tables"][0]["name"], "user");
+ }
+
+ #[test]
+ fn generation_and_standalone_construction_are_library_operations() {
+ let contract =
+ spock_lang::compile("table user { key id: uuid = auto\n username: text unique }")
+ .expect("contract");
+
+ let types = generate_artifact(&contract, GenerationTarget::Types).expect("types");
+ assert!(types.contains("export interface user {"), "{types}");
+ let schema = generate_artifact(&contract, GenerationTarget::GraphqlSchema).expect("SDL");
+ assert!(schema.contains("type Query"), "{schema}");
+ assert!(schema.contains("user("), "{schema}");
+
+ let run = StandaloneRun::construct(contract, None, "").expect("standalone run");
+ assert_eq!(
+ run.summary(),
+ StandaloneRunSummary {
+ tables: 1,
+ functions: 0,
+ seed_rows: 0,
+ storage: false,
+ }
+ );
+ assert_eq!(run.app().contract.tables[0].name, "user");
+ }
+
+ #[test]
+ fn standalone_named_database_is_locked_before_reset_for_the_run_lifetime() {
+ let temporary = tempfile::tempdir().unwrap();
+ let database = temporary.path().join("shared.sqlite");
+ let sentinel = b"another process owns these bytes";
+ std::fs::write(&database, sentinel).unwrap();
+ let external_lock = spock_host::NamedStateLock::acquire(&database).unwrap();
+ let contract =
+ spock_lang::compile("table user { key id: uuid = auto\n username: text unique }")
+ .unwrap();
+
+ let blocked = StandaloneRun::construct(contract.clone(), Some(&database), "")
+ .err()
+ .expect("a live framework lock must block standalone reset");
+ assert!(blocked.to_string().contains("already owned"), "{blocked}");
+ assert_eq!(std::fs::read(&database).unwrap(), sentinel);
+
+ drop(external_lock);
+ let run = StandaloneRun::construct(contract, Some(&database), "").unwrap();
+ let contended = spock_host::NamedStateLock::acquire(&database)
+ .expect_err("standalone run must retain the lock");
+ assert!(
+ contended.to_string().contains("already owned"),
+ "{contended}"
+ );
+
+ drop(run);
+ let released = spock_host::NamedStateLock::acquire(&database).unwrap();
+ drop(released);
+ }
+}
diff --git a/crates/spock-cli/src/main.rs b/crates/spock-cli/src/main.rs
index dd1f911..1d75958 100644
--- a/crates/spock-cli/src/main.rs
+++ b/crates/spock-cli/src/main.rs
@@ -1,16 +1,27 @@
-//! The `spock` command: `check` (diagnostics), `build` (emit the contract),
-//! `gen` (derived artifacts — TypeScript types, GraphQL SDL; RFD 0010),
-//! `run` (materialize + serve the HTTP protocol). docs/spec/v0.md.
+//! The public `spock` command: framework projects plus the retained standalone
+//! language-file tools.
+use std::collections::BTreeSet;
+use std::future::Future;
+use std::io::{self, Write};
+use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::{Path, PathBuf};
+use std::pin::Pin;
use std::process::ExitCode;
-use std::sync::Arc;
+use std::sync::{Arc, Mutex};
use clap::{Parser, Subcommand};
-use spock_lang::ir::Contract;
+use spock_cli::{
+ CheckTargetError, CheckTargetSummary, FileProgram, GenerationTarget, StandaloneRun,
+};
+use spock_host::{HostMode, HostNotice, HostNoticeSink, ProjectCheckFailure, ServeOptions};
#[derive(Parser)]
-#[command(name = "spock", version, about = "The Spock v0 toolchain")]
+#[command(
+ name = "spock",
+ version,
+ about = "The Spock framework and language toolchain"
+)]
struct Cli {
#[command(subcommand)]
command: Command,
@@ -18,15 +29,51 @@ struct Cli {
#[derive(Subcommand)]
enum Command {
- /// Parse and check a program; print diagnostics.
- Check { file: PathBuf },
- /// Compile a program to its contract (JSON on stdout, or -o FILE).
+ /// Check a framework project, or an explicit standalone .spock file.
+ Check {
+ #[arg(value_name = "PATH")]
+ target: Option,
+ },
+ /// Create a new framework project.
+ New {
+ #[arg(value_name = "NAME")]
+ name: String,
+ /// Create the required Spock backend without an Uhura client.
+ #[arg(long)]
+ backend_only: bool,
+ },
+ /// Adopt an existing directory as a framework project.
+ Init {
+ #[arg(value_name = "PATH")]
+ target: Option,
+ },
+ /// Check once and serve one fixed framework generation.
+ Start {
+ #[arg(value_name = "PATH")]
+ target: Option,
+ #[arg(long, default_value_t = 4000)]
+ port: u16,
+ /// Disposable database file, reconstructed from seed on process start.
+ #[arg(long)]
+ db: Option,
+ },
+ /// Serve a framework project with live client publication.
+ Dev {
+ #[arg(value_name = "PATH")]
+ target: Option,
+ #[arg(long, default_value_t = 4000)]
+ port: u16,
+ /// Disposable database file, reconstructed from seed on process start.
+ #[arg(long)]
+ db: Option,
+ },
+ /// Compile a standalone program to its contract (JSON on stdout, or -o FILE).
Build {
file: PathBuf,
#[arg(short, long)]
out: Option,
},
- /// Compile, materialize the database, replay the seed, and serve HTTP.
+ /// Compile, materialize, seed, and serve one standalone .spock program.
Run {
file: PathBuf,
#[arg(long, default_value_t = 4000)]
@@ -35,7 +82,7 @@ enum Command {
#[arg(long)]
db: Option,
},
- /// Generate derived artifacts from a program (RFD 0010).
+ /// Generate derived artifacts from a standalone program (RFD 0010).
Gen {
#[command(subcommand)]
target: GenTarget,
@@ -59,50 +106,68 @@ enum GenTarget {
}
fn main() -> ExitCode {
- match Cli::parse().command {
- Command::Check { file } => {
- let Some(contract) = load(&file) else {
+ execute(Cli::parse().command)
+}
+
+fn execute(command: Command) -> ExitCode {
+ match command {
+ Command::Check { target } => {
+ let Some(cwd) = current_dir_or_report() else {
return ExitCode::FAILURE;
};
- // The full load proof (RFD 0013): materialize the schema in
- // memory, validate every fn body and inlined check, prove
- // defaults against their checks, and replay the seed — so
- // anything `spock run` would reject at load surfaces here,
- // without starting a server.
- if let Err(e) = spock_runtime::engine::open(&contract, None, Some(&source_dir(&file))) {
- eprintln!("error: {e}");
+ match spock_cli::check_target(target.as_deref(), &cwd) {
+ Ok(summary) => {
+ println!("{summary}");
+ if let CheckTargetSummary::Project { report, .. } = summary {
+ print_project_warnings(report.warnings);
+ }
+ ExitCode::SUCCESS
+ }
+ Err(error) => report_check_error(error),
+ }
+ }
+ Command::New { name, backend_only } => {
+ let Some(cwd) = current_dir_or_report() else {
return ExitCode::FAILURE;
+ };
+ match spock_cli::create_project(&cwd, &name, backend_only) {
+ Ok(summary) => {
+ println!("{summary}");
+ println!("next: run `spock dev` from the project directory above");
+ ExitCode::SUCCESS
+ }
+ Err(error) => report_error(error),
}
- // every v0 fn statement is an SQL escape — the unchecked count
- // is the ledger (RFD 0011 §4), trending to zero as native
- // bodies arrive
- let fns = if contract.fns.is_empty() {
- "0 fn(s)".to_string()
- } else {
- format!(
- "{} fn(s) ({} unchecked escapes)",
- contract.fns.len(),
- contract.fns.iter().map(|f| f.sql.len()).sum::()
- )
+ }
+ Command::Init { target } => {
+ let Some(cwd) = current_dir_or_report() else {
+ return ExitCode::FAILURE;
};
- println!(
- "ok: {} table(s), {} record(s), {fns}, {} seed row(s)",
- contract.tables.len(),
- contract.records.len(),
- contract.seed.len()
- );
- ExitCode::SUCCESS
+ match spock_cli::init_project(target.as_deref(), &cwd) {
+ Ok(summary) => {
+ println!("{summary}");
+ println!("next: run `spock dev` from the project directory above");
+ ExitCode::SUCCESS
+ }
+ Err(error) => report_error(error),
+ }
+ }
+ Command::Start { target, port, db } => {
+ serve_framework(target.as_deref(), port, db, HostMode::Start)
+ }
+ Command::Dev { target, port, db } => {
+ serve_framework(target.as_deref(), port, db, HostMode::Dev)
}
Command::Build { file, out } => {
- let Some(contract) = load(&file) else {
+ let Some(program) = load_or_report(&file) else {
return ExitCode::FAILURE;
};
- let json = serde_json::to_string_pretty(&contract).expect("contract serializes");
+ let json = spock_cli::build_artifact(program.contract());
match out {
None => println!("{json}"),
Some(path) => {
- if let Err(e) = std::fs::write(&path, json) {
- eprintln!("error: could not write {}: {e}", path.display());
+ if let Err(error) = std::fs::write(&path, json) {
+ eprintln!("error: could not write {}: {error}", path.display());
return ExitCode::FAILURE;
}
println!("wrote {}", path.display());
@@ -111,15 +176,20 @@ fn main() -> ExitCode {
ExitCode::SUCCESS
}
Command::Run { file, port, db } => {
- let Some(contract) = load(&file) else {
+ let Some(program) = load_or_report(&file) else {
return ExitCode::FAILURE;
};
- match run(contract, port, db, source_dir(&file)) {
+ let base_dir = program.source_dir();
+ let run = StandaloneRun::construct(program.into_contract(), db.as_deref(), base_dir);
+ match run.and_then(|run| {
+ let summary = run.summary();
+ println!("{summary}");
+ run.serve_until_ctrl_c(port, move || {
+ print_standalone_listening(port, summary.storage)
+ })
+ }) {
Ok(()) => ExitCode::SUCCESS,
- Err(e) => {
- eprintln!("error: {e}");
- ExitCode::FAILURE
- }
+ Err(error) => report_error(error),
}
}
Command::Gen { target } => {
@@ -128,37 +198,195 @@ fn main() -> ExitCode {
(file.clone(), out.clone())
}
};
- let Some(contract) = load(&file) else {
+ let Some(program) = load_or_report(&file) else {
return ExitCode::FAILURE;
};
let artifact = match target {
GenTarget::Types { .. } => {
- spock_lang::typescript::typescript(&contract).map_err(anyhow::Error::from)
+ spock_cli::generate_artifact(program.contract(), GenerationTarget::Types)
}
- GenTarget::GraphqlSchema { .. } => graphql_sdl(contract),
+ GenTarget::GraphqlSchema { .. } => spock_cli::generate_artifact(
+ program.contract(),
+ GenerationTarget::GraphqlSchema,
+ ),
};
match artifact {
Ok(content) => emit(out, content),
- Err(e) => {
- eprintln!("error: {e}");
- ExitCode::FAILURE
- }
+ Err(error) => report_error(error),
}
}
}
}
-/// The SDL of the schema the runtime would serve — derived through the
-/// same builder as `run`, so it cannot drift. The in-memory engine exists
-/// only because the builder wants a full `App`; no resolver ever runs,
-/// and the seed is dropped first: the SDL is a pure function of the
-/// tables, so a data problem (say, a seed unique conflict) must not gate
-/// a data-independent artifact.
-fn graphql_sdl(mut contract: Contract) -> anyhow::Result {
- contract.seed.clear();
- let conn = spock_runtime::engine::open(&contract, None, None)?;
- let app = Arc::new(spock_runtime::App::new(contract, conn));
- Ok(spock_runtime::graphql::schema(app)?.sdl())
+fn serve_framework(
+ target: Option<&Path>,
+ port: u16,
+ database_path: Option,
+ mode: HostMode,
+) -> ExitCode {
+ let Some(cwd) = current_dir_or_report() else {
+ return ExitCode::FAILURE;
+ };
+ let layout = match spock_cli::resolve_project_for_serve(target, &cwd) {
+ Ok(layout) => layout,
+ Err(error) => return report_error(error),
+ };
+ let runtime = match tokio::runtime::Builder::new_multi_thread()
+ .enable_all()
+ .build()
+ {
+ Ok(runtime) => runtime,
+ Err(error) => return report_error(error),
+ };
+ let options = ServeOptions {
+ bind: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port),
+ database_path,
+ ..ServeOptions::default()
+ };
+ let notices = HostNoticeSink::new(print_framework_notice);
+ let shutdown_signal = {
+ let _runtime_guard = runtime.enter();
+ match install_framework_shutdown_signal() {
+ Ok(signal) => signal,
+ Err(error) => {
+ return report_error(format!(
+ "could not install shutdown signal handler: {error}"
+ ))
+ }
+ }
+ };
+ let shutdown_error = Arc::new(Mutex::new(None));
+ let shutdown_error_for_signal = Arc::clone(&shutdown_error);
+ let shutdown = async move {
+ if let Err(error) = shutdown_signal.await {
+ *shutdown_error_for_signal
+ .lock()
+ .expect("shutdown error lock") = Some(error);
+ }
+ };
+ let result = runtime.block_on(spock_host::serve_project(
+ layout, mode, options, notices, shutdown,
+ ));
+ if let Some(error) = shutdown_error.lock().expect("shutdown error lock").take() {
+ return report_error(format!("shutdown signal handling failed: {error}"));
+ }
+ match result {
+ Ok(_) => ExitCode::SUCCESS,
+ Err(error) => report_error(error),
+ }
+}
+
+type ShutdownSignal = Pin> + Send>>;
+
+#[cfg(unix)]
+fn install_framework_shutdown_signal() -> io::Result {
+ use tokio::signal::unix::{signal, SignalKind};
+
+ let mut interrupt = signal(SignalKind::interrupt())?;
+ let mut terminate = signal(SignalKind::terminate())?;
+ Ok(Box::pin(async move {
+ tokio::select! {
+ received = interrupt.recv() => require_signal(received, "SIGINT"),
+ received = terminate.recv() => require_signal(received, "SIGTERM"),
+ }
+ }))
+}
+
+#[cfg(windows)]
+fn install_framework_shutdown_signal() -> io::Result {
+ use tokio::signal::windows::{ctrl_break, ctrl_c, ctrl_close};
+
+ let mut interrupt = ctrl_c()?;
+ let mut break_signal = ctrl_break()?;
+ let mut close = ctrl_close()?;
+ Ok(Box::pin(async move {
+ tokio::select! {
+ received = interrupt.recv() => require_signal(received, "Ctrl-C"),
+ received = break_signal.recv() => require_signal(received, "Ctrl-Break"),
+ received = close.recv() => require_signal(received, "console close"),
+ }
+ }))
+}
+
+#[cfg(not(any(unix, windows)))]
+fn install_framework_shutdown_signal() -> io::Result {
+ Ok(Box::pin(tokio::signal::ctrl_c()))
+}
+
+#[cfg(any(unix, windows))]
+fn require_signal(received: Option<()>, name: &str) -> io::Result<()> {
+ received.ok_or_else(|| {
+ io::Error::new(
+ io::ErrorKind::BrokenPipe,
+ format!("{name} signal stream closed before delivering a signal"),
+ )
+ })
+}
+
+fn print_framework_notice(notice: HostNotice) {
+ match notice {
+ HostNotice::DevelopmentPolicy => eprintln!(
+ "warning: backend inputs (including referenced seed assets) and spock.toml topology changes are observed but not applied; restart `spock dev` to reconstruct backend state from seed"
+ ),
+ HostNotice::Listening {
+ address,
+ client_configured,
+ } => {
+ println!("listening on http://{address}");
+ if client_configured {
+ println!(" GET / Uhura Editor");
+ println!(" GET /play Uhura Play");
+ }
+ println!(" GET /~studio Spock Studio");
+ println!(" GET /~contract active Spock contract");
+ println!(" GET /~project/status framework generation status");
+ println!(" GET /~health aggregate readiness");
+ println!(" * /rest/v1/* authority REST and RPC");
+ println!(" POST /graphql/v1 GraphQL when the contract is non-empty");
+ }
+ HostNotice::ClientBuilding { observed_revision } => {
+ println!("client: building revision {observed_revision}");
+ }
+ HostNotice::ClientPublished {
+ observed_revision,
+ source_revision,
+ play_generation,
+ } => println!(
+ "client: published revision {observed_revision} (source {source_revision}, Play generation {play_generation})"
+ ),
+ HostNotice::ClientRejected {
+ observed_revision,
+ diagnostics,
+ serving_last_good,
+ } => {
+ eprintln!(
+ "client: rejected revision {observed_revision}; serving_last_good={serving_last_good}"
+ );
+ for diagnostic in diagnostics {
+ eprintln!(" {diagnostic}");
+ }
+ }
+ HostNotice::BackendRestartRequired {
+ changed_inputs,
+ diagnostics,
+ } => {
+ eprintln!(
+ "backend: restart required; active state remains pinned (changed: {})",
+ changed_inputs.join(", ")
+ );
+ for diagnostic in diagnostics {
+ eprintln!(" {diagnostic}");
+ }
+ }
+ HostNotice::BackendReverted => {
+ println!("backend: inputs match the active generation again");
+ }
+ HostNotice::ObserverError { message } => {
+ eprintln!("warning: development observer: {message}");
+ }
+ }
+ let _ = io::stdout().flush();
+ let _ = io::stderr().flush();
}
/// Print to stdout, or write to `-o FILE`.
@@ -169,8 +397,8 @@ fn emit(out: Option, content: String) -> ExitCode {
ExitCode::SUCCESS
}
Some(path) => {
- if let Err(e) = std::fs::write(&path, content) {
- eprintln!("error: could not write {}: {e}", path.display());
+ if let Err(error) = std::fs::write(&path, content) {
+ eprintln!("error: could not write {}: {error}", path.display());
return ExitCode::FAILURE;
}
println!("wrote {}", path.display());
@@ -179,71 +407,81 @@ fn emit(out: Option, content: String) -> ExitCode {
}
}
-/// The directory a `.spock` file lives in — the root for `file("...")` seed
-/// assets (RFD 0018). Empty (cwd-relative) when the path has no parent.
-fn source_dir(file: &Path) -> PathBuf {
- file.parent().map(|p| p.to_path_buf()).unwrap_or_default()
+fn current_dir_or_report() -> Option {
+ match std::env::current_dir() {
+ Ok(path) => Some(path),
+ Err(error) => {
+ eprintln!("error: could not resolve the working directory: {error}");
+ None
+ }
+ }
}
-/// Read, compile, and (on failure) render every diagnostic.
-fn load(path: &PathBuf) -> Option {
- let source = match std::fs::read_to_string(path) {
- Ok(s) => s,
- Err(e) => {
- eprintln!("error: could not read {}: {e}", path.display());
- return None;
- }
- };
- match spock_lang::compile(&source) {
- Ok(contract) => Some(contract),
- Err(diags) => {
- for diag in &diags {
- eprintln!("{}", diag.render(&source, &path.display().to_string()));
- }
- eprintln!(
- "error: {} diagnostic(s), contract not produced",
- diags.len()
- );
+/// Binary-only presentation adapter for the library's structured load error.
+fn load_or_report(path: &Path) -> Option {
+ match FileProgram::load(path) {
+ Ok(program) => Some(program),
+ Err(error) => {
+ eprintln!("{error}");
None
}
}
}
-fn run(
- contract: Contract,
- port: u16,
- db: Option,
- base_dir: PathBuf,
-) -> anyhow::Result<()> {
- let conn = spock_runtime::engine::open(&contract, db.as_deref(), Some(base_dir.as_path()))?;
- println!(
- "spock v0 — contract loaded: {} table(s), {} fn(s), {} seed row(s) replayed",
- contract.tables.len(),
- contract.fns.len(),
- contract.seed.len()
- );
-
- let app = Arc::new(spock_runtime::App::new(contract, conn));
- let storage = spock_runtime::storage::storage_active(&app.contract);
- let runtime = tokio::runtime::Runtime::new()?;
- runtime.block_on(async move {
- let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await?;
- println!("listening on http://127.0.0.1:{port}");
- println!(" GET /~studio the developer console — browse, impersonate, run");
- println!(" GET /~contract the contract, as data");
- println!(" GET /rest/v1/{{table}} open reads (identity view)");
- println!(" POST /rest/v1/rpc/{{fn}} call a declared fn");
- println!(" POST /graphql/v1 GraphQL reads + writes (GraphiQL in the browser)");
- if storage {
- println!(" * /storage/v1/object upload + serve files (signed URLs)");
- }
- // `serve` owns the in-process orphan sweep for a storage contract
- // (RFD 0018 §1.6); the binary only decides when to stop.
- tokio::select! {
- result = spock_runtime::http::serve(app.clone(), listener) => result?,
- _ = tokio::signal::ctrl_c() => {}
+fn report_error(error: impl std::fmt::Display) -> ExitCode {
+ eprintln!("error: {error}");
+ ExitCode::FAILURE
+}
+
+fn report_check_error(error: CheckTargetError) -> ExitCode {
+ match error {
+ // These two preserve the historical standalone rendering, which
+ // already owns its `error:` prefix and source diagnostics.
+ CheckTargetError::FileLoad(error) => eprintln!("{error}"),
+ CheckTargetError::FileLoadProof { source, .. } => eprintln!("error: {source}"),
+ CheckTargetError::ProjectCheck(error) => report_project_check_failure(&error),
+ error => eprintln!("error: {error}"),
+ }
+ ExitCode::FAILURE
+}
+
+fn print_project_warnings(warnings: Vec) {
+ let mut seen = BTreeSet::new();
+ for warning in warnings {
+ if seen.insert(warning.clone()) {
+ eprintln!("warning: {warning}");
+ }
+ }
+}
+
+fn report_project_check_failure(error: &ProjectCheckFailure) {
+ let mut seen = BTreeSet::new();
+ let mut first = true;
+ for diagnostic in error.diagnostics() {
+ if !seen.insert(diagnostic.clone()) {
+ continue;
}
- Ok::<(), anyhow::Error>(())
- })?;
- Ok(())
+ let rendered = diagnostic.to_string();
+ if first {
+ eprintln!("error: {rendered}");
+ first = false;
+ } else {
+ eprintln!("{rendered}");
+ }
+ }
+ if first {
+ eprintln!("error: project check failed without diagnostics");
+ }
+}
+
+fn print_standalone_listening(port: u16, storage: bool) {
+ println!("listening on http://127.0.0.1:{port}");
+ println!(" GET /~studio the developer console — browse, impersonate, run");
+ println!(" GET /~contract the contract, as data");
+ println!(" GET /rest/v1/{{table}} open reads (identity view)");
+ println!(" POST /rest/v1/rpc/{{fn}} call a declared fn");
+ println!(" POST /graphql/v1 GraphQL reads + writes (GraphiQL in the browser)");
+ if storage {
+ println!(" * /storage/v1/object upload + serve files (signed URLs)");
+ }
}
diff --git a/crates/spock-cli/src/project_commands.rs b/crates/spock-cli/src/project_commands.rs
new file mode 100644
index 0000000..b2846cf
--- /dev/null
+++ b/crates/spock-cli/src/project_commands.rs
@@ -0,0 +1,635 @@
+//! Reusable command boundaries for framework-project operations.
+//!
+//! Target selection, checking, and filesystem mutation live here rather than
+//! in the Clap presentation layer. An explicit `.spock` target keeps the
+//! historical language-file check; every other accepted check target resolves
+//! to a validated framework project.
+
+use std::collections::BTreeSet;
+use std::fmt;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use spock_host::{ProjectCheckFailure, ProjectCheckReport};
+use spock_project::{
+ adoption_plan, load_project, minimal_uhura_client_template, parse_manifest, resolve_target,
+ scaffold_plan, Diagnostics, NormalizedRelativePath, ProjectInventory, ProjectLayout,
+ ProjectName, ResolvedTarget, MANIFEST_FILE,
+};
+use thiserror::Error;
+
+use crate::write_plan::{apply_prepared_write_plan, PreparedWriteRoot, PreparedWriteTarget};
+use crate::{full_load_check, ApplyError, CheckSummary, FileProgram, ProgramLoadError};
+
+/// A successful polymorphic `spock check` result.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum CheckTargetSummary {
+ /// The historical one-file language/runtime proof.
+ File {
+ path: PathBuf,
+ summary: CheckSummary,
+ },
+ /// A manifest, backend, client, and currently provable link check.
+ Project {
+ root: PathBuf,
+ project_name: String,
+ report: ProjectCheckReport,
+ },
+}
+
+impl CheckTargetSummary {
+ #[must_use]
+ pub fn path(&self) -> &Path {
+ match self {
+ Self::File { path, .. } => path,
+ Self::Project { root, .. } => root,
+ }
+ }
+}
+
+impl fmt::Display for CheckTargetSummary {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::File { summary, .. } => summary.fmt(formatter),
+ Self::Project {
+ project_name,
+ report,
+ ..
+ } => {
+ write!(
+ formatter,
+ "ok: project `{project_name}` — {} table(s), {} record(s), {} fn(s), {} seed row(s)",
+ report.backend.tables,
+ report.backend.records,
+ report.backend.functions,
+ report.backend.seed_rows,
+ )?;
+ match &report.client {
+ Some(client) => write!(
+ formatter,
+ ", {} preview(s), {} replay-derived preview(s)",
+ client.preview_count, client.replay_derived_count,
+ )?,
+ None => formatter.write_str(", backend only")?,
+ }
+ write!(
+ formatter,
+ ", {} unchecked link(s), {} warning(s)",
+ report.unchecked_links,
+ report.warnings.len(),
+ )
+ }
+ }
+ }
+}
+
+/// Failure from polymorphic `spock check` target selection or checking.
+#[derive(Debug, Error)]
+pub enum CheckTargetError {
+ #[error(transparent)]
+ ProjectTopology(#[from] Diagnostics),
+ #[error(transparent)]
+ FileLoad(#[from] ProgramLoadError),
+ #[error("error: {source}")]
+ FileLoadProof {
+ path: PathBuf,
+ #[source]
+ source: anyhow::Error,
+ },
+ #[error(transparent)]
+ ProjectCheck(#[from] ProjectCheckFailure),
+}
+
+/// Select and fully check either an explicit `.spock` file or a project.
+///
+/// File mode deliberately calls the same [`FileProgram`] and
+/// [`full_load_check`] boundary used by the legacy CLI. In project mode the
+/// framework manifest is loaded first, then every configured subsystem is
+/// checked without binding a listener or touching named state.
+pub fn check_target(
+ target: Option<&Path>,
+ cwd: &Path,
+) -> Result {
+ if let Some(path) = target.filter(|path| has_spock_extension(path)) {
+ let program = FileProgram::load_from_cwd(path, cwd)?;
+ let summary =
+ full_load_check(program.contract(), program.source_dir()).map_err(|source| {
+ CheckTargetError::FileLoadProof {
+ path: path.to_path_buf(),
+ source,
+ }
+ })?;
+ return Ok(CheckTargetSummary::File {
+ path: path.to_path_buf(),
+ summary,
+ });
+ }
+
+ match resolve_target(target, cwd)? {
+ ResolvedTarget::SpockFile(path) => {
+ // `resolve_target` selects file mode only by the extension checked
+ // above. Keep this fallback defensive for future resolver modes.
+ let program = FileProgram::load(&path)?;
+ let summary =
+ full_load_check(program.contract(), program.source_dir()).map_err(|source| {
+ CheckTargetError::FileLoadProof {
+ path: path.clone(),
+ source,
+ }
+ })?;
+ Ok(CheckTargetSummary::File { path, summary })
+ }
+ ResolvedTarget::Project(root) => {
+ let layout = load_project(&root)?;
+ let project_name = layout.manifest.project().as_str().to_string();
+ let mut report = spock_host::check_project(&layout)?;
+ deduplicate_warnings(&mut report);
+ Ok(CheckTargetSummary::Project {
+ root: layout.root,
+ project_name,
+ report,
+ })
+ }
+ }
+}
+
+/// Failure while selecting a framework project for `start` or `dev`.
+#[derive(Debug, Error)]
+pub enum ResolveProjectForServeError {
+ #[error(transparent)]
+ ProjectTopology(#[from] Diagnostics),
+ #[error("`{}` selects standalone `.spock` file mode; run `spock run` with that file instead", path.display())]
+ StandaloneFile { path: PathBuf },
+}
+
+/// Resolve and validate a project target for a framework serving command.
+///
+/// `start` and `dev` never silently reinterpret a `.spock` file as a project;
+/// their error points at the retained standalone `spock run` escape hatch.
+pub fn resolve_project_for_serve(
+ target: Option<&Path>,
+ cwd: &Path,
+) -> Result {
+ if let Some(path) = target.filter(|path| has_spock_extension(path)) {
+ return Err(ResolveProjectForServeError::StandaloneFile {
+ path: path.to_path_buf(),
+ });
+ }
+
+ match resolve_target(target, cwd)? {
+ ResolvedTarget::Project(root) => Ok(load_project(&root)?),
+ ResolvedTarget::SpockFile(path) => {
+ Err(ResolveProjectForServeError::StandaloneFile { path })
+ }
+ }
+}
+
+fn has_spock_extension(path: &Path) -> bool {
+ path.extension().and_then(|extension| extension.to_str()) == Some("spock")
+}
+
+fn deduplicate_warnings(report: &mut ProjectCheckReport) {
+ let mut seen = BTreeSet::new();
+ report
+ .warnings
+ .retain(|warning| seen.insert(warning.clone()));
+}
+
+/// Whether a successful project write created a new root or adopted one.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum ProjectWriteOperation {
+ New,
+ Init,
+}
+
+/// Exact, presentation-independent effects of `spock new` or `spock init`.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ProjectWriteSummary {
+ pub operation: ProjectWriteOperation,
+ pub root: PathBuf,
+ pub project_name: String,
+ pub includes_client: bool,
+ pub created_files: Vec,
+ pub created_directories: Vec,
+}
+
+impl fmt::Display for ProjectWriteSummary {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let verb = match self.operation {
+ ProjectWriteOperation::New => "created",
+ ProjectWriteOperation::Init => "initialized",
+ };
+ let shape = if self.includes_client {
+ "full-stack"
+ } else {
+ "backend-only"
+ };
+ write!(
+ formatter,
+ "{verb} {shape} project `{}` at {}",
+ self.project_name,
+ self.root.display(),
+ )
+ }
+}
+
+/// A rejected `spock new NAME` before any path is created.
+#[derive(Clone, Debug, Eq, Error, PartialEq)]
+#[error("invalid project name `{name}`: {reason}; NAME must be one safe path component")]
+pub struct NewProjectNameError {
+ pub name: String,
+ pub reason: String,
+}
+
+/// Failure while planning or safely applying `spock new`/`spock init`.
+#[derive(Debug, Error)]
+pub enum ProjectWriteError {
+ #[error(transparent)]
+ InvalidName(#[from] NewProjectNameError),
+ #[error("could not resolve {role} `{}`: {source}", path.display())]
+ ResolveDirectory {
+ role: &'static str,
+ path: PathBuf,
+ #[source]
+ source: std::io::Error,
+ },
+ #[error("{role} `{}` is not a directory", path.display())]
+ NotDirectory { role: &'static str, path: PathBuf },
+ #[error(transparent)]
+ ProjectPlan(#[from] Diagnostics),
+ #[error(transparent)]
+ Apply(#[from] ApplyError),
+}
+
+/// Create the canonical project as a direct child of `cwd`.
+///
+/// Full stack is the default; `backend_only` is the explicit opt-out. The
+/// destination is preflighted as empty and then created with the race-safe
+/// new-destination policy.
+pub fn create_project(
+ cwd: &Path,
+ name: &str,
+ backend_only: bool,
+) -> Result {
+ validate_new_project_name(name)?;
+ let cwd = canonical_directory(cwd, "working directory")?;
+ let prepared_parent = prepare_write_root(&cwd, "working directory")?;
+ let destination = prepared_parent.path().join(name);
+ let client = (!backend_only).then(minimal_uhura_client_template);
+ let plan = scaffold_plan(&destination, name, client.as_ref())?;
+ let inventory = ProjectInventory::empty(destination.clone());
+ plan.preflight(&inventory)?;
+ apply_project_plan(
+ &plan,
+ PreparedWriteTarget::new_child(prepared_parent, name),
+ ProjectWriteOperation::New,
+ )
+}
+
+/// Adopt exactly `target`, or `cwd` when no target is supplied.
+///
+/// Unlike project discovery, initialization does not walk to a parent
+/// manifest: it inventories the selected directory itself and lets the pure
+/// adoption planner reject existing projects, ambiguity, and conflicts before
+/// the race-safe apply boundary runs.
+pub fn init_project(
+ target: Option<&Path>,
+ cwd: &Path,
+) -> Result {
+ let cwd = canonical_directory(cwd, "working directory")?;
+ let selected = match target {
+ Some(path) if path.is_absolute() => path.to_path_buf(),
+ Some(path) => cwd.join(path),
+ None => cwd,
+ };
+ let selected = canonical_directory(&selected, "adoption root")?;
+ let prepared_root = prepare_write_root(&selected, "adoption root")?;
+ let inventory = prepared_root.inventory()?;
+ prepared_root
+ .validate()
+ .map_err(|source| ProjectWriteError::ResolveDirectory {
+ role: "adoption root",
+ path: selected,
+ source,
+ })?;
+ let plan = adoption_plan(&inventory, None)?;
+ plan.preflight(&inventory)?;
+ apply_project_plan(
+ &plan,
+ PreparedWriteTarget::existing(prepared_root),
+ ProjectWriteOperation::Init,
+ )
+}
+
+fn validate_new_project_name(name: &str) -> Result<(), NewProjectNameError> {
+ ProjectName::parse(name).map_err(|reason| NewProjectNameError {
+ name: name.to_string(),
+ reason,
+ })?;
+
+ let path = NormalizedRelativePath::file(name).map_err(|error| NewProjectNameError {
+ name: name.to_string(),
+ reason: error.to_string(),
+ })?;
+ if !path.parent().is_project_root() {
+ return Err(NewProjectNameError {
+ name: name.to_string(),
+ reason: "nested paths are not allowed".to_string(),
+ });
+ }
+ Ok(())
+}
+
+fn canonical_directory(path: &Path, role: &'static str) -> Result {
+ let canonical =
+ fs::canonicalize(path).map_err(|source| ProjectWriteError::ResolveDirectory {
+ role,
+ path: path.to_path_buf(),
+ source,
+ })?;
+ if !canonical.is_dir() {
+ return Err(ProjectWriteError::NotDirectory {
+ role,
+ path: canonical,
+ });
+ }
+ Ok(canonical)
+}
+
+fn prepare_write_root(
+ path: &Path,
+ role: &'static str,
+) -> Result {
+ PreparedWriteRoot::open(path).map_err(|source| ProjectWriteError::ResolveDirectory {
+ role,
+ path: path.to_path_buf(),
+ source,
+ })
+}
+
+fn apply_project_plan(
+ plan: &spock_project::WritePlan,
+ target: PreparedWriteTarget,
+ operation: ProjectWriteOperation,
+) -> Result {
+ let manifest_write = plan
+ .write(MANIFEST_FILE)
+ .expect("project plans always include their manifest commit marker");
+ let manifest_source = std::str::from_utf8(&manifest_write.contents)
+ .expect("project planners emit a UTF-8 manifest");
+ let manifest = parse_manifest(manifest_source)?;
+ let project_name = manifest.project().as_str().to_string();
+ let includes_client = manifest.client().is_some();
+
+ let applied = apply_prepared_write_plan(plan, target)?;
+ Ok(ProjectWriteSummary {
+ operation,
+ root: applied.root().to_path_buf(),
+ project_name,
+ includes_client,
+ created_files: applied.created_files().to_vec(),
+ created_directories: applied.created_directories().to_vec(),
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::io;
+ use std::sync::atomic::{AtomicU64, Ordering};
+
+ static NEXT_TEST_DIRECTORY: AtomicU64 = AtomicU64::new(0);
+
+ struct TestDirectory(PathBuf);
+
+ impl TestDirectory {
+ fn new() -> Self {
+ loop {
+ let id = NEXT_TEST_DIRECTORY.fetch_add(1, Ordering::Relaxed);
+ let path = std::env::temp_dir().join(format!(
+ "spock-project-commands-{}-{id}",
+ std::process::id()
+ ));
+ match fs::create_dir(&path) {
+ Ok(()) => return Self(path),
+ Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
+ Err(error) => panic!("could not create test directory: {error}"),
+ }
+ }
+ }
+
+ fn path(&self) -> &Path {
+ &self.0
+ }
+ }
+
+ impl Drop for TestDirectory {
+ fn drop(&mut self) {
+ let _ = fs::remove_dir_all(&self.0);
+ }
+ }
+
+ #[test]
+ fn explicit_spock_check_keeps_the_file_load_proof_and_summary() {
+ let temporary = TestDirectory::new();
+ let source = temporary.path().join("standalone.spock");
+ fs::write(
+ &source,
+ "table user { key id: uuid = auto\n name: text }\n\
+ seed { user { name: \"Ada\" } }\n",
+ )
+ .unwrap();
+
+ let result = check_target(Some(Path::new("standalone.spock")), temporary.path()).unwrap();
+
+ assert_eq!(
+ result,
+ CheckTargetSummary::File {
+ path: PathBuf::from("standalone.spock"),
+ summary: CheckSummary {
+ tables: 1,
+ records: 0,
+ functions: 0,
+ unchecked_escapes: 0,
+ seed_rows: 1,
+ },
+ }
+ );
+ assert_eq!(
+ result.to_string(),
+ "ok: 1 table(s), 0 record(s), 0 fn(s), 1 seed row(s)"
+ );
+ }
+
+ #[test]
+ fn omitted_check_target_checks_the_nearest_project() {
+ let temporary = TestDirectory::new();
+ let created = create_project(temporary.path(), "demo", true).unwrap();
+ let nested = created.root.join("backend");
+
+ let checked = check_target(None, &nested).unwrap();
+
+ let CheckTargetSummary::Project {
+ root,
+ project_name,
+ report,
+ } = checked
+ else {
+ panic!("omitted target did not select project mode");
+ };
+ assert_eq!(root, created.root);
+ assert_eq!(project_name, "demo");
+ assert_eq!(report.backend.tables, 0);
+ assert!(report.client.is_none());
+ assert!(report.warnings.is_empty());
+ }
+
+ #[test]
+ fn framework_serve_rejects_file_mode_with_run_guidance() {
+ let temporary = TestDirectory::new();
+ let error =
+ resolve_project_for_serve(Some(Path::new("api.spock")), temporary.path()).unwrap_err();
+
+ assert!(matches!(
+ error,
+ ResolveProjectForServeError::StandaloneFile { .. }
+ ));
+ let rendered = error.to_string();
+ assert!(rendered.contains("spock run"), "{rendered}");
+ assert!(rendered.contains("api.spock"), "{rendered}");
+ }
+
+ #[test]
+ fn new_defaults_to_the_embedded_full_stack_template() {
+ let temporary = TestDirectory::new();
+
+ let summary = create_project(temporary.path(), "demo", false).unwrap();
+
+ assert_eq!(summary.operation, ProjectWriteOperation::New);
+ assert_eq!(summary.project_name, "demo");
+ assert!(summary.includes_client);
+ assert!(summary.root.join("backend/app.spock").is_file());
+ for file in minimal_uhura_client_template().files() {
+ assert_eq!(
+ fs::read(summary.root.join("client").join(file.path().as_path())).unwrap(),
+ file.contents(),
+ );
+ }
+ assert_eq!(
+ summary.created_files.last(),
+ Some(&summary.root.join(MANIFEST_FILE)),
+ "manifest remains the final commit marker"
+ );
+ }
+
+ #[test]
+ fn backend_only_new_omits_client_topology() {
+ let temporary = TestDirectory::new();
+
+ let summary = create_project(temporary.path(), "authority", true).unwrap();
+
+ assert!(!summary.includes_client);
+ assert!(!summary.root.join("client").exists());
+ let manifest = fs::read_to_string(summary.root.join(MANIFEST_FILE)).unwrap();
+ assert!(!manifest.contains("[client]"));
+ }
+
+ #[test]
+ fn unsafe_new_names_fail_without_filesystem_effects() {
+ let temporary = TestDirectory::new();
+
+ for name in [
+ "",
+ ".",
+ "..",
+ "../escape",
+ "nested/name",
+ "nested\\name",
+ "C:",
+ ] {
+ let error = create_project(temporary.path(), name, false).unwrap_err();
+ assert!(matches!(error, ProjectWriteError::InvalidName(_)), "{name}");
+ }
+
+ assert_eq!(fs::read_dir(temporary.path()).unwrap().count(), 0);
+ }
+
+ #[test]
+ fn init_adopts_the_exact_selected_directory_without_moving_sources() {
+ let temporary = TestDirectory::new();
+ let adoption = temporary.path().join("existing");
+ fs::create_dir(&adoption).unwrap();
+ fs::write(adoption.join("main.spock"), "").unwrap();
+ fs::write(adoption.join("owned.txt"), "keep\n").unwrap();
+
+ let summary = init_project(Some(Path::new("existing")), temporary.path()).unwrap();
+
+ assert_eq!(summary.operation, ProjectWriteOperation::Init);
+ assert_eq!(summary.root, fs::canonicalize(&adoption).unwrap());
+ assert_eq!(
+ fs::read_to_string(adoption.join("owned.txt")).unwrap(),
+ "keep\n"
+ );
+ assert!(adoption.join("main.spock").is_file());
+ assert!(!adoption.join("backend/app.spock").exists());
+ let manifest = fs::read_to_string(adoption.join(MANIFEST_FILE)).unwrap();
+ assert!(manifest.contains("root = \".\""), "{manifest}");
+ assert!(manifest.contains("entry = \"main.spock\""), "{manifest}");
+ }
+
+ #[test]
+ fn init_of_uhura_only_root_adds_the_required_empty_backend_and_checks() {
+ let temporary = TestDirectory::new();
+ for file in minimal_uhura_client_template().files() {
+ let destination = temporary.path().join(file.path().as_path());
+ fs::create_dir_all(destination.parent().unwrap()).unwrap();
+ fs::write(destination, file.contents()).unwrap();
+ }
+
+ let summary = init_project(None, temporary.path()).unwrap();
+
+ assert!(summary.includes_client);
+ assert!(temporary.path().join("backend/app.spock").is_file());
+ let checked = check_target(None, temporary.path()).unwrap();
+ let CheckTargetSummary::Project { report, .. } = checked else {
+ panic!("adopted root did not select project mode");
+ };
+ assert!(report.client.is_some());
+ assert_eq!(report.unchecked_links, 1);
+ assert_eq!(report.warnings.len(), 1);
+ }
+
+ #[test]
+ fn init_ambiguity_fails_before_writing_a_manifest() {
+ let temporary = TestDirectory::new();
+ fs::write(temporary.path().join("one.spock"), "").unwrap();
+ fs::write(temporary.path().join("two.spock"), "").unwrap();
+
+ let error = init_project(None, temporary.path()).unwrap_err();
+
+ let ProjectWriteError::ProjectPlan(diagnostics) = error else {
+ panic!("ambiguity did not remain a structured project diagnostic");
+ };
+ assert_eq!(
+ diagnostics.iter().next().map(|diagnostic| diagnostic.code),
+ Some(spock_project::DiagnosticCode::AmbiguousBackend),
+ );
+ assert!(!temporary.path().join(MANIFEST_FILE).exists());
+ }
+
+ #[test]
+ fn init_rejects_portable_manifest_alias_before_other_writes() {
+ let temporary = TestDirectory::new();
+ let aliased_manifest = temporary.path().join("SPOCK.TOML");
+ fs::write(&aliased_manifest, "owned-by-existing-project\n").unwrap();
+
+ let error = init_project(None, temporary.path()).unwrap_err();
+
+ assert!(matches!(error, ProjectWriteError::ProjectPlan(_)));
+ assert_eq!(
+ fs::read_to_string(aliased_manifest).unwrap(),
+ "owned-by-existing-project\n"
+ );
+ assert!(!temporary.path().join("backend").exists());
+ }
+}
diff --git a/crates/spock-cli/src/write_plan.rs b/crates/spock-cli/src/write_plan.rs
new file mode 100644
index 0000000..f910894
--- /dev/null
+++ b/crates/spock-cli/src/write_plan.rs
@@ -0,0 +1,2771 @@
+//! Race-safe filesystem application for [`spock_project::WritePlan`].
+//!
+//! Planning remains mutation-free in `spock-project`. This module owns the
+//! imperative boundary used by `spock new` and `spock init`: it creates every
+//! file with `create_new`, treats `spock.toml` as the final commit marker, and
+//! handles failure without touching pre-existing paths. Unix rollback removes
+//! exact invocation-owned files through retained parent handles but preserves
+//! created directories because `mkdir` cannot atomically return an ownership
+//! handle. Windows deliberately preserves and reports every known creation
+//! because the available rename APIs cannot safely restore without replacement.
+
+#[cfg(windows)]
+use std::collections::BTreeMap;
+use std::ffi::{OsStr, OsString};
+use std::fmt;
+use std::fs;
+#[cfg(not(unix))]
+use std::fs::OpenOptions;
+use std::io::{self, Write};
+use std::path::{Path, PathBuf};
+#[cfg(not(windows))]
+use std::sync::atomic::{AtomicU64, Ordering};
+
+use spock_project::{
+ is_ignored_inventory_directory, Diagnostic, DiagnosticCode, Diagnostics, InventoryEntryKind,
+ NormalizedRelativePath, PlanKind, ProjectInventory, ProjectResult, WritePlan, MANIFEST_FILE,
+};
+
+#[cfg(not(windows))]
+static NEXT_QUARANTINE: AtomicU64 = AtomicU64::new(0);
+
+/// Filesystem policy for the root of a write plan.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum RootPolicy {
+ /// `spock new`: the destination itself must not exist.
+ NewDestination,
+ /// `spock init`: the adoption root must already be a real directory.
+ ExistingAdoptionRoot,
+}
+
+impl RootPolicy {
+ fn accepts(self, kind: PlanKind) -> bool {
+ matches!(
+ (self, kind),
+ (Self::NewDestination, PlanKind::Scaffold)
+ | (Self::ExistingAdoptionRoot, PlanKind::Adopt)
+ )
+ }
+}
+
+/// A live filesystem capability retained between project inventory and apply.
+///
+/// The public project commands use this lease so planning and mutation cannot
+/// silently select different directories at the same pathname. On Windows the
+/// retained directory handle also denies delete sharing, preventing rename or
+/// replacement for the lifetime of the lease.
+#[derive(Debug)]
+pub(crate) struct PreparedWriteRoot(PinnedRoot);
+
+impl PreparedWriteRoot {
+ pub(crate) fn open(path: &Path) -> io::Result {
+ PinnedRoot::open(path).map(Self)
+ }
+
+ pub(crate) fn path(&self) -> &Path {
+ &self.0.path
+ }
+
+ pub(crate) fn validate(&self) -> io::Result<()> {
+ self.0.validate()
+ }
+
+ /// Inventory exactly the retained directory used by the later apply.
+ pub(crate) fn inventory(&self) -> ProjectResult {
+ scan_prepared_inventory(&self.0)
+ }
+}
+
+fn inventory_io(
+ root: &Path,
+ relative: &Path,
+ action: &str,
+ error: impl fmt::Display,
+) -> Diagnostics {
+ Diagnostics::one(
+ Diagnostic::new(DiagnosticCode::Io, format!("could not {action}: {error}"))
+ .at_path(root.join(relative)),
+ )
+}
+
+fn normalized_inventory_path(
+ root: &Path,
+ relative: &Path,
+) -> ProjectResult {
+ let mut segments = Vec::new();
+ for component in relative.components() {
+ let std::path::Component::Normal(segment) = component else {
+ return Err(Diagnostic::new(
+ DiagnosticCode::InvalidManifestPath,
+ "scanned path cannot be represented in a project manifest",
+ )
+ .at_path(root.join(relative))
+ .into());
+ };
+ let Some(segment) = segment.to_str() else {
+ return Err(Diagnostic::new(
+ DiagnosticCode::InvalidManifestPath,
+ "scanned path is not UTF-8 and cannot be represented in a project manifest",
+ )
+ .at_path(root.join(relative))
+ .into());
+ };
+ segments.push(segment);
+ }
+ NormalizedRelativePath::file(&segments.join("/")).map_err(|error| {
+ Diagnostics::one(
+ Diagnostic::new(
+ DiagnosticCode::InvalidManifestPath,
+ format!("scanned path is not a valid project path: {error}"),
+ )
+ .at_path(root.join(relative)),
+ )
+ })
+}
+
+#[cfg(unix)]
+fn scan_prepared_inventory(root: &PinnedRoot) -> ProjectResult {
+ let mut entries = Vec::new();
+ scan_unix_inventory_directory(&root.directory, Path::new(""), &root.path, &mut entries)?;
+ ProjectInventory::from_entries(root.path.clone(), entries)
+}
+
+#[cfg(unix)]
+fn scan_unix_inventory_directory(
+ directory: &fs::File,
+ relative_parent: &Path,
+ root: &Path,
+ entries: &mut Vec<(NormalizedRelativePath, InventoryEntryKind)>,
+) -> ProjectResult<()> {
+ use std::os::unix::ffi::OsStrExt;
+
+ let reader = rustix::fs::Dir::read_from(directory)
+ .map_err(|error| inventory_io(root, relative_parent, "scan directory", error))?;
+ let mut names = reader
+ .map(|entry| {
+ let entry = entry.map_err(|error| {
+ inventory_io(root, relative_parent, "scan directory entry", error)
+ })?;
+ Ok(OsStr::from_bytes(entry.file_name().to_bytes()).to_os_string())
+ })
+ .collect::>>()?;
+ names.retain(|name| name != "." && name != "..");
+ names.sort();
+
+ for name in names {
+ let relative = relative_parent.join(&name);
+ let metadata = rustix::fs::statat(directory, &name, rustix::fs::AtFlags::SYMLINK_NOFOLLOW)
+ .map_err(|error| inventory_io(root, &relative, "inspect directory entry", error))?;
+ let kind = match rustix::fs::FileType::from_raw_mode(metadata.st_mode) {
+ rustix::fs::FileType::Directory => InventoryEntryKind::Directory,
+ rustix::fs::FileType::Symlink => InventoryEntryKind::Symlink,
+ rustix::fs::FileType::RegularFile => InventoryEntryKind::File,
+ _ => InventoryEntryKind::Unsupported,
+ };
+ let normalized = normalized_inventory_path(root, &relative)?;
+ let ignored = kind == InventoryEntryKind::Directory
+ && normalized
+ .file_name()
+ .is_some_and(is_ignored_inventory_directory);
+ entries.push((normalized, kind));
+ if kind == InventoryEntryKind::Directory && !ignored {
+ let child = open_directory_at(directory, &name)
+ .map_err(|error| inventory_io(root, &relative, "open directory", error))?;
+ scan_unix_inventory_directory(&child, &relative, root, entries)?;
+ }
+ }
+ Ok(())
+}
+
+#[cfg(windows)]
+fn scan_prepared_inventory(root: &PinnedRoot) -> ProjectResult {
+ let mut entries = Vec::new();
+ scan_windows_inventory_directory(&root.directory, Path::new(""), &root.path, &mut entries)?;
+ ProjectInventory::from_entries(root.path.clone(), entries)
+}
+
+#[cfg(windows)]
+fn scan_windows_inventory_directory(
+ directory: &cap_std::fs::Dir,
+ relative_parent: &Path,
+ root: &Path,
+ entries: &mut Vec<(NormalizedRelativePath, InventoryEntryKind)>,
+) -> ProjectResult<()> {
+ use cap_fs_ext::DirExt as _;
+
+ let reader = directory
+ .entries()
+ .map_err(|error| inventory_io(root, relative_parent, "scan directory", error))?;
+ let mut children = reader
+ .map(|entry| {
+ let entry = entry.map_err(|error| {
+ inventory_io(root, relative_parent, "scan directory entry", error)
+ })?;
+ let name = entry.file_name();
+ let kind = entry.file_type().map_err(|error| {
+ inventory_io(
+ root,
+ &relative_parent.join(&name),
+ "inspect directory entry",
+ error,
+ )
+ })?;
+ Ok((name, kind))
+ })
+ .collect::>>()?;
+ children.sort_by(|left, right| left.0.cmp(&right.0));
+
+ for (name, file_type) in children {
+ let relative = relative_parent.join(&name);
+ let kind = if file_type.is_symlink() {
+ InventoryEntryKind::Symlink
+ } else if file_type.is_dir() {
+ InventoryEntryKind::Directory
+ } else if file_type.is_file() {
+ InventoryEntryKind::File
+ } else {
+ InventoryEntryKind::Unsupported
+ };
+ let normalized = normalized_inventory_path(root, &relative)?;
+ let ignored = kind == InventoryEntryKind::Directory
+ && normalized
+ .file_name()
+ .is_some_and(is_ignored_inventory_directory);
+ entries.push((normalized, kind));
+ if kind == InventoryEntryKind::Directory && !ignored {
+ let child = directory
+ .open_dir_nofollow(&name)
+ .map_err(|error| inventory_io(root, &relative, "open directory", error))?;
+ scan_windows_inventory_directory(&child, &relative, root, entries)?;
+ }
+ }
+ Ok(())
+}
+
+#[cfg(not(any(unix, windows)))]
+fn scan_prepared_inventory(root: &PinnedRoot) -> ProjectResult {
+ root.validate().map_err(|error| {
+ inventory_io(&root.path, Path::new(""), "validate adoption root", error)
+ })?;
+ ProjectInventory::scan(&root.path)
+}
+
+#[derive(Debug)]
+pub(crate) enum PreparedWriteTarget {
+ NewChild {
+ parent: PreparedWriteRoot,
+ child: OsString,
+ },
+ Existing(PreparedWriteRoot),
+}
+
+impl PreparedWriteTarget {
+ pub(crate) fn new_child(parent: PreparedWriteRoot, child: impl Into) -> Self {
+ Self::NewChild {
+ parent,
+ child: child.into(),
+ }
+ }
+
+ pub(crate) fn existing(root: PreparedWriteRoot) -> Self {
+ Self::Existing(root)
+ }
+
+ fn policy(&self) -> RootPolicy {
+ match self {
+ Self::NewChild { .. } => RootPolicy::NewDestination,
+ Self::Existing(_) => RootPolicy::ExistingAdoptionRoot,
+ }
+ }
+}
+
+/// The operation that failed while applying a plan.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum ApplyStage {
+ ValidatePolicy,
+ ValidateRoot,
+ CreateRoot,
+ CreateDirectory,
+ CreateFile,
+ RecordOwnership,
+ WriteFile,
+}
+
+impl fmt::Display for ApplyStage {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let description = match self {
+ Self::ValidatePolicy => "validate write-plan policy",
+ Self::ValidateRoot => "validate project root",
+ Self::CreateRoot => "create project root",
+ Self::CreateDirectory => "create project directory",
+ Self::CreateFile => "create project file",
+ Self::RecordOwnership => "record project path ownership",
+ Self::WriteFile => "write project file",
+ };
+ f.write_str(description)
+ }
+}
+
+/// Kind of invocation-created path considered during rollback.
+#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
+pub enum CreatedPathKind {
+ File,
+ Directory,
+}
+
+/// One invocation-created path that could not be removed during rollback.
+#[derive(Debug)]
+pub struct RollbackResidual {
+ path: PathBuf,
+ kind: CreatedPathKind,
+ error: io::Error,
+}
+
+impl RollbackResidual {
+ pub fn path(&self) -> &Path {
+ &self.path
+ }
+
+ pub fn kind(&self) -> CreatedPathKind {
+ self.kind
+ }
+
+ pub fn error(&self) -> &io::Error {
+ &self.error
+ }
+}
+
+/// Result of the best-effort rollback performed after an apply failure.
+#[derive(Debug, Default)]
+pub struct RollbackReport {
+ residuals: Vec,
+}
+
+impl RollbackReport {
+ /// True when every invocation-created path is gone.
+ pub fn is_complete(&self) -> bool {
+ self.residuals.is_empty()
+ }
+
+ /// Invocation-created paths that still exist or could not be inspected.
+ pub fn residuals(&self) -> &[RollbackResidual] {
+ &self.residuals
+ }
+}
+
+/// Successful filesystem effects of one plan application.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ApplySummary {
+ root: PathBuf,
+ created_files: Vec,
+ created_directories: Vec,
+}
+
+impl ApplySummary {
+ pub fn root(&self) -> &Path {
+ &self.root
+ }
+
+ /// Files in creation order. `spock.toml` is always last.
+ pub fn created_files(&self) -> &[PathBuf] {
+ &self.created_files
+ }
+
+ /// Directories in parent-before-child creation order.
+ pub fn created_directories(&self) -> &[PathBuf] {
+ &self.created_directories
+ }
+}
+
+/// A failed plan application together with its rollback outcome.
+#[derive(Debug)]
+pub struct ApplyError {
+ stage: ApplyStage,
+ path: PathBuf,
+ source: io::Error,
+ rollback: RollbackReport,
+}
+
+impl ApplyError {
+ pub fn stage(&self) -> ApplyStage {
+ self.stage
+ }
+
+ pub fn path(&self) -> &Path {
+ &self.path
+ }
+
+ pub fn io_error(&self) -> &io::Error {
+ &self.source
+ }
+
+ pub fn rollback(&self) -> &RollbackReport {
+ &self.rollback
+ }
+}
+
+impl fmt::Display for ApplyError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(
+ f,
+ "could not {} `{}`: {}",
+ self.stage,
+ self.path.display(),
+ self.source
+ )?;
+ if !self.rollback.is_complete() {
+ write!(f, "; rollback left")?;
+ for residual in self.rollback.residuals() {
+ write!(f, " `{}` ({})", residual.path().display(), residual.error())?;
+ }
+ }
+ Ok(())
+ }
+}
+
+impl std::error::Error for ApplyError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ Some(&self.source)
+ }
+}
+
+/// Apply a pure project write plan without overwriting any existing file.
+///
+/// Non-manifest files are created first. `spock.toml` is created last so its
+/// presence means all preceding template writes succeeded. A later conflict
+/// triggers a platform-specific best-effort rollback. Unix removes exact
+/// invocation-owned files through retained parent handles and reports created
+/// directories as residuals. Windows performs no rollback mutation and reports
+/// every known creation as a residual. These conservative rules avoid deleting
+/// or overwriting an entry concurrently installed under a created name.
+#[cfg(test)]
+fn apply_write_plan(plan: &WritePlan, root_policy: RootPolicy) -> Result {
+ apply_write_plan_inner(plan, root_policy, |_| {})
+}
+
+pub(crate) fn apply_prepared_write_plan(
+ plan: &WritePlan,
+ target: PreparedWriteTarget,
+) -> Result {
+ let policy = target.policy();
+ apply_write_plan_inner_with_target(plan, policy, Some(target), |_| {})
+}
+
+#[cfg(test)]
+fn apply_write_plan_inner(
+ plan: &WritePlan,
+ root_policy: RootPolicy,
+ after_write: F,
+) -> Result
+where
+ F: FnMut(&Path),
+{
+ apply_write_plan_inner_with_target(plan, root_policy, None, after_write)
+}
+
+fn apply_write_plan_inner_with_target(
+ plan: &WritePlan,
+ root_policy: RootPolicy,
+ prepared_target: Option,
+ mut after_write: F,
+) -> Result
+where
+ F: FnMut(&Path),
+{
+ let mut journal = CreationJournal::default();
+
+ if !root_policy.accepts(plan.kind) {
+ let source = io::Error::new(
+ io::ErrorKind::InvalidInput,
+ format!(
+ "root policy {root_policy:?} does not accept a {:?} plan",
+ plan.kind
+ ),
+ );
+ return Err(failed(
+ ApplyStage::ValidatePolicy,
+ &plan.root,
+ source,
+ journal,
+ ));
+ }
+
+ let root = match prepare_root(&plan.root, root_policy, prepared_target, &mut journal) {
+ Ok(root) => root,
+ Err(failure) => {
+ return Err(failed(failure.stage, failure.path, failure.error, journal));
+ }
+ };
+ let mut directory_cache = DirectoryCache::new();
+
+ let mut writes = plan.writes().iter().collect::>();
+ writes.sort_by(|left, right| {
+ let left_is_manifest = left.relative_path.as_str() == MANIFEST_FILE;
+ let right_is_manifest = right.relative_path.as_str() == MANIFEST_FILE;
+ left_is_manifest
+ .cmp(&right_is_manifest)
+ .then_with(|| left.relative_path.cmp(&right.relative_path))
+ });
+
+ for write in writes {
+ let relative_parent = write
+ .relative_path
+ .as_path()
+ .parent()
+ .expect("a planned file always has a parent");
+ let parent = match ensure_relative_directories(
+ &root,
+ relative_parent,
+ &mut journal,
+ &mut directory_cache,
+ ) {
+ Ok(parent) => parent,
+ Err(failure) => {
+ return Err(failed(failure.stage, failure.path, failure.error, journal));
+ }
+ };
+
+ let destination = plan.root.join(write.relative_path.as_path());
+ let file_name = write
+ .relative_path
+ .as_path()
+ .file_name()
+ .expect("a planned file has a file name");
+ let (mut file, anchor) = match parent.create_new_file(file_name) {
+ Ok(created) => created,
+ Err(error) => {
+ return Err(failed(ApplyStage::CreateFile, destination, error, journal));
+ }
+ };
+ // Retain an open handle until success or rollback. Comparing a current
+ // directory entry to this live file object avoids inode/file-index
+ // reuse after an editor atomically replaces the path.
+ let identity = match journal_identity_from_file(&file) {
+ Ok(identity) => identity,
+ Err(error) => {
+ journal
+ .files
+ .push(JournalEntry::file(destination.clone(), file, None, anchor));
+ return Err(failed(
+ ApplyStage::RecordOwnership,
+ destination,
+ error,
+ journal,
+ ));
+ }
+ };
+
+ if let Err(error) = file.write_all(&write.contents) {
+ journal.files.push(JournalEntry::file(
+ destination.clone(),
+ file,
+ identity,
+ anchor,
+ ));
+ return Err(failed(ApplyStage::WriteFile, destination, error, journal));
+ }
+ journal.files.push(JournalEntry::file(
+ destination.clone(),
+ file,
+ identity,
+ anchor,
+ ));
+ after_write(&destination);
+ }
+
+ if let Err(error) = root.validate() {
+ return Err(failed(ApplyStage::ValidateRoot, &plan.root, error, journal));
+ }
+
+ let CreationJournal { files, directories } = journal;
+ Ok(ApplySummary {
+ root: plan.root.clone(),
+ created_files: files.into_iter().map(|entry| entry.path).collect(),
+ created_directories: directories.into_iter().map(|entry| entry.path).collect(),
+ })
+}
+
+#[derive(Debug)]
+struct OperationFailure {
+ stage: ApplyStage,
+ path: PathBuf,
+ error: io::Error,
+}
+
+fn prepare_root(
+ root: &Path,
+ root_policy: RootPolicy,
+ prepared_target: Option,
+ journal: &mut CreationJournal,
+) -> Result {
+ if let Some(prepared_target) = prepared_target {
+ return match prepared_target {
+ PreparedWriteTarget::Existing(PreparedWriteRoot(prepared)) => {
+ if root_policy != RootPolicy::ExistingAdoptionRoot || prepared.path != root {
+ return Err(prepared_target_mismatch(root));
+ }
+ prepared.validate().map_err(|error| OperationFailure {
+ stage: ApplyStage::ValidateRoot,
+ path: root.to_path_buf(),
+ error,
+ })?;
+ Ok(prepared)
+ }
+ PreparedWriteTarget::NewChild {
+ parent: PreparedWriteRoot(parent),
+ child,
+ } => {
+ let expected = parent.path.join(&child);
+ let mut components = Path::new(&child).components();
+ let is_normal_child = matches!(
+ (components.next(), components.next()),
+ (Some(std::path::Component::Normal(_)), None)
+ );
+ if root_policy != RootPolicy::NewDestination || expected != root || !is_normal_child
+ {
+ return Err(prepared_target_mismatch(root));
+ }
+ create_new_root_from_prepared_parent(root, &child, parent, journal)
+ }
+ };
+ }
+
+ match root_policy {
+ RootPolicy::NewDestination => create_new_root(root, journal),
+ RootPolicy::ExistingAdoptionRoot => {
+ validate_existing_root(root)?;
+ PinnedRoot::open(root).map_err(|error| OperationFailure {
+ stage: ApplyStage::ValidateRoot,
+ path: root.to_path_buf(),
+ error,
+ })
+ }
+ }
+}
+
+fn prepared_target_mismatch(root: &Path) -> OperationFailure {
+ OperationFailure {
+ stage: ApplyStage::ValidatePolicy,
+ path: root.to_path_buf(),
+ error: io::Error::new(
+ io::ErrorKind::InvalidInput,
+ "prepared filesystem target does not match the write plan root and kind",
+ ),
+ }
+}
+
+#[cfg(windows)]
+fn create_new_root_from_prepared_parent(
+ root: &Path,
+ child: &OsStr,
+ parent: PinnedRoot,
+ journal: &mut CreationJournal,
+) -> Result {
+ parent.validate().map_err(|error| OperationFailure {
+ stage: ApplyStage::ValidateRoot,
+ path: parent.path.clone(),
+ error,
+ })?;
+ let created = create_windows_directory_at(&parent.directory, child).map_err(|error| {
+ OperationFailure {
+ stage: ApplyStage::CreateRoot,
+ path: root.to_path_buf(),
+ error,
+ }
+ })?;
+ let directory = retain_windows_created_directory(
+ &parent.directory,
+ child,
+ root.to_path_buf(),
+ created,
+ journal,
+ )?;
+ let identity =
+ windows_identity_from_directory(&directory).map_err(|error| OperationFailure {
+ stage: ApplyStage::RecordOwnership,
+ path: root.to_path_buf(),
+ error,
+ })?;
+ let PinnedRoot {
+ directory: parent_directory,
+ mut ancestor_guards,
+ ..
+ } = parent;
+ ancestor_guards.push(parent_directory);
+ let pinned = PinnedRoot {
+ directory,
+ identity,
+ path: root.to_path_buf(),
+ ancestor_guards,
+ };
+ pinned.validate().map_err(|error| OperationFailure {
+ stage: ApplyStage::ValidateRoot,
+ path: root.to_path_buf(),
+ error,
+ })?;
+ Ok(pinned)
+}
+
+#[cfg(unix)]
+fn create_new_root_from_prepared_parent(
+ root: &Path,
+ child: &OsStr,
+ parent: PinnedRoot,
+ journal: &mut CreationJournal,
+) -> Result {
+ parent.validate().map_err(|error| OperationFailure {
+ stage: ApplyStage::ValidateRoot,
+ path: parent.path.clone(),
+ error,
+ })?;
+ create_directory_at(&parent.directory, child).map_err(|error| OperationFailure {
+ stage: ApplyStage::CreateRoot,
+ path: root.to_path_buf(),
+ error,
+ })?;
+ // Journal immediately after mkdirat. The retained child handle is opened
+ // in a second syscall, so rollback must preserve this directory even when
+ // that open or any following validation fails.
+ journal
+ .directories
+ .push(JournalEntry::directory_without_identity(
+ root.to_path_buf(),
+ None,
+ ));
+ let directory =
+ open_directory_at(&parent.directory, child).map_err(|error| OperationFailure {
+ stage: ApplyStage::CreateRoot,
+ path: root.to_path_buf(),
+ error,
+ })?;
+ parent.validate().map_err(|error| OperationFailure {
+ stage: ApplyStage::ValidateRoot,
+ path: parent.path.clone(),
+ error,
+ })?;
+ let metadata = directory.metadata().map_err(|error| OperationFailure {
+ stage: ApplyStage::RecordOwnership,
+ path: root.to_path_buf(),
+ error,
+ })?;
+ let identity = entry_identity(&metadata).expect("Unix directory identity");
+ let pinned = PinnedRoot {
+ directory,
+ identity,
+ path: root.to_path_buf(),
+ };
+ pinned.validate().map_err(|error| OperationFailure {
+ stage: ApplyStage::ValidateRoot,
+ path: root.to_path_buf(),
+ error,
+ })?;
+ Ok(pinned)
+}
+
+#[cfg(not(any(unix, windows)))]
+fn create_new_root_from_prepared_parent(
+ root: &Path,
+ _child: &OsStr,
+ parent: PinnedRoot,
+ journal: &mut CreationJournal,
+) -> Result {
+ parent.validate().map_err(|error| OperationFailure {
+ stage: ApplyStage::ValidateRoot,
+ path: parent.path.clone(),
+ error,
+ })?;
+ let result = create_new_root(root, journal);
+ parent.validate().map_err(|error| OperationFailure {
+ stage: ApplyStage::ValidateRoot,
+ path: parent.path.clone(),
+ error,
+ })?;
+ result
+}
+
+#[cfg(not(windows))]
+fn create_new_root(
+ root: &Path,
+ journal: &mut CreationJournal,
+) -> Result {
+ match fs::symlink_metadata(root) {
+ Ok(_) => {
+ return Err(OperationFailure {
+ stage: ApplyStage::CreateRoot,
+ path: root.to_path_buf(),
+ error: io::Error::new(
+ io::ErrorKind::AlreadyExists,
+ "new project destination already exists",
+ ),
+ });
+ }
+ Err(error) if error.kind() == io::ErrorKind::NotFound => {}
+ Err(error) => {
+ return Err(OperationFailure {
+ stage: ApplyStage::CreateRoot,
+ path: root.to_path_buf(),
+ error,
+ });
+ }
+ }
+
+ if let Some(parent) = nonempty_parent(root) {
+ ensure_directory_tree(parent, journal)?;
+ }
+
+ fs::create_dir(root).map_err(|error| OperationFailure {
+ stage: ApplyStage::CreateRoot,
+ path: root.to_path_buf(),
+ error,
+ })?;
+ record_created_directory(root, journal)?;
+ PinnedRoot::open(root).map_err(|error| OperationFailure {
+ stage: ApplyStage::ValidateRoot,
+ path: root.to_path_buf(),
+ error,
+ })
+}
+
+#[cfg(windows)]
+fn create_new_root(
+ root: &Path,
+ journal: &mut CreationJournal,
+) -> Result {
+ match fs::symlink_metadata(root) {
+ Ok(_) => {
+ return Err(OperationFailure {
+ stage: ApplyStage::CreateRoot,
+ path: root.to_path_buf(),
+ error: io::Error::new(
+ io::ErrorKind::AlreadyExists,
+ "new project destination already exists",
+ ),
+ });
+ }
+ Err(error) if error.kind() == io::ErrorKind::NotFound => {}
+ Err(error) => {
+ return Err(OperationFailure {
+ stage: ApplyStage::CreateRoot,
+ path: root.to_path_buf(),
+ error,
+ });
+ }
+ }
+
+ // Walk upward without mutating until one real ancestor can be pinned.
+ // Every missing component is then created relative to the retained handle;
+ // the newly created root handle itself becomes `PinnedRoot` and is never
+ // reopened through its mutable pathname.
+ let mut missing = Vec::<(PathBuf, OsString)>::new();
+ let mut existing = root.to_path_buf();
+ loop {
+ match fs::symlink_metadata(&existing) {
+ Ok(metadata) => {
+ require_real_directory(&existing, &metadata)?;
+ break;
+ }
+ Err(error) if error.kind() == io::ErrorKind::NotFound => {
+ let name = existing.file_name().ok_or_else(|| OperationFailure {
+ stage: ApplyStage::CreateRoot,
+ path: root.to_path_buf(),
+ error: io::Error::new(
+ io::ErrorKind::InvalidInput,
+ "new project destination has no creatable path component",
+ ),
+ })?;
+ missing.push((existing.clone(), name.to_os_string()));
+ existing = existing
+ .parent()
+ .filter(|parent| !parent.as_os_str().is_empty())
+ .map(Path::to_path_buf)
+ .unwrap_or_else(|| PathBuf::from("."));
+ }
+ Err(error) => {
+ return Err(OperationFailure {
+ stage: ApplyStage::CreateDirectory,
+ path: existing,
+ error,
+ });
+ }
+ }
+ }
+
+ let pinned_ancestor = PinnedRoot::open(&existing).map_err(|error| OperationFailure {
+ stage: ApplyStage::ValidateRoot,
+ path: existing.clone(),
+ error,
+ })?;
+ let PinnedRoot {
+ directory: mut current,
+ mut ancestor_guards,
+ ..
+ } = pinned_ancestor;
+
+ let missing_count = missing.len();
+ for (index, (path, name)) in missing.into_iter().rev().enumerate() {
+ ancestor_guards.push(current.try_clone().map_err(|error| OperationFailure {
+ stage: ApplyStage::RecordOwnership,
+ path: path.clone(),
+ error,
+ })?);
+ let created = match create_windows_directory_at(¤t, &name) {
+ Ok(created) => created,
+ Err(error)
+ if error.kind() == io::ErrorKind::AlreadyExists && index + 1 != missing_count =>
+ {
+ use cap_fs_ext::DirExt as _;
+
+ current = current
+ .open_dir_nofollow(&name)
+ .map_err(|error| OperationFailure {
+ stage: ApplyStage::CreateDirectory,
+ path,
+ error,
+ })?;
+ continue;
+ }
+ Err(error) => {
+ return Err(OperationFailure {
+ stage: if index + 1 == missing_count {
+ ApplyStage::CreateRoot
+ } else {
+ ApplyStage::CreateDirectory
+ },
+ path,
+ error,
+ });
+ }
+ };
+ current = retain_windows_created_directory(¤t, &name, path, created, journal)?;
+ }
+
+ let identity = windows_identity_from_directory(¤t).map_err(|error| OperationFailure {
+ stage: ApplyStage::RecordOwnership,
+ path: root.to_path_buf(),
+ error,
+ })?;
+ let pinned = PinnedRoot {
+ directory: current,
+ identity,
+ path: root.to_path_buf(),
+ ancestor_guards,
+ };
+ pinned.validate().map_err(|error| OperationFailure {
+ stage: ApplyStage::ValidateRoot,
+ path: root.to_path_buf(),
+ error,
+ })?;
+ Ok(pinned)
+}
+
+fn validate_existing_root(root: &Path) -> Result<(), OperationFailure> {
+ let metadata = fs::symlink_metadata(root).map_err(|error| OperationFailure {
+ stage: ApplyStage::ValidateRoot,
+ path: root.to_path_buf(),
+ error,
+ })?;
+ if !metadata.file_type().is_dir() {
+ return Err(OperationFailure {
+ stage: ApplyStage::ValidateRoot,
+ path: root.to_path_buf(),
+ error: io::Error::new(
+ io::ErrorKind::NotADirectory,
+ "adoption root is not a real directory",
+ ),
+ });
+ }
+ Ok(())
+}
+
+#[cfg(not(windows))]
+fn nonempty_parent(path: &Path) -> Option<&Path> {
+ path.parent()
+ .filter(|parent| !parent.as_os_str().is_empty())
+}
+
+/// Ensure a possibly-outside-root ancestor path for a new destination.
+/// Existing ancestors terminate recursion; only directories created while
+/// unwinding are journaled.
+#[cfg(not(windows))]
+fn ensure_directory_tree(
+ directory: &Path,
+ journal: &mut CreationJournal,
+) -> Result<(), OperationFailure> {
+ match fs::symlink_metadata(directory) {
+ Ok(metadata) => return require_real_directory(directory, &metadata),
+ Err(error) if error.kind() == io::ErrorKind::NotFound => {}
+ Err(error) => {
+ return Err(OperationFailure {
+ stage: ApplyStage::CreateDirectory,
+ path: directory.to_path_buf(),
+ error,
+ });
+ }
+ }
+
+ if let Some(parent) = nonempty_parent(directory) {
+ ensure_directory_tree(parent, journal)?;
+ }
+ create_directory_if_missing(directory, journal)
+}
+
+#[cfg(unix)]
+#[derive(Debug)]
+struct PinnedRoot {
+ directory: fs::File,
+ identity: EntryIdentity,
+ path: PathBuf,
+}
+
+#[cfg(not(windows))]
+#[derive(Debug)]
+struct DirectoryCache;
+
+#[cfg(not(windows))]
+impl DirectoryCache {
+ fn new() -> Self {
+ Self
+ }
+}
+
+#[cfg(windows)]
+#[derive(Debug, Default)]
+struct DirectoryCache {
+ directories: BTreeMap,
+}
+
+#[cfg(windows)]
+impl DirectoryCache {
+ fn new() -> Self {
+ Self::default()
+ }
+}
+
+#[cfg(unix)]
+impl PinnedRoot {
+ fn open(path: &Path) -> io::Result {
+ let directory = open_directory_path(path)?;
+ let identity = entry_identity(&directory.metadata()?).expect("Unix directory identity");
+ let root = Self {
+ directory,
+ identity,
+ path: path.to_path_buf(),
+ };
+ root.validate()?;
+ Ok(root)
+ }
+
+ fn validate(&self) -> io::Result<()> {
+ let metadata = fs::symlink_metadata(&self.path)?;
+ if !metadata.file_type().is_dir()
+ || entry_identity(&metadata) != Some(self.identity)
+ || entry_identity(&self.directory.metadata()?) != Some(self.identity)
+ {
+ return Err(io::Error::new(
+ io::ErrorKind::AlreadyExists,
+ "project root identity changed while applying the write plan",
+ ));
+ }
+ Ok(())
+ }
+
+ fn cursor(&self) -> io::Result {
+ self.validate()?;
+ Ok(DirectoryCursor {
+ directory: self.directory.try_clone()?,
+ path: self.path.clone(),
+ })
+ }
+}
+
+#[cfg(unix)]
+#[derive(Debug)]
+struct DirectoryCursor {
+ directory: fs::File,
+ path: PathBuf,
+}
+
+#[cfg(unix)]
+impl DirectoryCursor {
+ fn create_new_file(&self, name: &OsStr) -> io::Result<(fs::File, Option)> {
+ // Duplicate the parent capability before the mutating openat. If the
+ // process is out of descriptors, fail before creating a file that the
+ // caller cannot journal through its retained parent anchor.
+ let parent = self.directory.try_clone()?;
+ let descriptor = rustix::fs::openat(
+ &self.directory,
+ name,
+ rustix::fs::OFlags::WRONLY
+ | rustix::fs::OFlags::CREATE
+ | rustix::fs::OFlags::EXCL
+ | rustix::fs::OFlags::NOFOLLOW
+ | rustix::fs::OFlags::CLOEXEC,
+ rustix::fs::Mode::from_raw_mode(0o666),
+ )?;
+ let file = fs::File::from(descriptor);
+ let anchor = PathAnchor {
+ parent,
+ name: name.to_os_string(),
+ };
+ Ok((file, Some(anchor)))
+ }
+}
+
+#[cfg(windows)]
+#[derive(Debug)]
+struct PinnedRoot {
+ directory: cap_std::fs::Dir,
+ identity: EntryIdentity,
+ path: PathBuf,
+ // `spock new` may create a missing chain below the nearest existing
+ // ancestor. Keep every ancestor locked until the plan commits so moving a
+ // parent cannot carry the pinned project outside the requested pathname.
+ ancestor_guards: Vec,
+}
+
+#[cfg(windows)]
+impl PinnedRoot {
+ fn open(path: &Path) -> io::Result {
+ let metadata = fs::symlink_metadata(path)?;
+ if !metadata.file_type().is_dir() {
+ return Err(io::Error::new(
+ io::ErrorKind::NotADirectory,
+ "project root is not a real directory",
+ ));
+ }
+ let directory = cap_std::fs::Dir::from_std_file(open_directory_path(path)?);
+ let identity = windows_identity_from_directory(&directory)?;
+ let root = Self {
+ directory,
+ identity,
+ path: path.to_path_buf(),
+ ancestor_guards: Vec::new(),
+ };
+ root.validate()?;
+ Ok(root)
+ }
+
+ fn validate(&self) -> io::Result<()> {
+ let metadata = fs::symlink_metadata(&self.path)?;
+ if !metadata.file_type().is_dir()
+ || !windows_path_matches_identity(&self.path, &self.identity)?
+ || windows_identity_from_directory(&self.directory)? != self.identity
+ {
+ return Err(io::Error::new(
+ io::ErrorKind::AlreadyExists,
+ "project root identity changed while applying the write plan",
+ ));
+ }
+ Ok(())
+ }
+
+ fn cursor(&self) -> io::Result {
+ self.validate()?;
+ let root = self.directory.try_clone()?;
+ Ok(DirectoryCursor {
+ directory: root.try_clone()?,
+ root_identity: windows_identity_from_directory(&root)?,
+ root,
+ root_path: self.path.clone(),
+ })
+ }
+}
+
+#[cfg(windows)]
+#[derive(Debug)]
+struct DirectoryCursor {
+ directory: cap_std::fs::Dir,
+ root: cap_std::fs::Dir,
+ root_identity: EntryIdentity,
+ root_path: PathBuf,
+}
+
+#[cfg(windows)]
+impl DirectoryCursor {
+ fn validate_root(&self) -> io::Result<()> {
+ let metadata = fs::symlink_metadata(&self.root_path)?;
+ if !metadata.file_type().is_dir()
+ || !windows_path_matches_identity(&self.root_path, &self.root_identity)?
+ || windows_identity_from_directory(&self.root)? != self.root_identity
+ {
+ return Err(io::Error::new(
+ io::ErrorKind::AlreadyExists,
+ "project root identity changed while applying the write plan",
+ ));
+ }
+ Ok(())
+ }
+
+ fn create_new_file(&self, name: &OsStr) -> io::Result<(fs::File, Option)> {
+ use cap_fs_ext::{FollowSymlinks, OpenOptionsFollowExt};
+ use cap_std::fs::OpenOptionsExt as _;
+ use windows_sys::Win32::Storage::FileSystem::{
+ FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, FILE_SHARE_WRITE,
+ };
+
+ self.validate_root()?;
+ let mut options = cap_std::fs::OpenOptions::new();
+ options
+ .write(true)
+ .create_new(true)
+ .follow(FollowSymlinks::No)
+ // `create_new` already guarantees the final component did not
+ // exist. Supplying the no-follow flag explicitly prevents
+ // cap-std from performing a fallible metadata probe after the
+ // successful create syscall, so the returned handle can always be
+ // journaled by the caller before any later fallible operation.
+ .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
+ .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE);
+ let file = self.directory.open_with(name, &options)?.into_std();
+ Ok((file, None))
+ }
+}
+
+#[cfg(not(any(unix, windows)))]
+#[derive(Debug)]
+struct PinnedRoot {
+ directory: fs::File,
+ identity: Option,
+ path: PathBuf,
+}
+
+#[cfg(not(any(unix, windows)))]
+impl PinnedRoot {
+ fn open(path: &Path) -> io::Result {
+ let metadata = fs::symlink_metadata(path)?;
+ if !metadata.file_type().is_dir() {
+ return Err(io::Error::new(
+ io::ErrorKind::NotADirectory,
+ "project root is not a real directory",
+ ));
+ }
+ let directory = open_directory_path(path)?;
+ let identity = entry_identity(&directory.metadata()?);
+ let root = Self {
+ directory,
+ identity,
+ path: path.to_path_buf(),
+ };
+ root.validate()?;
+ Ok(root)
+ }
+
+ fn validate(&self) -> io::Result<()> {
+ let metadata = fs::symlink_metadata(&self.path)?;
+ if !metadata.file_type().is_dir()
+ || self.identity.is_none()
+ || entry_identity(&metadata) != self.identity
+ || entry_identity(&self.directory.metadata()?) != self.identity
+ {
+ return Err(io::Error::new(
+ io::ErrorKind::AlreadyExists,
+ "project root identity changed while applying the write plan",
+ ));
+ }
+ Ok(())
+ }
+
+ fn cursor(&self) -> io::Result {
+ self.validate()?;
+ Ok(DirectoryCursor {
+ root: self.directory.try_clone()?,
+ root_identity: self.identity,
+ root_path: self.path.clone(),
+ path: self.path.clone(),
+ })
+ }
+}
+
+#[cfg(not(any(unix, windows)))]
+#[derive(Debug)]
+struct DirectoryCursor {
+ root: fs::File,
+ root_identity: Option,
+ root_path: PathBuf,
+ path: PathBuf,
+}
+
+#[cfg(not(any(unix, windows)))]
+impl DirectoryCursor {
+ fn validate_root(&self) -> io::Result<()> {
+ let metadata = fs::symlink_metadata(&self.root_path)?;
+ if !metadata.file_type().is_dir()
+ || self.root_identity.is_none()
+ || entry_identity(&metadata) != self.root_identity
+ || entry_identity(&self.root.metadata()?) != self.root_identity
+ {
+ return Err(io::Error::new(
+ io::ErrorKind::AlreadyExists,
+ "project root identity changed while applying the write plan",
+ ));
+ }
+ Ok(())
+ }
+
+ fn create_new_file(&self, name: &OsStr) -> io::Result<(fs::File, Option)> {
+ self.validate_root()?;
+ let file = OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .open(self.path.join(name))?;
+ self.validate_root()?;
+ Ok((file, None))
+ }
+}
+
+#[cfg(unix)]
+fn ensure_relative_directories(
+ root: &PinnedRoot,
+ relative: &Path,
+ journal: &mut CreationJournal,
+ _cache: &mut DirectoryCache,
+) -> Result {
+ let mut current = root.cursor().map_err(|error| OperationFailure {
+ stage: ApplyStage::ValidateRoot,
+ path: root.path.clone(),
+ error,
+ })?;
+ for component in relative.components() {
+ let std::path::Component::Normal(segment) = component else {
+ return Err(invalid_planned_parent(&root.path, relative));
+ };
+ let path = current.path.join(segment);
+ match open_directory_at(¤t.directory, segment) {
+ Ok(directory) => current = DirectoryCursor { directory, path },
+ Err(error) if error.kind() == io::ErrorKind::NotFound => {
+ let created = match create_directory_at(¤t.directory, segment) {
+ Ok(()) => true,
+ Err(error) if error.kind() == io::ErrorKind::AlreadyExists => false,
+ Err(error) => {
+ return Err(OperationFailure {
+ stage: ApplyStage::CreateDirectory,
+ path,
+ error,
+ });
+ }
+ };
+ if created {
+ // `mkdirat` has already mutated the filesystem and does
+ // not return a handle. Record the logical creation before
+ // any fallible open or metadata call. Directory rollback
+ // is deliberately non-mutating because ownership cannot be
+ // proven across this gap.
+ journal
+ .directories
+ .push(JournalEntry::directory_without_identity(path.clone(), None));
+ }
+ let directory =
+ open_directory_at(¤t.directory, segment).map_err(|error| {
+ OperationFailure {
+ stage: ApplyStage::CreateDirectory,
+ path: path.clone(),
+ error,
+ }
+ })?;
+ current = DirectoryCursor { directory, path };
+ }
+ Err(error) => {
+ return Err(OperationFailure {
+ stage: ApplyStage::CreateDirectory,
+ path,
+ error,
+ });
+ }
+ }
+ }
+ Ok(current)
+}
+
+#[cfg(windows)]
+fn ensure_relative_directories(
+ root: &PinnedRoot,
+ relative: &Path,
+ journal: &mut CreationJournal,
+ cache: &mut DirectoryCache,
+) -> Result {
+ use cap_fs_ext::DirExt as _;
+
+ let mut current = root.cursor().map_err(|error| OperationFailure {
+ stage: ApplyStage::ValidateRoot,
+ path: root.path.clone(),
+ error,
+ })?;
+ let mut relative_cursor = PathBuf::new();
+ for component in relative.components() {
+ let std::path::Component::Normal(segment) = component else {
+ return Err(invalid_planned_parent(&root.path, relative));
+ };
+ relative_cursor.push(segment);
+ let path = root.path.join(&relative_cursor);
+
+ current.validate_root().map_err(|error| OperationFailure {
+ stage: ApplyStage::ValidateRoot,
+ path: root.path.clone(),
+ error,
+ })?;
+
+ let directory = if let Some(cached) = cache.directories.get(&relative_cursor) {
+ cached.try_clone().map_err(|error| OperationFailure {
+ stage: ApplyStage::RecordOwnership,
+ path: path.clone(),
+ error,
+ })?
+ } else {
+ let directory = match current.directory.open_dir_nofollow(segment) {
+ Ok(directory) => directory,
+ Err(error) if error.kind() == io::ErrorKind::NotFound => {
+ match create_windows_directory_at(¤t.directory, segment) {
+ Ok(created) => retain_windows_created_directory(
+ ¤t.directory,
+ segment,
+ path.clone(),
+ created,
+ journal,
+ )?,
+ Err(error) if error.kind() == io::ErrorKind::AlreadyExists => current
+ .directory
+ .open_dir_nofollow(segment)
+ .map_err(|error| OperationFailure {
+ stage: ApplyStage::CreateDirectory,
+ path: path.clone(),
+ error,
+ })?,
+ Err(error) => {
+ return Err(OperationFailure {
+ stage: ApplyStage::CreateDirectory,
+ path,
+ error,
+ });
+ }
+ }
+ }
+ Err(error) => {
+ return Err(OperationFailure {
+ stage: ApplyStage::CreateDirectory,
+ path,
+ error,
+ });
+ }
+ };
+ cache.directories.insert(
+ relative_cursor.clone(),
+ directory.try_clone().map_err(|error| OperationFailure {
+ stage: ApplyStage::RecordOwnership,
+ path: path.clone(),
+ error,
+ })?,
+ );
+ directory
+ };
+ current = DirectoryCursor {
+ directory,
+ root: current.root.try_clone().map_err(|error| OperationFailure {
+ stage: ApplyStage::RecordOwnership,
+ path: path.clone(),
+ error,
+ })?,
+ root_identity: windows_identity_from_directory(¤t.root).map_err(|error| {
+ OperationFailure {
+ stage: ApplyStage::RecordOwnership,
+ path: path.clone(),
+ error,
+ }
+ })?,
+ root_path: current.root_path.clone(),
+ };
+ }
+ Ok(current)
+}
+
+#[cfg(not(any(unix, windows)))]
+fn ensure_relative_directories(
+ root: &PinnedRoot,
+ relative: &Path,
+ journal: &mut CreationJournal,
+ _cache: &mut DirectoryCache,
+) -> Result {
+ let mut current = root.cursor().map_err(|error| OperationFailure {
+ stage: ApplyStage::ValidateRoot,
+ path: root.path.clone(),
+ error,
+ })?;
+ for component in relative.components() {
+ let std::path::Component::Normal(segment) = component else {
+ return Err(invalid_planned_parent(&root.path, relative));
+ };
+ current.validate_root().map_err(|error| OperationFailure {
+ stage: ApplyStage::ValidateRoot,
+ path: root.path.clone(),
+ error,
+ })?;
+ current.path.push(segment);
+ match fs::symlink_metadata(¤t.path) {
+ Ok(metadata) => require_real_directory(¤t.path, &metadata)?,
+ Err(error) if error.kind() == io::ErrorKind::NotFound => {
+ create_directory_if_missing(¤t.path, journal)?;
+ }
+ Err(error) => {
+ return Err(OperationFailure {
+ stage: ApplyStage::CreateDirectory,
+ path: current.path,
+ error,
+ });
+ }
+ }
+ }
+ Ok(current)
+}
+
+fn invalid_planned_parent(root: &Path, relative: &Path) -> OperationFailure {
+ OperationFailure {
+ stage: ApplyStage::CreateDirectory,
+ path: root.join(relative),
+ error: io::Error::new(
+ io::ErrorKind::InvalidInput,
+ "planned parent is not a normalized relative path",
+ ),
+ }
+}
+
+#[cfg(not(windows))]
+fn create_directory_if_missing(
+ directory: &Path,
+ journal: &mut CreationJournal,
+) -> Result<(), OperationFailure> {
+ match fs::create_dir(directory) {
+ Ok(()) => record_created_directory(directory, journal),
+ Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
+ // Another creator won the race. Accept only a real directory and
+ // do not journal it: this invocation does not own that path.
+ let metadata = fs::symlink_metadata(directory).map_err(|error| OperationFailure {
+ stage: ApplyStage::CreateDirectory,
+ path: directory.to_path_buf(),
+ error,
+ })?;
+ require_real_directory(directory, &metadata)
+ }
+ Err(error) => Err(OperationFailure {
+ stage: ApplyStage::CreateDirectory,
+ path: directory.to_path_buf(),
+ error,
+ }),
+ }
+}
+
+fn require_real_directory(
+ directory: &Path,
+ metadata: &fs::Metadata,
+) -> Result<(), OperationFailure> {
+ if metadata.file_type().is_dir() {
+ Ok(())
+ } else {
+ Err(OperationFailure {
+ stage: ApplyStage::CreateDirectory,
+ path: directory.to_path_buf(),
+ error: io::Error::new(
+ io::ErrorKind::NotADirectory,
+ "path exists but is not a real directory",
+ ),
+ })
+ }
+}
+
+#[cfg(unix)]
+fn open_directory_path(path: &Path) -> io::Result {
+ let descriptor = rustix::fs::openat(
+ rustix::fs::CWD,
+ path,
+ rustix::fs::OFlags::RDONLY
+ | rustix::fs::OFlags::DIRECTORY
+ | rustix::fs::OFlags::NOFOLLOW
+ | rustix::fs::OFlags::CLOEXEC,
+ rustix::fs::Mode::empty(),
+ )?;
+ Ok(fs::File::from(descriptor))
+}
+
+#[cfg(windows)]
+fn open_directory_path(path: &Path) -> io::Result {
+ open_windows_entry_path(path)
+}
+
+#[cfg(windows)]
+fn open_windows_entry_path(path: &Path) -> io::Result {
+ use std::os::windows::fs::OpenOptionsExt;
+ use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE};
+
+ const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
+ const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
+ OpenOptions::new()
+ .read(true)
+ // A retained directory handle is a filesystem capability on Windows.
+ // Denying delete sharing prevents the directory from being renamed or
+ // removed while descendant operations are resolved through it.
+ .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
+ .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
+ .open(path)
+}
+
+#[cfg(windows)]
+fn create_windows_directory_at(parent: &cap_std::fs::Dir, name: &OsStr) -> io::Result {
+ use fs_at::os::windows::OpenOptionsExt as _;
+ use windows_sys::Wdk::Storage::FileSystem::{FILE_DIRECTORY_FILE, FILE_OPEN_REPARSE_POINT};
+ use windows_sys::Win32::Storage::FileSystem::{
+ FILE_LIST_DIRECTORY, FILE_READ_ATTRIBUTES, FILE_TRAVERSE, FILE_WRITE_ATTRIBUTES,
+ };
+
+ let parent = parent.try_clone()?.into_std_file();
+ let mut options = fs_at::OpenOptions::default();
+ options
+ .create_new(true)
+ .desired_access(
+ FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | FILE_TRAVERSE | FILE_WRITE_ATTRIBUTES,
+ )
+ // `mkdir_at` performs a second reparse probe after creating on
+ // Windows. Requesting FILE_CREATE through `open_at` gives us the
+ // created directory handle in one operation, so every mutation can be
+ // journaled even if later validation fails.
+ .create_options(FILE_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT);
+ options.open_at(&parent, name)
+}
+
+#[cfg(windows)]
+fn retain_windows_created_directory(
+ parent: &cap_std::fs::Dir,
+ name: &OsStr,
+ path: PathBuf,
+ created: fs::File,
+ journal: &mut CreationJournal,
+) -> Result {
+ use cap_fs_ext::DirExt as _;
+
+ let identity = match windows_identity_from_file(&created) {
+ Ok(identity) => identity,
+ Err(error) => {
+ journal
+ .directories
+ .push(JournalEntry::directory_without_identity_with_live_file(
+ path.clone(),
+ None,
+ created,
+ ));
+ return Err(OperationFailure {
+ stage: ApplyStage::RecordOwnership,
+ path,
+ error,
+ });
+ }
+ };
+ let locked = match parent.open_dir_nofollow(name) {
+ Ok(locked) => locked,
+ Err(error) => {
+ journal
+ .directories
+ .push(JournalEntry::directory_with_live_file(
+ path.clone(),
+ Some(identity),
+ None,
+ created,
+ ));
+ return Err(OperationFailure {
+ stage: ApplyStage::RecordOwnership,
+ path,
+ error,
+ });
+ }
+ };
+ let locked_identity = match windows_identity_from_directory(&locked) {
+ Ok(identity) => identity,
+ Err(error) => {
+ journal
+ .directories
+ .push(JournalEntry::directory_with_live_file(
+ path.clone(),
+ Some(identity),
+ None,
+ created,
+ ));
+ return Err(OperationFailure {
+ stage: ApplyStage::RecordOwnership,
+ path,
+ error,
+ });
+ }
+ };
+ if locked_identity != identity {
+ journal
+ .directories
+ .push(JournalEntry::directory_with_live_file(
+ path.clone(),
+ Some(identity),
+ None,
+ created,
+ ));
+ return Err(OperationFailure {
+ stage: ApplyStage::RecordOwnership,
+ path,
+ error: io::Error::new(
+ io::ErrorKind::AlreadyExists,
+ "created directory identity changed before it could be pinned",
+ ),
+ });
+ }
+
+ let live = match locked.try_clone().map(cap_std::fs::Dir::into_std_file) {
+ Ok(live) => live,
+ Err(error) => {
+ journal
+ .directories
+ .push(JournalEntry::directory_with_live_file(
+ path.clone(),
+ Some(identity),
+ None,
+ created,
+ ));
+ return Err(OperationFailure {
+ stage: ApplyStage::RecordOwnership,
+ path,
+ error,
+ });
+ }
+ };
+ journal
+ .directories
+ .push(JournalEntry::directory_with_live_file(
+ path,
+ Some(identity),
+ None,
+ live,
+ ));
+ Ok(locked)
+}
+
+#[cfg(not(any(unix, windows)))]
+fn open_directory_path(path: &Path) -> io::Result {
+ fs::File::open(path)
+}
+
+#[cfg(unix)]
+fn open_directory_at(parent: &fs::File, name: &OsStr) -> io::Result {
+ let descriptor = rustix::fs::openat(
+ parent,
+ name,
+ rustix::fs::OFlags::RDONLY
+ | rustix::fs::OFlags::DIRECTORY
+ | rustix::fs::OFlags::NOFOLLOW
+ | rustix::fs::OFlags::CLOEXEC,
+ rustix::fs::Mode::empty(),
+ )?;
+ Ok(fs::File::from(descriptor))
+}
+
+#[cfg(unix)]
+fn create_directory_at(parent: &fs::File, name: &OsStr) -> io::Result<()> {
+ Ok(rustix::fs::mkdirat(
+ parent,
+ name,
+ rustix::fs::Mode::from_raw_mode(0o777),
+ )?)
+}
+
+#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))]
+fn rename_noreplace_at(
+ from_parent: &fs::File,
+ from: &OsStr,
+ to_parent: &fs::File,
+ to: &OsStr,
+) -> io::Result<()> {
+ Ok(rustix::fs::renameat_with(
+ from_parent,
+ from,
+ to_parent,
+ to,
+ rustix::fs::RenameFlags::NOREPLACE,
+ )?)
+}
+
+#[cfg(all(
+ unix,
+ not(any(target_os = "linux", target_os = "android", target_os = "macos"))
+))]
+fn rename_noreplace_at(
+ _from_parent: &fs::File,
+ _from: &OsStr,
+ _to_parent: &fs::File,
+ _to: &OsStr,
+) -> io::Result<()> {
+ Err(io::Error::new(
+ io::ErrorKind::Unsupported,
+ "atomic no-replace restore is unavailable on this Unix platform",
+ ))
+}
+
+#[cfg(unix)]
+fn identity_at(parent: &fs::File, name: &OsStr) -> io::Result {
+ let metadata = rustix::fs::statat(parent, name, rustix::fs::AtFlags::SYMLINK_NOFOLLOW)?;
+ Ok(EntryIdentity {
+ volume: stat_identity_part(metadata.st_dev)?,
+ file: stat_identity_part(metadata.st_ino)?,
+ })
+}
+
+#[cfg(unix)]
+fn stat_identity_part(value: T) -> io::Result
+where
+ T: TryInto,
+ T::Error: fmt::Display,
+{
+ value.try_into().map_err(|error| {
+ io::Error::new(
+ io::ErrorKind::InvalidData,
+ format!("filesystem identity does not fit u64: {error}"),
+ )
+ })
+}
+
+#[cfg(unix)]
+fn unlink_at(parent: &fs::File, name: &OsStr, kind: CreatedPathKind) -> io::Result<()> {
+ let flags = match kind {
+ CreatedPathKind::File => rustix::fs::AtFlags::empty(),
+ CreatedPathKind::Directory => rustix::fs::AtFlags::REMOVEDIR,
+ };
+ Ok(rustix::fs::unlinkat(parent, name, flags)?)
+}
+
+#[derive(Debug)]
+struct PathAnchor {
+ #[cfg(unix)]
+ parent: fs::File,
+ #[cfg(unix)]
+ name: OsString,
+}
+
+#[cfg(unix)]
+fn path_anchor(path: &Path) -> io::Result {
+ let parent = path
+ .parent()
+ .filter(|path| !path.as_os_str().is_empty())
+ .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "created path has no parent"))?;
+ let name = path.file_name().ok_or_else(|| {
+ io::Error::new(io::ErrorKind::InvalidInput, "created path has no file name")
+ })?;
+ Ok(PathAnchor {
+ parent: open_directory_path(parent)?,
+ name: name.to_os_string(),
+ })
+}
+
+#[cfg(not(any(unix, windows)))]
+fn path_anchor(_path: &Path) -> io::Result {
+ Ok(PathAnchor {})
+}
+
+#[cfg(unix)]
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+struct EntryIdentity {
+ volume: u64,
+ file: u64,
+}
+
+#[cfg(windows)]
+type EntryIdentity = same_file::Handle;
+
+#[cfg(not(any(unix, windows)))]
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+struct EntryIdentity;
+
+#[cfg(unix)]
+fn entry_identity(metadata: &fs::Metadata) -> Option {
+ use std::os::unix::fs::MetadataExt;
+
+ Some(EntryIdentity {
+ volume: metadata.dev(),
+ file: metadata.ino(),
+ })
+}
+
+#[cfg(not(any(unix, windows)))]
+fn entry_identity(_metadata: &fs::Metadata) -> Option {
+ None
+}
+
+#[cfg(unix)]
+fn journal_identity_from_file(file: &fs::File) -> io::Result