diff --git a/.repos/alchemy-effect/.eslintignore b/.repos/alchemy-effect/.eslintignore deleted file mode 100644 index b5e86027d9f734e9f74d6408a24250e12ac5bc03..0000000000000000000000000000000000000000 --- a/.repos/alchemy-effect/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -alchemy/test/** diff --git a/.repos/alchemy-effect/.gitattributes b/.repos/alchemy-effect/.gitattributes index 1d9523b95f21e56aa13a54ba1b15b744d23989b1..ae67a4d31b1c768a4465a9813dccc100dcc6b786 100644 --- a/.repos/alchemy-effect/.gitattributes +++ b/.repos/alchemy-effect/.gitattributes @@ -1,4 +1,3 @@ * text=auto eol=lf -.vendor/** linguist-vendored -distilled/** linguist-vendored +submodules/** linguist-vendored diff --git a/.repos/alchemy-effect/.github/pr-assets/721/aws-hub.png b/.repos/alchemy-effect/.github/pr-assets/721/aws-hub.png deleted file mode 100644 index ab54ea70e4fb4765dabcf5d85ffb1c748df296e0..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/721/aws-hub.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/721/axiom-hub.png b/.repos/alchemy-effect/.github/pr-assets/721/axiom-hub.png deleted file mode 100644 index c3ad3acc7a47f0a7a62ca79f5752159c1c11a0f0..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/721/axiom-hub.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/721/cloudflare-hub-dark.png b/.repos/alchemy-effect/.github/pr-assets/721/cloudflare-hub-dark.png deleted file mode 100644 index 0f828340ae9e393721bea7a3e8f361d76f4e868b..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/721/cloudflare-hub-dark.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/721/cloudflare-hub.png b/.repos/alchemy-effect/.github/pr-assets/721/cloudflare-hub.png deleted file mode 100644 index 03fd7eab008067337d509c11370119664437b332..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/721/cloudflare-hub.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/721/docs-tab.png b/.repos/alchemy-effect/.github/pr-assets/721/docs-tab.png deleted file mode 100644 index 86ce81758763738de586790afa9280cd964f779e..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/721/docs-tab.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/721/landing.png b/.repos/alchemy-effect/.github/pr-assets/721/landing.png deleted file mode 100644 index bfeec314e849036946e01e0f3a0a1b604eb72648..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/721/landing.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/721/planetscale-hub.png b/.repos/alchemy-effect/.github/pr-assets/721/planetscale-hub.png deleted file mode 100644 index e516f716ad583741c5fcfd61cbcb70029c7446cc..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/721/planetscale-hub.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/721/reference.png b/.repos/alchemy-effect/.github/pr-assets/721/reference.png deleted file mode 100644 index 95d92738f1459607843826f9907ecd11b532c55a..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/721/reference.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/731/aws-hub.png b/.repos/alchemy-effect/.github/pr-assets/731/aws-hub.png deleted file mode 100644 index ab54ea70e4fb4765dabcf5d85ffb1c748df296e0..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/731/aws-hub.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/731/axiom-hub.png b/.repos/alchemy-effect/.github/pr-assets/731/axiom-hub.png deleted file mode 100644 index c3ad3acc7a47f0a7a62ca79f5752159c1c11a0f0..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/731/axiom-hub.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/731/cloudflare-hub-dark.png b/.repos/alchemy-effect/.github/pr-assets/731/cloudflare-hub-dark.png deleted file mode 100644 index 7e1a5feb5e0bd0e17117702e3910df9a79dbb046..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/731/cloudflare-hub-dark.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/731/cloudflare-hub.png b/.repos/alchemy-effect/.github/pr-assets/731/cloudflare-hub.png deleted file mode 100644 index 09bfbaa50dd12dd6bef2e273307787e789909fa8..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/731/cloudflare-hub.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/731/docs-tab.png b/.repos/alchemy-effect/.github/pr-assets/731/docs-tab.png deleted file mode 100644 index 7cc7126c50a796d1a786919ad6d423c1b46e20e5..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/731/docs-tab.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/731/landing.png b/.repos/alchemy-effect/.github/pr-assets/731/landing.png deleted file mode 100644 index 8d426b4c8219133bf180f7ac2c6e813926e812f4..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/731/landing.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/731/planetscale-hub.png b/.repos/alchemy-effect/.github/pr-assets/731/planetscale-hub.png deleted file mode 100644 index e516f716ad583741c5fcfd61cbcb70029c7446cc..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/731/planetscale-hub.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/731/reference.png b/.repos/alchemy-effect/.github/pr-assets/731/reference.png deleted file mode 100644 index da7a70bede9ef9467f91e97a076c26fe1bb02a9e..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/731/reference.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/732/aws-hub.png b/.repos/alchemy-effect/.github/pr-assets/732/aws-hub.png deleted file mode 100644 index ab54ea70e4fb4765dabcf5d85ffb1c748df296e0..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/732/aws-hub.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/732/axiom-hub.png b/.repos/alchemy-effect/.github/pr-assets/732/axiom-hub.png deleted file mode 100644 index c3ad3acc7a47f0a7a62ca79f5752159c1c11a0f0..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/732/axiom-hub.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/732/cloudflare-hub-dark.png b/.repos/alchemy-effect/.github/pr-assets/732/cloudflare-hub-dark.png deleted file mode 100644 index 7e1a5feb5e0bd0e17117702e3910df9a79dbb046..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/732/cloudflare-hub-dark.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/732/cloudflare-hub.png b/.repos/alchemy-effect/.github/pr-assets/732/cloudflare-hub.png deleted file mode 100644 index 09bfbaa50dd12dd6bef2e273307787e789909fa8..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/732/cloudflare-hub.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/732/docs-tab.png b/.repos/alchemy-effect/.github/pr-assets/732/docs-tab.png deleted file mode 100644 index 7cc7126c50a796d1a786919ad6d423c1b46e20e5..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/732/docs-tab.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/732/landing.png b/.repos/alchemy-effect/.github/pr-assets/732/landing.png deleted file mode 100644 index 07730155d6af0ed5b34e7bf7f3c42d38774ab265..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/732/landing.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/732/planetscale-hub.png b/.repos/alchemy-effect/.github/pr-assets/732/planetscale-hub.png deleted file mode 100644 index e516f716ad583741c5fcfd61cbcb70029c7446cc..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/732/planetscale-hub.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/732/reference.png b/.repos/alchemy-effect/.github/pr-assets/732/reference.png deleted file mode 100644 index 59a1b8431ee5c738bab98229af52456b2b43ca55..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/732/reference.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/883/mobile-docs-header.png b/.repos/alchemy-effect/.github/pr-assets/883/mobile-docs-header.png deleted file mode 100644 index 26d13c3da72ab2b73c852563405d06fd06245be7..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/883/mobile-docs-header.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/883/mobile-search-open.png b/.repos/alchemy-effect/.github/pr-assets/883/mobile-search-open.png deleted file mode 100644 index 5f875b8c90ac47da677f683d5525ca3346dd9fb1..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/883/mobile-search-open.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/903/sql-drizzle-d1.png b/.repos/alchemy-effect/.github/pr-assets/903/sql-drizzle-d1.png deleted file mode 100644 index 6b5c763d19864d4ee982dd4f3ffb22fa7b0ca81a..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/903/sql-drizzle-d1.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/903/sql-hub-dark.png b/.repos/alchemy-effect/.github/pr-assets/903/sql-hub-dark.png deleted file mode 100644 index 53f7960488c36efa5c43a748d6c4ddce1c3717c9..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/903/sql-hub-dark.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/903/sql-hub.png b/.repos/alchemy-effect/.github/pr-assets/903/sql-hub.png deleted file mode 100644 index cf847db631ddd9a6dfa490d07a4bcd3dc2db623a..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/903/sql-hub.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/903/sql-lifecycle.png b/.repos/alchemy-effect/.github/pr-assets/903/sql-lifecycle.png deleted file mode 100644 index 33a89ed62660d4a1c87bc2ef968b84eb42921b0c..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/903/sql-lifecycle.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/967/multi-stack.png b/.repos/alchemy-effect/.github/pr-assets/967/multi-stack.png deleted file mode 100644 index cc8472a3ccf80a8cb5357dc9541f20ec74cd08da..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/967/multi-stack.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/967/single-stack.png b/.repos/alchemy-effect/.github/pr-assets/967/single-stack.png deleted file mode 100644 index 1291225d0901184551e7528057fe4f01437172b0..0000000000000000000000000000000000000000 Binary files a/.repos/alchemy-effect/.github/pr-assets/967/single-stack.png and /dev/null differ diff --git a/.repos/alchemy-effect/.github/pr-assets/update-screenshots.sh b/.repos/alchemy-effect/.github/pr-assets/update-screenshots.sh deleted file mode 100755 index ba957357ffa97881ce0500a8161c3de8617ef797..0000000000000000000000000000000000000000 --- a/.repos/alchemy-effect/.github/pr-assets/update-screenshots.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash -# Regenerate the PR screenshots from the built site and commit them if changed. -# Usage: .github/pr-assets/update-screenshots.sh [pr-number] -# Assumes website/dist is freshly built (DOCS_FAST=1 bun astro build). -set -euo pipefail - -PR="${1:-721}" -ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -OUT="$ROOT/.github/pr-assets/$PR" -DIST="$ROOT/website/dist" -CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" -PORT=4899 - -[ -d "$DIST" ] || { echo "website/dist missing — build first"; exit 1; } -mkdir -p "$OUT" - -python3 -m http.server "$PORT" --directory "$DIST" >/dev/null 2>&1 & -SERVER_PID=$! -trap 'kill $SERVER_PID 2>/dev/null || true' EXIT -sleep 1 - -shot() { # shot [extra chrome flags...] - local file="$1" path="$2"; shift 2 - "$CHROME" --headless=new --disable-gpu --hide-scrollbars \ - --window-size=1600,1000 --virtual-time-budget=6000 \ - "$@" --screenshot="$OUT/$file" "http://localhost:$PORT$path" 2>/dev/null - echo "$file: $(wc -c <"$OUT/$file" | tr -d ' ') bytes" -} - -shot landing.png / -shot docs-tab.png /getting-started/ -shot cloudflare-hub.png /cloudflare/ -shot aws-hub.png /aws/ -shot planetscale-hub.png /planetscale/ -shot axiom-hub.png /axiom/ -shot reference.png /providers/cloudflare/workers/durableobject/ -shot cloudflare-hub-dark.png /cloudflare/ --force-dark-mode --blink-settings=preferredColorScheme=2 - -cd "$ROOT" -if git status --porcelain -- ".github/pr-assets/$PR" | grep -q .; then - git add ".github/pr-assets/$PR" - git commit -q -m "chore(website): refresh PR screenshots - -Co-Authored-By: Claude Fable 5 " - git push - echo "screenshots refreshed and pushed" -else - echo "screenshots unchanged" -fi diff --git a/.repos/alchemy-effect/.github/workflows/check.yml b/.repos/alchemy-effect/.github/workflows/check.yml index 6a52713180dc05f9ed66fc1cf91d3c8f4950f7f9..d8625628f5221cdf83fb96ef68774bd79281c9e6 100644 --- a/.repos/alchemy-effect/.github/workflows/check.yml +++ b/.repos/alchemy-effect/.github/workflows/check.yml @@ -6,7 +6,7 @@ on: push: branches: [main] paths: - - "distilled" + - "submodules/distilled" - "benchmark/**" - "examples/**" - "packages/**" @@ -22,7 +22,7 @@ on: pull_request: types: [opened, reopened, synchronize] paths: - - "distilled" + - "submodules/distilled" - "benchmark/**" - "examples/**" - "packages/**" @@ -53,17 +53,17 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Checkout distilled - run: git submodule update --init --depth=1 --checkout -- distilled + run: git submodule update --init --depth=1 --checkout -- submodules/distilled - name: Restore Astro build cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: website/node_modules/.astro - key: website-astro-${{ runner.os }}-${{ github.event.pull_request.number || 'main' }}-${{ github.sha }}-${{ github.workflow }}-${{ github.run_attempt }} + key: website-astro-check-${{ runner.os }}-${{ github.event.pull_request.number || 'main' }}-${{ github.sha }}-${{ github.workflow }}-${{ github.run_attempt }} restore-keys: | - website-astro-${{ runner.os }}-${{ github.event.pull_request.number || 'main' }}-${{ github.sha }}- - website-astro-${{ runner.os }}-${{ github.event.pull_request.number || 'main' }}- - website-astro-${{ runner.os }}-main- + website-astro-check-${{ runner.os }}-${{ github.event.pull_request.number || 'main' }}-${{ github.sha }}- + website-astro-check-${{ runner.os }}-${{ github.event.pull_request.number || 'main' }}- + website-astro-check-${{ runner.os }}-main- - name: Restore Turbo cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -84,6 +84,9 @@ jobs: - name: Run Node Utils tests run: pnpm -C packages/node-utils test + - name: Run pkg tests + run: pnpm -C packages/pkg test + - name: Run Typecheck run: pnpm tsc -b diff --git a/.repos/alchemy-effect/.github/workflows/claude.yml b/.repos/alchemy-effect/.github/workflows/claude.yml index c96e8f4ffaeba7918a1fa2f8ee5d2acdc3fc2fa7..713a97ca9605adf7bb97d24f22702159b27d7438 100644 --- a/.repos/alchemy-effect/.github/workflows/claude.yml +++ b/.repos/alchemy-effect/.github/workflows/claude.yml @@ -37,7 +37,7 @@ jobs: fetch-depth: 1 - name: Checkout distilled - run: git submodule update --init --depth=1 --checkout -- distilled + run: git submodule update --init --depth=1 --checkout -- submodules/distilled - name: Restore Turbo cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 diff --git a/.repos/alchemy-effect/.github/workflows/cloudflare-tools.yml b/.repos/alchemy-effect/.github/workflows/cloudflare-tools.yml index e378929e58ad04aa2e40994c2b7986a103336cd1..4c3c2842b53275d729ee4fc51d90ae7c864e1b8d 100644 --- a/.repos/alchemy-effect/.github/workflows/cloudflare-tools.yml +++ b/.repos/alchemy-effect/.github/workflows/cloudflare-tools.yml @@ -4,8 +4,7 @@ on: push: branches: [main] paths: - - "distilled" - - "fixtures/**" + - "submodules/distilled" - "packages/cloudflare-runtime/**" - "packages/cloudflare-test-tools/**" - "packages/frontend-frameworks/**" @@ -21,8 +20,7 @@ on: pull_request: types: [opened, reopened, synchronize] paths: - - "distilled" - - "fixtures/**" + - "submodules/distilled" - "packages/cloudflare-runtime/**" - "packages/cloudflare-test-tools/**" - "packages/frontend-frameworks/**" @@ -66,7 +64,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Checkout distilled - run: git submodule update --init --depth=1 --checkout -- distilled + run: git submodule update --init --depth=1 --checkout -- submodules/distilled - name: Restore Turbo cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 diff --git a/.repos/alchemy-effect/.github/workflows/pkg.yml b/.repos/alchemy-effect/.github/workflows/pkg.yml new file mode 100644 index 0000000000000000000000000000000000000000..f674c0605a903d544bbb42041aeadb0feb6aa2bb --- /dev/null +++ b/.repos/alchemy-effect/.github/workflows/pkg.yml @@ -0,0 +1,94 @@ +name: Package Preview + +# Builds, packs, and publishes preview packages from the job that built them. +# The job proves it is the run it claims to be by uploading the manifest as +# an artifact of its own run, which only the job's runtime token can do; the +# registry reads the artifact list back through the GitHub API. That works +# the same for pushes, same-repo pull requests, and fork pull requests, and +# needs no permissions or secrets. The upload has to be an action step: the +# runtime token is never exposed to `run:` steps. + +on: + push: + branches: [main] + paths: + - "submodules/distilled" + - "packages/**" + - "scripts/**" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "turbo.json" + - "tsconfig.json" + - ".github/workflows/pkg.yml" + pull_request: + paths: + - "submodules/distilled" + - "packages/**" + - "scripts/**" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "turbo.json" + - "tsconfig.json" + - ".github/workflows/pkg.yml" + +permissions: + contents: read + +env: + PKG_REGISTRY: https://pkg.alchemy.run + +concurrency: + group: pkg-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + publish: + name: Publish preview packages + runs-on: blacksmith-8vcpu-ubuntu-2404 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Pack the pull request head rather than the synthetic merge commit, + # so every tarball is addressed by a commit that exists on the PR. + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Checkout distilled + run: git submodule update --init --depth=1 --checkout -- submodules/distilled + + # No turbo cache here on purpose: the restore keys fall back to any + # earlier run, and replayed outputs for the distilled packages, whose + # sources live in a submodule, have compiled alchemy against stale + # declarations. A clean build is a few minutes and always correct. + - name: Setup pnpm, Node.js, Bun and install dependencies + uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0 + with: + cache: true + install: true + require-lockfile: true + + - name: Build packages + run: pnpm build:pkg + + # Keep the directory lists in sync with the `build:pkg` script. + - name: Pack packages + id: pack + run: >- + pnpm exec pkg pack + --group 'alchemy=./packages/alchemy' + --group '@alchemy.run[Collapsed]=./packages/{better-auth,cloudflare-runtime,frontend-frameworks,node-utils,floci,pkg}' + --group '@distilled.cloud[Collapsed]=./submodules/distilled/packages/{core,aws,axiom,cloudflare,fly-io,github,hetzner,neon,planetscale,railway,stripe}' + + - name: Vouch for the manifest + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ steps.pack.outputs.artifact-name }} + path: .pkg/pkg-manifest.json + include-hidden-files: true + if-no-files-found: error + retention-days: 1 + + - name: Publish packages + run: pnpm exec pkg publish diff --git a/.repos/alchemy-effect/.github/workflows/pr-package.yml b/.repos/alchemy-effect/.github/workflows/pr-package.yml deleted file mode 100644 index dc40cfb3082220cb252eca582fc71dce3cc1c562..0000000000000000000000000000000000000000 --- a/.repos/alchemy-effect/.github/workflows/pr-package.yml +++ /dev/null @@ -1,123 +0,0 @@ -name: PR Package - -on: - push: - branches: [main] - paths: - - "distilled" - - "packages/**" - - "scripts/**" - - "package.json" - - "pnpm-lock.yaml" - - "pnpm-workspace.yaml" - - "turbo.json" - - "tsconfig.json" - - ".github/workflows/pr-package.yml" - pull_request: - # `labeled` so adding the `force-ci` label re-triggers the run. We - # intentionally do NOT listen to `closed` — pr-package tags persist - # past PR close so existing install URLs keep resolving. - types: [opened, synchronize, reopened, labeled] - paths: - - "distilled" - - "packages/**" - - "scripts/**" - - "package.json" - - "pnpm-lock.yaml" - - "pnpm-workspace.yaml" - - "turbo.json" - - "tsconfig.json" - - ".github/workflows/pr-package.yml" - -# The action can't widen permissions, so the job must grant write access for -# the sticky install-instructions comment. -permissions: - contents: read - pull-requests: write - -concurrency: - group: pr-package-${{ github.ref }} - # A newer PR commit supersedes its preview packages. Never interrupt a main - # publish, where cancellation could leave only part of the package set live. - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -jobs: - pr-package: - name: Publish PR preview packages - if: >- - github.event_name == 'push' || - (github.event_name == 'pull_request' && github.event.action != 'closed' && - github.event.pull_request.head.repo.full_name == github.repository) - runs-on: blacksmith-8vcpu-ubuntu-2404 - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - - name: Checkout distilled - run: git submodule update --init --depth=1 --checkout -- distilled - - - name: Restore Turbo cache - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: .turbo - key: turbo-${{ runner.os }}-${{ github.repository_id }}-${{ github.sha }}-${{ github.workflow }}-${{ github.run_attempt }} - restore-keys: | - turbo-${{ runner.os }}-${{ github.repository_id }}-${{ github.sha }}- - turbo-${{ runner.os }}-${{ github.repository_id }}- - - # The root install runs Turbo-backed prepare builds, so restore its - # cache before the setup action installs dependencies. - - name: Setup pnpm, Node.js, Bun and install dependencies - uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0 - with: - cache: true - install: true - require-lockfile: true - - - name: Build packages - run: pnpm build:pr-packages - - - name: Publish packages - id: publish - uses: alchemy-run/actions/actions/pr-package@098beeda4d3c2ce8850638c4466783893b8f0c23 # actions#11 - with: - rebuild-all-paths: distilled - packages: >- - [ - { "dir": "packages/alchemy", "name": "alchemy", "group": "Alchemy" }, - { "dir": "packages/better-auth", "name": "@alchemy.run/better-auth", "group": "Alchemy" }, - { "dir": "packages/cloudflare-runtime", "name": "@alchemy.run/cloudflare-runtime", "group": "Alchemy" }, - { "dir": "packages/frontend-frameworks", "name": "@alchemy.run/frontend-frameworks", "group": "Alchemy" }, - { "dir": "packages/node-utils", "name": "@alchemy.run/node-utils", "group": "Alchemy" }, - { "dir": "packages/pr-package", "name": "@alchemy.run/pr-package", "group": "Alchemy" }, - { "dir": "packages/floci", "name": "@alchemy.run/floci", "group": "Alchemy" }, - { "dir": "distilled/packages/core", "name": "@distilled.cloud/core", "group": "Distilled", "submodule": true }, - { "dir": "distilled/packages/aws", "name": "@distilled.cloud/aws", "group": "Distilled", "submodule": true }, - { "dir": "distilled/packages/axiom", "name": "@distilled.cloud/axiom", "group": "Distilled", "submodule": true }, - { "dir": "distilled/packages/cloudflare", "name": "@distilled.cloud/cloudflare", "group": "Distilled", "submodule": true }, - { "dir": "distilled/packages/hetzner", "name": "@distilled.cloud/hetzner", "group": "Distilled", "submodule": true }, - { "dir": "distilled/packages/neon", "name": "@distilled.cloud/neon", "group": "Distilled", "submodule": true }, - { "dir": "distilled/packages/planetscale", "name": "@distilled.cloud/planetscale", "group": "Distilled", "submodule": true } - ] - pr-package-token: ${{ secrets.PR_PACKAGE_TOKEN }} - - - name: Generate bot token - if: >- - github.event_name == 'pull_request' && - fromJson(steps.publish.outputs.plan).packages[0] != null - id: bot-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - app-id: ${{ secrets.ALCHEMY_VERSION_BOT_ID }} - private-key: ${{ secrets.ALCHEMY_VERSION_BOT_PRIVATE_KEY }} - - - name: Comment on PR - if: >- - github.event_name == 'pull_request' && - fromJson(steps.publish.outputs.plan).packages[0] != null - uses: alchemy-run/actions/actions/pr-package-comment@098beeda4d3c2ce8850638c4466783893b8f0c23 # actions#11 - with: - plan: ${{ steps.publish.outputs.plan }} - token: ${{ steps.bot-token.outputs.token }} diff --git a/.repos/alchemy-effect/.github/workflows/release.yml b/.repos/alchemy-effect/.github/workflows/release.yml index 5fb1c7af6fed60afeecf26a8e9e31e488517c5ca..f25b7dce13a917389bda1189c84f8925b6293178 100644 --- a/.repos/alchemy-effect/.github/workflows/release.yml +++ b/.repos/alchemy-effect/.github/workflows/release.yml @@ -54,7 +54,7 @@ jobs: token: ${{ steps.bot-token.outputs.token }} - name: Checkout distilled - run: git submodule update --init --depth=1 --checkout -- distilled + run: git submodule update --init --depth=1 --checkout -- submodules/distilled - name: Restore Turbo cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -120,7 +120,7 @@ jobs: fetch-depth: 0 - name: Checkout distilled - run: git submodule update --init --depth=1 --checkout -- distilled + run: git submodule update --init --depth=1 --checkout -- submodules/distilled - name: Setup pnpm, Node.js, Bun uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0 diff --git a/.repos/alchemy-effect/.github/workflows/website.yml b/.repos/alchemy-effect/.github/workflows/website.yml index 3fa72073506ad2b6d717eca5ca87609c55167183..94a84b816b8a0dd6817721b1429edd76454b94ac 100644 --- a/.repos/alchemy-effect/.github/workflows/website.yml +++ b/.repos/alchemy-effect/.github/workflows/website.yml @@ -2,14 +2,20 @@ name: Website on: # Manual override — deploys unconditionally on whatever branch it's run - # from (use this to force a prod deploy from main without a release). + # from. Toggle `prod` to force a production deploy without a release. workflow_dispatch: + inputs: + prod: + description: "Deploy to production (alchemy.run) instead of the branch stage" + type: boolean + default: false # Release commits update package manifests, so packages/** keeps production # deploys automatic without redeploying the site for unrelated main commits. push: branches: - main paths: + - "submodules/distilled" - "website/**" - "packages/**" - "scripts/generate-api-reference.ts" @@ -18,17 +24,19 @@ on: - "pnpm-lock.yaml" - "pnpm-workspace.yaml" - "tsconfig.json" + - "turbo.json" - ".github/workflows/website.yml" pull_request: - # `labeled` so adding `deploy-website` on an open PR triggers a - # deploy without needing another push. + # Label changes create and remove previews without needing another push. types: - opened - reopened - synchronize - labeled + - unlabeled - closed paths: + - "submodules/distilled" - "website/**" - "packages/**" - "scripts/generate-api-reference.ts" @@ -37,15 +45,23 @@ on: - "pnpm-lock.yaml" - "pnpm-workspace.yaml" - "tsconfig.json" + - "turbo.json" - ".github/workflows/website.yml" concurrency: - group: deploy-website-${{ github.ref }} + group: deploy-website-${{ github.event_name == 'pull_request' && + format('pr-{0}', github.event.number) || (inputs.prod || + startsWith(github.event.head_commit.message, 'chore(release):')) && + 'prod' || (github.ref == 'refs/heads/main' && 'main' || github.ref_name) + }} + # Deploy and destroy mutate the same stack and must finish atomically. Each + # run checks the PR's current state before mutation, so queued label events + # converge to the latest desired state even when GitHub reorders them. cancel-in-progress: false env: STAGE: ${{ github.event_name == 'pull_request' && format('pr-{0}', - github.event.number) || (github.event_name == 'workflow_dispatch' || + github.event.number) || (inputs.prod || startsWith(github.event.head_commit.message, 'chore(release):')) && 'prod' || (github.ref == 'refs/heads/main' && 'main' || github.ref_name) }} @@ -53,17 +69,20 @@ jobs: deploy: name: Deploy # Deploy every push to main. Release commits (`chore(release): x.y.z`) - # and manual dispatches deploy production; other main commits deploy to - # main.alchemy.run. On PRs, require BOTH: + # and dispatches with `prod` toggled deploy production; other main + # commits deploy to main.alchemy.run. On PRs, require BOTH: # - the PR is internal (forks can't access bot/Cloudflare secrets # so `create-github-app-token` would error immediately), AND # - the PR carries the `deploy-website` label (opt-in — most PRs # don't need a website preview, so default to skipping). if: >- - github.event.action != 'closed' && (github.event_name != 'pull_request' || (github.event.pull_request.head.repo.full_name == github.repository && - contains(github.event.pull_request.labels.*.name, 'deploy-website'))) + contains(github.event.pull_request.labels.*.name, 'deploy-website') && + github.event.action != 'closed' && + github.event.action != 'unlabeled' && + (github.event.action != 'labeled' || + github.event.label.name == 'deploy-website'))) runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: read @@ -73,7 +92,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Checkout distilled - run: git submodule update --init --depth=1 --checkout -- distilled + run: git submodule update --init --depth=1 --checkout -- submodules/distilled # Mint a token for the alchemy-version-bot GitHub App so the PR # preview comment (created via GitHub.Comment in alchemy.run.ts) @@ -91,11 +110,11 @@ jobs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: website/node_modules/.astro - key: website-astro-${{ runner.os }}-${{ github.event.pull_request.number || 'main' }}-${{ github.sha }}-${{ github.workflow }}-${{ github.run_attempt }} + key: website-astro-full-${{ runner.os }}-${{ github.event.pull_request.number || 'main' }}-${{ github.sha }}-${{ github.workflow }}-${{ github.run_attempt }} restore-keys: | - website-astro-${{ runner.os }}-${{ github.event.pull_request.number || 'main' }}-${{ github.sha }}- - website-astro-${{ runner.os }}-${{ github.event.pull_request.number || 'main' }}- - website-astro-${{ runner.os }}-main- + website-astro-full-${{ runner.os }}-${{ github.event.pull_request.number || 'main' }}-${{ github.sha }}- + website-astro-full-${{ runner.os }}-${{ github.event.pull_request.number || 'main' }}- + website-astro-full-${{ runner.os }}-main- - name: Restore Turbo cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -113,8 +132,21 @@ jobs: install: true require-lockfile: true + - name: Check current preview state + if: github.event_name == 'pull_request' + id: preview-state + env: + GH_TOKEN: ${{ github.token }} + run: | + active="$(gh api "repos/$GITHUB_REPOSITORY/pulls/${{ github.event.number }}" \ + --jq '[.state == "open", any(.labels[]; .name == "deploy-website")] | all')" + echo "active=$active" >> "$GITHUB_OUTPUT" + # Main/prod use the production account; PR stages use the test account. - name: Deploy + if: >- + github.event_name != 'pull_request' || + steps.preview-state.outputs.active == 'true' working-directory: website run: bun alchemy deploy --stage "$STAGE" --yes env: @@ -139,8 +171,11 @@ jobs: name: Cleanup runs-on: blacksmith-4vcpu-ubuntu-2404 if: >- - github.event_name == 'pull_request' && github.event.action == 'closed' && - github.event.pull_request.head.repo.full_name == github.repository + github.event.pull_request.head.repo.full_name == github.repository && + ((github.event.action == 'closed' && + contains(github.event.pull_request.labels.*.name, 'deploy-website')) || + (github.event.action == 'unlabeled' && + github.event.label.name == 'deploy-website')) permissions: contents: read pull-requests: write @@ -149,7 +184,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Checkout distilled - run: git submodule update --init --depth=1 --checkout -- distilled + run: git submodule update --init --depth=1 --checkout -- submodules/distilled - name: Generate bot token id: bot-token @@ -169,22 +204,35 @@ jobs: # bundle anything. Skip native postinstalls and the root prepare build. run: pnpm install --frozen-lockfile --ignore-scripts - - name: Install Doppler CLI - uses: dopplerhq/cli-action@4819d808ab99e5cde19a0637a16536a4038fad73 # v4 + - name: Check current preview state + id: preview-state + env: + GH_TOKEN: ${{ github.token }} + run: | + active="$(gh api "repos/$GITHUB_REPOSITORY/pulls/${{ github.event.number }}" \ + --jq '[.state == "open", any(.labels[]; .name == "deploy-website")] | all')" + echo "active=$active" >> "$GITHUB_OUTPUT" + # Allowlist, not denylist: cleanup must only ever destroy pr-N stages, + # so refuse anything else outright (prod/main/preview-base) even if a + # future STAGE refactor changes what a pull_request event resolves to. - name: Safety Check + if: steps.preview-state.outputs.active != 'true' run: |- - if [ "${{ env.STAGE }}" = "prod" ]; then - echo "ERROR: Cannot destroy prod environment in cleanup job" - exit 1 - fi + case "${{ env.STAGE }}" in + pr-[0-9]*) ;; + *) + echo "ERROR: cleanup only destroys pr-* stages, got '${{ env.STAGE }}'" + exit 1 + ;; + esac - name: Destroy Preview Environment + if: steps.preview-state.outputs.active != 'true' working-directory: website - run: >- - doppler run --project "$DOPPLER_PROJECT" --config "$DOPPLER_CONFIG" - -- bun alchemy destroy --stage "$STAGE" --yes + run: bun alchemy destroy --stage "$STAGE" --yes env: - DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} PULL_REQUEST: ${{ github.event.number }} + CLOUDFLARE_API_TOKEN: ${{ secrets.TEST_CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.TEST_CLOUDFLARE_ACCOUNT_ID }} GITHUB_TOKEN: ${{ steps.bot-token.outputs.token }} diff --git a/.repos/alchemy-effect/.gitignore b/.repos/alchemy-effect/.gitignore index 2e022378275ef20aa850e327c70644104077af9d..bf6515b84ed319aeccef11a79d3229c1e0c7400c 100644 --- a/.repos/alchemy-effect/.gitignore +++ b/.repos/alchemy-effect/.gitignore @@ -46,8 +46,8 @@ packages/cloudflare-runtime/src/rolldown/out/ !packages/cloudflare-runtime/src/rolldown/test/fixtures/**/*.js !packages/cloudflare-frameworks/src/nextjs/wrangler-stub/*.js !packages/cloudflare-*/src/**/*.d.ts -!fixtures/**/*.d.ts -!fixtures/**/*.js +!packages/frontend-frameworks/fixtures/**/*.d.ts +!packages/frontend-frameworks/fixtures/**/*.js .attest .cache @@ -67,13 +67,16 @@ examples/cloudflare-worker/src/scratch.ts /packages/alchemy/README.md !/packages/alchemy/bin/cli.js -.vendor/* +!/packages/alchemy/bin/register-dev-mode.js +!/packages/alchemy/bin/register-oxc.js .tmp # the lockfile-pinning test fixture is a checked-in lockfile, not a generated one !packages/alchemy/test/AWS/Lambda/fixtures/lockfile-pinning/pnpm/pnpm-lock.yaml -!.vendor/distilled -!.vendor/floci .ai-workspace/ /processes/ .probe +!packages/alchemy/src/Cloudflare/Workers/worker-module.d.ts + +# pkg pack output (CI artifact hand-off) +.pkg diff --git a/.repos/alchemy-effect/.gitmodules b/.repos/alchemy-effect/.gitmodules index 78497e7e153f75eb758fa017d0f6c6955063a81e..5631a217347bd5a4535a8b0bec52c4972de12eea 100644 --- a/.repos/alchemy-effect/.gitmodules +++ b/.repos/alchemy-effect/.gitmodules @@ -1,71 +1,14 @@ - # reference-only checkout of the repos — skipped by - # `git submodule update --init` (CI included). Fetch explicitly with - # `git submodule update --init --checkout -- .vendor/`. -[submodule "vendor/alchemy"] - path = .vendor/alchemy - url = https://github.com/alchemy-run/alchemy-async.git - shallow = true - update = none - ignore = all -[submodule "vendor/workers-sdk"] - path = .vendor/workers-sdk - url = https://github.com/cloudflare/workers-sdk.git - shallow = true - update = none - ignore = all -[submodule "vendor/next.js"] - path = .vendor/next.js - url = https://github.com/vercel/next.js.git - shallow = true - update = none - ignore = all -[submodule "vendor/opennextjs-cloudflare"] - path = .vendor/opennextjs-cloudflare - url = https://github.com/opennextjs/opennextjs-cloudflare.git - shallow = true - update = none - ignore = all -[submodule "vendor/astro"] - path = .vendor/astro - url = https://github.com/withastro/astro.git - shallow = true - update = none - ignore = all -[submodule "vendor/sveltekit"] - path = .vendor/sveltekit - url = https://github.com/sveltejs/kit.git - shallow = true - update = none - ignore = all -[submodule "vendor/waku"] - path = .vendor/waku - url = https://github.com/wakujs/waku.git - shallow = true - update = none - ignore = all -[submodule "vendor/nuxt"] - path = .vendor/nuxt - url = https://github.com/nuxt/nuxt.git - shallow = true - update = none - ignore = all -[submodule "vendor/nitro"] - path = .vendor/nitro - url = https://github.com/nitrojs/nitro.git - shallow = true - update = none - ignore = all -[submodule "vendor/nitro-cloudflare-dev"] - path = .vendor/nitro-cloudflare-dev - url = https://github.com/nitrojs/nitro-cloudflare-dev.git - shallow = true - update = none - ignore = all +# First-class sibling repositories we maintain. +# +# Distilled is part of the TypeScript workspace and is initialized by +# `git submodule update --init`. Floci is a large Java tree used only to +# build the local AWS emulator, so it is skipped unless requested: +# `git submodule update --init --checkout -- submodules/floci`. [submodule "distilled"] - path = distilled + path = submodules/distilled url = https://github.com/alchemy-run/distilled.git -[submodule "vendor/floci"] - path = .vendor/floci +[submodule "floci"] + path = submodules/floci url = https://github.com/alchemy-run/floci.git shallow = true update = none diff --git a/.repos/alchemy-effect/.husky/post-checkout b/.repos/alchemy-effect/.husky/post-checkout index 5da7cb59785c4324775f65a2f78432e2f09ff0f5..9ffc5dcfc80239f330c524d4f3fd7735b4e3d649 100755 --- a/.repos/alchemy-effect/.husky/post-checkout +++ b/.repos/alchemy-effect/.husky/post-checkout @@ -1,13 +1,2 @@ #!/usr/bin/env sh -set -eu - -git_dir="$(git rev-parse --path-format=absolute --git-dir)" -common_dir="$(git rev-parse --path-format=absolute --git-common-dir)" - -# `post-checkout` also runs for ordinary checkouts and clones. Distilled only -# needs bootstrapping when Git has just created (or switched) a linked worktree. -[ "$git_dir" != "$common_dir" ] || exit 0 - -bun scripts/bootstrap-distilled-worktree.ts >/dev/null 2>&1 || true - -exit 0 +node scripts/bootstrap-distilled.mjs "$@" diff --git a/.repos/alchemy-effect/.husky/pre-commit b/.repos/alchemy-effect/.husky/pre-commit index 172915addb702aecc12b112e8bc74741cb58df33..fd63fee24c2c5c293e8a5ff6913c02ec964391ae 100644 --- a/.repos/alchemy-effect/.husky/pre-commit +++ b/.repos/alchemy-effect/.husky/pre-commit @@ -1,2 +1,2 @@ -bun format +pnpm run format git update-index --again diff --git a/.repos/alchemy-effect/.ignore b/.repos/alchemy-effect/.ignore deleted file mode 100644 index 6f0ec623987798df11bf332c4d10256d91847f3e..0000000000000000000000000000000000000000 --- a/.repos/alchemy-effect/.ignore +++ /dev/null @@ -1 +0,0 @@ -# see: https://opencode.ai/docs/tools/#ignore-patterns \ No newline at end of file diff --git a/.repos/alchemy-effect/.oxfmtrc.json b/.repos/alchemy-effect/.oxfmtrc.json deleted file mode 100644 index c387161983f7dd44aef986c62de58f89d947ef50..0000000000000000000000000000000000000000 --- a/.repos/alchemy-effect/.oxfmtrc.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "semi": true, - "singleQuote": false, - "tabWidth": 2, - "useTabs": false, - "printWidth": 80, - "endOfLine": "lf", - "ternaries": true, - "experimental_sort_imports": { - "order": "asc" - }, - "ignorePatterns": [ - "dist/**", - "*.min.js", - "**/lib/**", - "**/mdx/**", - "**/*.mdx", - "**/*.md", - "**/__snapshots__/**", - "**/.svelte-kit/**", - "**/routeTree.gen.ts", - "**/test-results/**", - "**/.vendor/**", - "examples/prisma-tanstack-start/src/prisma/contract.d.ts", - "examples/prisma-tanstack-start/src/prisma/contract.json", - "**/package.json", - "./distilled", - "**/fixtures/chart/templates/**" - ] -} diff --git a/.repos/alchemy-effect/.oxlintrc.json b/.repos/alchemy-effect/.oxlintrc.json deleted file mode 100644 index 5ec7dbbbb645eac4ffcdfa089427ed93c19b838e..0000000000000000000000000000000000000000 --- a/.repos/alchemy-effect/.oxlintrc.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "./node_modules/oxfmt/configuration_schema.json", - "rules": { - "no-misused-new": "off", - "require-yield": "off", - "no-non-null-asserted-optional-chain": "off" - } -} diff --git a/.repos/alchemy-effect/.pnpmfile.mjs b/.repos/alchemy-effect/.pnpmfile.mjs new file mode 100644 index 0000000000000000000000000000000000000000..df4fa200daa8320dd317dae455141b69130c355d --- /dev/null +++ b/.repos/alchemy-effect/.pnpmfile.mjs @@ -0,0 +1,14 @@ +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { bootstrap } from "./scripts/bootstrap-distilled.mjs"; + +export const hooks = { + updateConfig(config) { + // pnpm also runs this hook for parallel Turbo tasks. Only bootstrap a + // missing checkout; leave existing worktrees and their Git config alone. + if (!existsSync(resolve(import.meta.dirname, "submodules/distilled/.git"))) { + bootstrap(import.meta.dirname); + } + return config; + }, +}; diff --git a/.repos/alchemy-effect/.vendor/alchemy b/.repos/alchemy-effect/.vendor/alchemy deleted file mode 160000 index c9f5e549cf023632c3df948c207a58336192b3c7..0000000000000000000000000000000000000000 --- a/.repos/alchemy-effect/.vendor/alchemy +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c9f5e549cf023632c3df948c207a58336192b3c7 diff --git a/.repos/alchemy-effect/.vendor/astro b/.repos/alchemy-effect/.vendor/astro deleted file mode 160000 index a2240c43830e29c1d85667f4c610655d6b10253f..0000000000000000000000000000000000000000 --- a/.repos/alchemy-effect/.vendor/astro +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a2240c43830e29c1d85667f4c610655d6b10253f diff --git a/.repos/alchemy-effect/.vendor/floci b/.repos/alchemy-effect/.vendor/floci deleted file mode 160000 index 3772bad5e971f9d14e9bed56f271a16fa7c61907..0000000000000000000000000000000000000000 --- a/.repos/alchemy-effect/.vendor/floci +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3772bad5e971f9d14e9bed56f271a16fa7c61907 diff --git a/.repos/alchemy-effect/.vendor/next.js b/.repos/alchemy-effect/.vendor/next.js deleted file mode 160000 index 55d30fb91d8197597c5c400174f1d38a89333de8..0000000000000000000000000000000000000000 --- a/.repos/alchemy-effect/.vendor/next.js +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 55d30fb91d8197597c5c400174f1d38a89333de8 diff --git a/.repos/alchemy-effect/.vendor/nitro b/.repos/alchemy-effect/.vendor/nitro deleted file mode 160000 index 52abde8aa9d85215bc0e8dc3b007f29fc6112ac3..0000000000000000000000000000000000000000 --- a/.repos/alchemy-effect/.vendor/nitro +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 52abde8aa9d85215bc0e8dc3b007f29fc6112ac3 diff --git a/.repos/alchemy-effect/.vendor/nitro-cloudflare-dev b/.repos/alchemy-effect/.vendor/nitro-cloudflare-dev deleted file mode 160000 index a22a83b7151b314d70a01089c352ca6007161051..0000000000000000000000000000000000000000 --- a/.repos/alchemy-effect/.vendor/nitro-cloudflare-dev +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a22a83b7151b314d70a01089c352ca6007161051 diff --git a/.repos/alchemy-effect/.vendor/nuxt b/.repos/alchemy-effect/.vendor/nuxt deleted file mode 160000 index 17119f8ee6f753faf2e61564bba3211871e16e2c..0000000000000000000000000000000000000000 --- a/.repos/alchemy-effect/.vendor/nuxt +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 17119f8ee6f753faf2e61564bba3211871e16e2c diff --git a/.repos/alchemy-effect/.vendor/opennextjs-cloudflare b/.repos/alchemy-effect/.vendor/opennextjs-cloudflare deleted file mode 160000 index da4b7fc8c89441a6f96205635fa70e329d1f0df7..0000000000000000000000000000000000000000 --- a/.repos/alchemy-effect/.vendor/opennextjs-cloudflare +++ /dev/null @@ -1 +0,0 @@ -Subproject commit da4b7fc8c89441a6f96205635fa70e329d1f0df7 diff --git a/.repos/alchemy-effect/.vendor/sveltekit b/.repos/alchemy-effect/.vendor/sveltekit deleted file mode 160000 index f36b3a4cd307bd8c89e03fa191becc175e7b3077..0000000000000000000000000000000000000000 --- a/.repos/alchemy-effect/.vendor/sveltekit +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f36b3a4cd307bd8c89e03fa191becc175e7b3077 diff --git a/.repos/alchemy-effect/.vendor/waku b/.repos/alchemy-effect/.vendor/waku deleted file mode 160000 index 39e868f2638c27de9635c68ebf965e14a4112e9b..0000000000000000000000000000000000000000 --- a/.repos/alchemy-effect/.vendor/waku +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 39e868f2638c27de9635c68ebf965e14a4112e9b diff --git a/.repos/alchemy-effect/.vendor/workers-sdk b/.repos/alchemy-effect/.vendor/workers-sdk deleted file mode 160000 index 99eb50ce1d3420a50ae0e95958bf49d65874706e..0000000000000000000000000000000000000000 --- a/.repos/alchemy-effect/.vendor/workers-sdk +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 99eb50ce1d3420a50ae0e95958bf49d65874706e diff --git a/.repos/alchemy-effect/.vscode/settings.json b/.repos/alchemy-effect/.vscode/settings.json index 5aabc40bab620d392e02c0845b256ebd0e43aeed..c580ed95ed63f6cb2af0f0fa69e2e21648e2ae03 100644 --- a/.repos/alchemy-effect/.vscode/settings.json +++ b/.repos/alchemy-effect/.vscode/settings.json @@ -47,8 +47,8 @@ "files.exclude": { "**/__pycache__": true, "**/*.pyc": true, - "packages/alchemy/bin/**/*.js*": true, - "packages/alchemy/bin/**/*.d*": true + "packages/alchemy/bin/**/*.d*": true, + "packages/alchemy/bin/**/*.map": true, }, // The client watcher feeds the tsgo LSP (workspace/didChangeWatchedFiles); // every unexcluded change storm forces a session-snapshot clone + program @@ -56,21 +56,19 @@ // program input: agent worktrees (.claude is ~69GB incl. nested // node_modules), vendored submodules, test logs/tmp bundles, and installs. "files.watcherExclude": { - "**/distilled/**": true, + "**/submodules/**": true, "**/.claude/**": true, - "**/.vendor/**": true, "**/.alchemy/**": true, "**/node_modules/**": true, "**/.git/**": true }, "search.exclude": { - "**/distilled/**": true, + "**/submodules/**": true, "**/.claude/**": true, - "**/.vendor/**": true, "**/.alchemy/**": true }, // Keep the 200MB generated distilled surface out of the auto-import index. - "typescript.preferences.autoImportFileExcludePatterns": ["**/distilled/**"], + "typescript.preferences.autoImportFileExcludePatterns": [ "**/submodules/**" ], "[typescript]": { "editor.indentSize": 2, "editor.defaultFormatter": "oxc.oxc-vscode" diff --git a/.repos/alchemy-effect/AGENTS.md b/.repos/alchemy-effect/AGENTS.md index 614ac96f6c4c3004cdec59a3eeb91872c85b0b44..60add368775e4b55daa3baa2d7e1e0a473d24e86 100644 --- a/.repos/alchemy-effect/AGENTS.md +++ b/.repos/alchemy-effect/AGENTS.md @@ -48,6 +48,11 @@ A Resource Provider implements the following Lifecycle Operations: # File System Conventions +First-class sibling repositories we maintain live in `submodules/`: + +- `submodules/distilled` — generated Effect SDKs (workspace packages). Initialized by `git submodule update --init`. +- `submodules/floci` — our fork of the local AWS emulator. Skipped by default; fetch with `git submodule update --init --checkout -- submodules/floci`. + Each Service's Resources follow the same pattern. Resource contract and provider are co-located in the same file. Each Capability lives in its own file(s) named after the capability and access level (`Binding.Service` contract + the `*Binding` / `*Http` implementations). ```sh @@ -108,9 +113,9 @@ Alchemy resource coverage is produced as a **software factory**: fleets of agent update statuses <── regenerate service <── patch distilled ``` -1. **Catalog** — fan out research agents over the provider's distilled service modules (one batch per thematic group). Each agent reads the generated SDK (`distilled/packages/{cloud}/src/services/{service}.ts`), cross-references the vendor API docs, and writes a self-contained design spec to `processes/{Cloud}/catalog/{service}.md`: resources, namespaces, props/attrs with replacement rules, lifecycle-to-operation mapping, scope (account/zone), testability, priority. The coordinator aggregates a machine-readable `summary.json` + human `INDEX.md` that tracks `implemented | partial | missing` per resource — this is the factory's order book. +1. **Catalog** — fan out research agents over the provider's distilled service modules (one batch per thematic group). Each agent reads the generated SDK (`submodules/distilled/packages/{cloud}/src/services/{service}.ts`), cross-references the vendor API docs, and writes a self-contained design spec to `processes/{Cloud}/catalog/{service}.md`: resources, namespaces, props/attrs with replacement rules, lifecycle-to-operation mapping, scope (account/zone), testability, priority. The coordinator aggregates a machine-readable `summary.json` + human `INDEX.md` that tracks `implemented | partial | missing` per resource — this is the factory's order book. 2. **Implement + test** in waves (below). Tests run against the real cloud (`pnpm test --profile testing`); zone-scoped tests use the standing test zone (`alchemy-test-2.us` via `findZoneByName`). -3. **Patch the SDK, never the consumer** — every `UnknownCloudflareError`, out-of-union status error, or wrong request/response schema found by a test becomes an RFC 6902 JSON Patch against the service's Smithy model, under `distilled/packages/{cloud}/patches/{service}/{op}.json` (see the Typed Error Doctrine section). Regenerate only that service. The typed union improves for every future consumer of the SDK — that is the flywheel's output. +3. **Patch the SDK, never the consumer** — every `UnknownCloudflareError`, out-of-union status error, or wrong request/response schema found by a test becomes an RFC 6902 JSON Patch against the service's Smithy model, under `submodules/distilled/packages/{cloud}/patches/{service}/{op}.json` (see the Typed Error Doctrine section). Regenerate only that service. The typed union improves for every future consumer of the SDK — that is the flywheel's output. 4. **Update the catalog** statuses after each wave and pick the next batch from the order book. Repeat until everything left is documented as out of scope (deprecated APIs, billing/data-only endpoints, closed-beta, needs-external-systems). ## Orchestration rules (the coordinator) @@ -183,7 +188,7 @@ pnpm docs:gen # -> website/src/content/docs/providers/{Cloud}/{Resource}.md This is the only doc generator that produces user-facing output. ([scripts/generate-api-reference.ts](./scripts/generate-api-reference.ts)) does the following: 1. Discovers documented files across its configured source roots — `packages/alchemy/src/{Cloud}/{Service}/` plus flat single-provider packages like `packages/better-auth/src/` (mapped onto a synthetic provider directory, e.g. `BetterAuth/`) -2. Parses TypeScript with `ts-morph` +2. Parses TypeScript with the native TypeScript API (`typescript-api` tooling alias) 3. Extracts the page-level summary plus Markdown section/example blocks from JSDoc on the export tagged `@resource`, `@binding`, or `@layer` 4. Writes one markdown file per page at `website/src/content/docs/providers/{Provider}/{Name}.md` @@ -712,7 +717,7 @@ See the [VPC Smoke Test](./test/AWS/EC2/Vpc.smoke.test.ts) for an example. ## How distilled is built (Smithy + JSON Patch) -Distilled is a Smithy-based SDK factory. Every provider package (`distilled/packages/{cloud}`) runs the same pipeline: +Distilled is a Smithy-based SDK factory. Every provider package (`submodules/distilled/packages/{cloud}`) runs the same pipeline: 1. **Convert** — the provider's spec source is converted into Smithy 2.0 JSON models, one per service, in `.generated-specs/{service}.json`. Cloudflare mines them from the downloaded API docs (`scripts/spec-to-smithy.ts` over `specs/api/resources/**`); AWS consumes the official `api-models-aws` Smithy models submodule directly. Hand-authored models for APIs the spec source doesn't cover live in `manual-specs/`. 2. **Patch** — an **RFC 6902 JSON Patch chain** (files shaped `{ "description": ..., "patches": [ops] }`) is applied to the provider's intermediary spec before codegen. For Cloudflare, patches in `patches/{service}/*.json` target the **Smithy model**, applied in filename order with `*.manual.json` files last; `_metadata.json` carries service-level `/metadata/keyDictionary` and `/metadata/opAliases`. OpenAPI-sourced providers (Neon, PlanetScale, Stripe, …) patch the **OpenAPI document** upstream of the smithy conversion instead. A patch whose target path is stale (no longer in the model) warns and is skipped; a malformed patch **fails the generator run**. @@ -730,7 +735,7 @@ Every error a distilled operation can produce in practice MUST be a tagged error **When you hit an unmatched error** (an `UnknownCloudflareError`, or you find yourself wanting to check `CloudflareHttpError.status` or an out-of-union `NotFound`), the fix is ALWAYS a distilled patch, never a catch in alchemy: 1. Note the error's code / status / message from the failure output. -2. Add or extend `distilled/packages/cloudflare/patches/{service}/{operation}.json` with a JSON Patch that (a) adds an error structure carrying the `smithy.api#error` trait and `com.cloudflare.protocols#errorMatchers` matchers, and (b) attaches it to the operation's `errors` list. Use a **meaningful, resource-specific tag** (e.g. `WidgetNotFound`, not a bare `NotFound`): +2. Add or extend `submodules/distilled/packages/cloudflare/patches/{service}/{operation}.json` with a JSON Patch that (a) adds an error structure carrying the `smithy.api#error` trait and `com.cloudflare.protocols#errorMatchers` matchers, and (b) attaches it to the operation's `errors` list. Use a **meaningful, resource-specific tag** (e.g. `WidgetNotFound`, not a bare `NotFound`): ```json { @@ -762,10 +767,10 @@ Every error a distilled operation can produce in practice MUST be a tagged error If the operation already has an `errors` array (from an earlier patch), append with `"path": ".../errors/-"` instead of adding the whole array. Matchers may combine `code`, `status`, and `message` (a string, or `{ "includes": "..." }` / `{ "matches": "..." }`) — e.g. `[{ "status": 400, "message": { "includes": "snippet not found" } }]` when Cloudflare misuses 400 for a missing resource. Prefer matching the Cloudflare error `code` when one exists; fall back to `status` + `message` otherwise. The most specific matcher wins; ties break by declaration order. -3. Regenerate ONLY that service: `cd distilled/packages/cloudflare && bun scripts/generate.ts --resource {service}` (then format: `pnpm exec oxfmt src/services/{service}.ts`). A warned-stale or failed patch is a bug in your patch — fix it; never leave a red generate. +3. Regenerate ONLY that service: `cd submodules/distilled/packages/cloudflare && bun scripts/generate.ts --resource {service}` (then format: `pnpm exec oxfmt src/services/{service}.ts`). A warned-stale or failed patch is a bug in your patch — fix it; never leave a red generate. 4. Handle the now-typed tag in alchemy code and re-run the tests. -**AWS is the one exception to the JSON Patch format**: it layers typed-error metadata over the official Smithy models with a per-service schema file `distilled/packages/aws/patches/{service}.json` (error categories, aliases, synthetic errors with message matchers — see `distilled/packages/aws/scripts/spec-schema.ts`), regenerated with `cd distilled/packages/aws && bun scripts/generate.ts --sdk {service}`. The doctrine is identical; only the patch dialect differs. +**AWS is the one exception to the JSON Patch format**: it layers typed-error metadata over the official Smithy models with a per-service schema file `submodules/distilled/packages/aws/patches/{service}.json` (error categories, aliases, synthetic errors with message matchers — see `submodules/distilled/packages/aws/scripts/spec-schema.ts`), regenerated with `cd submodules/distilled/packages/aws && bun scripts/generate.ts --sdk {service}`. The doctrine is identical; only the patch dialect differs. **Forbidden patterns** — these defeat the type system and must never appear in alchemy code or tests: @@ -808,7 +813,7 @@ Engine semantics (never re-implement these per provider): ## `LocalProvider.make` — long-running local providers -A local provider whose physical resource is a **running process** (dev server, workerd instance) MUST be built with `LocalProvider.make(cls, serverEntryUrl, spec)` — do not hand-roll FiberMap/instance-registry/hash machinery in the provider: +A local provider whose physical resource is a **running process** (dev server, workerd instance) MUST be built with `LocalProvider.make(cls, providersUrl, spec)` — do not hand-roll FiberMap/instance-registry/hash machinery in the provider: - **`resolveConfig(ctx)`** — the restart surface. Plain, canonically-hashable data only (no closures or runtime objects: derive plain *descriptors* here and materialize `BindingHook`s etc. inside `start` — see the descriptor/hook split in [LocalWorkerProvider.ts](./packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts)). Must be cheap and side-effect-free — it runs inside `diff` on every plan. Its canonical hash decides noop-vs-restart AND the same value is handed to `start`, so "what changed?" and "what starts?" can never drift. Deliberately EXCLUDE runtime wiring observed at start time (e.g. queue consumers read from `LocalRuntimeState`) — sibling reconciles drive those via restart hooks, not config. - **`start(ctx)`** — boot ONE instance in the ambient `Scope` and return Attributes at *readiness*; the process keeps running until the runner closes the scope on restart/delete. For processes that can die on their own, fork `ctx.invalidate` after the exit so the next plan reports `update`. @@ -1067,7 +1072,7 @@ Ironing out the AWS suite is an iterative loop, driven by a coordinator, that te Each iteration: -0. **Clean slate** — first run `aws sso login` (the alchemy `testing` profile and the raw `aws` CLI ride the same SSO session; an expired token mid-round breaks the pipeline with auth errors — only escalate to a human if the login doesn't complete automatically). Then `pnpm nuke --yes` (deletes every alchemy-tagged cloud resource; `scripts/nuke.sh` already spares state buckets, SSO roles, and AWS-managed singletons) then `pnpm alchemy state clear ./stacks/nuke.ts --profile testing --yes`. Never overlap nuke with a running suite. Plain `pnpm clear:state` lacks the profile and dies on expired Cloudflare OAuth; nuke without `--yes` hangs on an interactive confirm in non-interactive shells. +0. **Clean slate** — first run `aws sso login` (the alchemy `testing` profile and the raw `aws` CLI ride the same SSO session; an expired token mid-round breaks the pipeline with auth errors — only escalate to a human if the login doesn't complete automatically). Then `pnpm nuke --yes` (deletes every alchemy-tagged cloud resource; `scripts/nuke.sh` already spares state buckets, SSO roles, and AWS-managed singletons) then `pnpm alchemy state delete Nuke --config ./stacks/nuke.ts --profile testing --recursive`. Never overlap nuke with a running suite. Plain `pnpm clear:state` lacks the profile and dies on expired Cloudflare OAuth; nuke without `--yes` hangs on an interactive confirm in non-interactive shells. 1. **Full suite, bounded** — `pnpm test test/AWS --profile testing`. The runner defaults to `--concurrency 32`; NEVER override it to `unbounded` on a full-suite run: all ~775 files' `beforeAll` deploys start at once, the event loop saturates, and hundreds of fake 0ms `beforeAll TimeoutError` failures drown the real signal (heap is ~8.5 GB regardless of N — the constraint is CPU, not memory). Target ≤10 min wall-clock, hard cap 128; measured 32 → ~21 min clean. The saturation tell is `beforeAll` failures at 0ms; real failures fail slow. If the cap can't reach 10 min, the residual is individual slow files — skipIf-gate them per the speed doctrine. 2. **Leak census** — `pnpm nuke --dry-run` after the suite; diff against the pre-suite baseline. Worklist = **failed services ∪ leaking services** (a service can pass green and still leak). Leave the leaked resources LIVE as forensic evidence for the fix agents; carry-over holdouts that survive repeated nuke passes (stuck deletes) go on the worklist too — their delete path is the bug. 3. **Fix-fleet workflow** — one agent per service on the worklist (account-singleton services — CloudTrail, Config, SecurityHub, GuardDuty, ControlTower, IdentityCenter — run as a sequential chain; everything else fans out). Each agent gets its exact failures, its leak inventory, and this root-cause priority: **provider bug > distilled patch > test fix** — never paper over a provider leak in the test. Each agent runs ONLY its own suite (`timeout 240 pnpm test test/AWS/{Service} --profile testing`), audits its tests for non-deterministic names (rely on PhysicalName auto-naming; random data in message payloads/idempotency tokens is fine), verifies zero orphans from its service via out-of-band distilled list/describe calls, and reports a structured result. Agents never run tsc/build and never run the account-wide nuke. diff --git a/.repos/alchemy-effect/CHANGELOG.md b/.repos/alchemy-effect/CHANGELOG.md index 5a63bba5ae119ac32696068ba0392b6b5ccc1a7b..b65f238cafa507fa1b8da5d8019c406781acb240 100644 --- a/.repos/alchemy-effect/CHANGELOG.md +++ b/.repos/alchemy-effect/CHANGELOG.md @@ -1,3 +1,207 @@ +## v2.0.0-beta.78 + +###    🚨 Breaking Changes + +- **aws**: Preserve explicit security group egress intent  -  by **Henning Pokriefke** in https://github.com/alchemy-run/alchemy/issues/1623 [(d438b)](https://github.com/alchemy-run/alchemy/commit/d438b9d8b) +- **git**: Expose Git operations for native Effect APIs  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1621 [(bd021)](https://github.com/alchemy-run/alchemy/commit/bd0211912) + +###    🚀 Features + +- **aws**: + - **s3**: Manage blocked encryption types  -  by **Bjorn Pagen** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1588 [(3f97d)](https://github.com/alchemy-run/alchemy/commit/3f97df5f2) +- **cloudflare**: + - **workers**: Worker Previews  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1563 [(eae69)](https://github.com/alchemy-run/alchemy/commit/eae693613) +- **cloudflare-runtime**: + - Replace the workerd proxy with a layer-4 relay  -  by **BlankParticle** in https://github.com/alchemy-run/alchemy/issues/1612 [(9a9b6)](https://github.com/alchemy-run/alchemy/commit/9a9b60179) +- **core**: + - Keep engine/AWS/distilled out of dev processes  -  by **BlankParticle** in https://github.com/alchemy-run/alchemy/issues/1614 [(bad83)](https://github.com/alchemy-run/alchemy/commit/bad831409) +- **dev**: + - Serve every local provider group from one sidecar  -  by **BlankParticle** in https://github.com/alchemy-run/alchemy/issues/1613 [(38f27)](https://github.com/alchemy-run/alchemy/commit/38f2714f7) +- **git**: + - Git hosting on Workers, Durable Objects, and R2  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1187 [(9e2f8)](https://github.com/alchemy-run/alchemy/commit/9e2f88577) +- **github**: + - Add Label resource  -  by **Agusti F.** in https://github.com/alchemy-run/alchemy/issues/1565 [(d49c2)](https://github.com/alchemy-run/alchemy/commit/d49c25853) + - Add Milestone resource  -  by **Agusti F.** in https://github.com/alchemy-run/alchemy/issues/1566 [(4c911)](https://github.com/alchemy-run/alchemy/commit/4c911a504) + - Add Issue resource  -  by **Agusti F.** in https://github.com/alchemy-run/alchemy/issues/1568 [(6911c)](https://github.com/alchemy-run/alchemy/commit/6911c4090) + - Add PullRequest resource  -  by **Agusti F.** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1569 [(19c1b)](https://github.com/alchemy-run/alchemy/commit/19c1bf400) + - Add Release resource  -  by **Agusti F.** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1577 [(87801)](https://github.com/alchemy-run/alchemy/commit/87801a9c4) + - Add TeamAccess resource  -  by **Agusti F.** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1572 [(9585e)](https://github.com/alchemy-run/alchemy/commit/9585ed6ff) + - Add BranchProtection resource  -  by **99andytang** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1514 [(527e7)](https://github.com/alchemy-run/alchemy/commit/527e7c4bb) + - Add Ruleset resource  -  by **Agusti F.** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1570 [(113f5)](https://github.com/alchemy-run/alchemy/commit/113f5c194) + - Add Collaborator resource  -  by **Agusti F.** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1571 [(16847)](https://github.com/alchemy-run/alchemy/commit/168470bcd) + - Add WikiPage resource  -  by **Agusti F.** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1578 [(8d617)](https://github.com/alchemy-run/alchemy/commit/8d617fca2) +- **node-utils**: + - Cache oxc transforms and run the CLI launcher in place  -  by **BlankParticle** in https://github.com/alchemy-run/alchemy/issues/1615 [(6087e)](https://github.com/alchemy-run/alchemy/commit/6087e5289) +- **pkg**: + - Preview package registry verified against GitHub Actions runs  -  by **BlankParticle** in https://github.com/alchemy-run/alchemy/issues/1516 [(b7fc7)](https://github.com/alchemy-run/alchemy/commit/b7fc701fe) +- **prisma**: + - Consume @distilled.cloud/prisma-postgres instead of the hand-rolled client  -  by **Will Madden** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1290 [(f7340)](https://github.com/alchemy-run/alchemy/commit/f73403bc0) + - Deploy static apps with Compute  -  by **Aman Varshney** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1208 [(35193)](https://github.com/alchemy-run/alchemy/commit/351932777) +- **railway**: + - Migrate reconcilers to selective GraphQL  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1604 [(d0b83)](https://github.com/alchemy-run/alchemy/commit/d0b83b2e0) + - Add sandbox domains, sizing, forks, and checkpoints  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1661 [(41cdf)](https://github.com/alchemy-run/alchemy/commit/41cdf897d) +- **stripe**: + - Add Stripe resources, auth, and nuke wiring  -  by **Michael K** in https://github.com/alchemy-run/alchemy/issues/1298 [(0d9a5)](https://github.com/alchemy-run/alchemy/commit/0d9a58c85) +- **test**: + - Default Test.make stage to test_$USER  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1579 [(d2185)](https://github.com/alchemy-run/alchemy/commit/d218541b2) + +###    🐞 Bug Fixes + +- Effect rc.113 proxyChain/BunHttpServer regressions; AWS destroy after drifted state  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1576 [(9ee79)](https://github.com/alchemy-run/alchemy/commit/9ee794539) +- DSQL SNI (sql-pg rc.113), ECS Service delete on a draining service, Nextjs preview proxy secret  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1580 [(92d04)](https://github.com/alchemy-run/alchemy/commit/92d0480ad) +- Sql-pg rc.113 sslmode=prefer, Prisma.Database branch attachment  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1582 [(42c3f)](https://github.com/alchemy-run/alchemy/commit/42c3ffc47) +- Resolve provider modes and repair integration test failures  -  by **BlankParticle** in https://github.com/alchemy-run/alchemy/issues/1646 [(c6c58)](https://github.com/alchemy-run/alchemy/commit/c6c58baa5) +- Outdated tests and regressions  -  by **BlankParticle** in https://github.com/alchemy-run/alchemy/issues/1659 [(d05b2)](https://github.com/alchemy-run/alchemy/commit/d05b217cb) +- Resolve dev sidecar through package exports  -  by **BlankParticle** in https://github.com/alchemy-run/alchemy/issues/1678 [(6d803)](https://github.com/alchemy-run/alchemy/commit/6d8030273) +- Resolve release validation failures  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1671 [(5bd89)](https://github.com/alchemy-run/alchemy/commit/5bd8968b7) +- Align distilled pnpm version for CI builds  -  by **BlankParticle** [(edb81)](https://github.com/alchemy-run/alchemy/commit/edb8192a6) +- **aws**: + - Restore missing ownership tags in AWS Organizations  -  by **Henning Pokriefke** in https://github.com/alchemy-run/alchemy/issues/1637 [(b66be)](https://github.com/alchemy-run/alchemy/commit/b66bef23b) + - Preserve standalone security group rules  -  by **Henning Pokriefke** in https://github.com/alchemy-run/alchemy/issues/1624 [(3cbc9)](https://github.com/alchemy-run/alchemy/commit/3cbc9b3dc) + - Enforce S3 and security group desired state  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1664 [(66c94)](https://github.com/alchemy-run/alchemy/commit/66c944949) + - Replace fixed-IP EC2 instances delete-first  -  by **Sam Goodwin** and **dawson** in https://github.com/alchemy-run/alchemy/issues/1667 [(2a615)](https://github.com/alchemy-run/alchemy/commit/2a61544e2) + - **ec2**: + - Reconcile security-group rule deltas  -  by **Bjorn Pagen** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1592 [(fae76)](https://github.com/alchemy-run/alchemy/commit/fae766993) + - **ecr**: + - Skip updates for relocated image contexts  -  by **Bjorn Pagen** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1591 [(bb4be)](https://github.com/alchemy-run/alchemy/commit/bb4be0f11) + - **logs**: + - Normalize log group ARN from state before tagging calls  -  by **Evan Spaeder** in https://github.com/alchemy-run/alchemy/issues/1575 [(c17cb)](https://github.com/alchemy-run/alchemy/commit/c17cb45c7) + - **rds**: + - Enforce desired parameter group values  -  by **Bjorn Pagen** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1589 [(461a5)](https://github.com/alchemy-run/alchemy/commit/461a5c574) + - Repair parameter group drift with desired defaults  -  by **Bjorn Pagen** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1590 [(7c8fc)](https://github.com/alchemy-run/alchemy/commit/7c8fc7bf0) + - Reconcile storage autoscaling defaults  -  by **Bjorn Pagen** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1593 [(076ef)](https://github.com/alchemy-run/alchemy/commit/076eff8b4) + - Include coupled storage modification fields  -  by **Bjorn Pagen** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1594 [(d2c06)](https://github.com/alchemy-run/alchemy/commit/d2c0699ca) + - Compare the observed endpoint port  -  by **Bjorn Pagen** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1595 [(7c3eb)](https://github.com/alchemy-run/alchemy/commit/7c3ebed5c) + - Skip unchanged instance associations  -  by **Bjorn Pagen** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1596 [(82e82)](https://github.com/alchemy-run/alchemy/commit/82e829ffa) + - **s3**: + - Check bucket ownership with GetBucketLocation  -  by **Bjorn Pagen** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1585 [(8ac81)](https://github.com/alchemy-run/alchemy/commit/8ac81b2c1) + - Propagate configuration read failures  -  by **Bjorn Pagen** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1586 [(640c6)](https://github.com/alchemy-run/alchemy/commit/640c65b7c) + - Compare decoded encryption key values  -  by **Bjorn Pagen** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1587 [(899bc)](https://github.com/alchemy-run/alchemy/commit/899bc357e) +- **cli**: + - Preserve js extension for exec bundle  -  by **BlankParticle** in https://github.com/alchemy-run/alchemy/issues/1625 [(88339)](https://github.com/alchemy-run/alchemy/commit/883398af5) + - Isolate the React renderer from consumer dependencies  -  by **BlankParticle** in https://github.com/alchemy-run/alchemy/issues/1636 [(c83b4)](https://github.com/alchemy-run/alchemy/commit/c83b45421) + - Respect detected terminal color support  -  by **BlankParticle** in https://github.com/alchemy-run/alchemy/issues/1679 [(75f93)](https://github.com/alchemy-run/alchemy/commit/75f93a4ee) +- **cloudflare**: + - Forward crons to Vite dev workers  -  by **zawaki** in https://github.com/alchemy-run/alchemy/issues/1641 [(0aab5)](https://github.com/alchemy-run/alchemy/commit/0aab5723d) + - Forward container memoryMib  -  by **Dan van der Merwe** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1521 [(b96d4)](https://github.com/alchemy-run/alchemy/commit/b96d494e2) + - Plan replacements for cached container identity mismatches  -  by **Dan van der Merwe** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1523 [(0f26a)](https://github.com/alchemy-run/alchemy/commit/0f26aa54b) + - Export container builds directly to registries  -  by **Dan van der Merwe** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1524 [(c74a3)](https://github.com/alchemy-run/alchemy/commit/c74a3833a) + - Deduplicate container image publication  -  by **Dan van der Merwe** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1525 [(27536)](https://github.com/alchemy-run/alchemy/commit/275361dfe) + - Preserve unresolved container attachments  -  by **Dan van der Merwe** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1150 [(d9d7f)](https://github.com/alchemy-run/alchemy/commit/d9d7fa234) + - Expose the Worker bridge from a runtime-only entry  -  by **Alex** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1643 [(fc0a2)](https://github.com/alchemy-run/alchemy/commit/fc0a24130) + - Recover interrupted creates by generated identity  -  by **Dan van der Merwe** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1526 [(bd83d)](https://github.com/alchemy-run/alchemy/commit/bd83dd948) + - **r2**: Forward put options on the stream path  -  by **BlankParticle** in https://github.com/alchemy-run/alchemy/issues/1602 [(34917)](https://github.com/alchemy-run/alchemy/commit/34917080f) + - **worker**: Restore request URL through trusted proxy  -  by **BlankParticle** in https://github.com/alchemy-run/alchemy/issues/1573 [(da987)](https://github.com/alchemy-run/alchemy/commit/da987815e) + - **workers**: Type named entrypoint bindings with their RPC surface  -  by **Michael K** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1415 [(8d3f9)](https://github.com/alchemy-run/alchemy/commit/8d3f92ea2) +- **cloudflare-runtime**: + - Read registry mtime with node:fs.stat  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1564 [(ca421)](https://github.com/alchemy-run/alchemy/commit/ca4216f00) + - Pull the egress interceptor host-native  -  by **Erik Müller** in https://github.com/alchemy-run/alchemy/issues/1459 [(b7505)](https://github.com/alchemy-run/alchemy/commit/b7505d5e6) +- **deps**: + - Update to effect 4.0.0-rc.113  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1562 [(4340a)](https://github.com/alchemy-run/alchemy/commit/4340adad4) + - Effect 4.0.0-rc.115; drop the sql-pg SNI shim  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1601 [(ba30b)](https://github.com/alchemy-run/alchemy/commit/ba30b9efc) + - Upgrade Foldkit for Effect RC 115  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1618 [(4343f)](https://github.com/alchemy-run/alchemy/commit/4343fc985) +- **docker**: + - Fall back to build + push on Buildx < 0.26 registry exports  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1685 [(c37b0)](https://github.com/alchemy-run/alchemy/commit/c37b0414f) +- **hetzner**: + - Recover cleanup after interrupted server creation  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1662 [(fca23)](https://github.com/alchemy-run/alchemy/commit/fca232564) +- **node-utils**: + - Exclude the root's own node_modules from the Bun dev probe  -  by **Austin** in https://github.com/alchemy-run/alchemy/issues/1617 [(80c71)](https://github.com/alchemy-run/alchemy/commit/80c71fe8d) +- **railway**: + - Mount volumes and wait for the uploaded Service deploy  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1584 [(361f5)](https://github.com/alchemy-run/alchemy/commit/361f53df7) + - Import the native SDK from the package root  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1620 [(18156)](https://github.com/alchemy-run/alchemy/commit/18156acbc) + - Use environment-managed private networking  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1682 [(c197c)](https://github.com/alchemy-run/alchemy/commit/c197cd0a5) +- **state**: + - Keep HTTP state payloads out of errors  -  by **Dan van der Merwe** in https://github.com/alchemy-run/alchemy/issues/1522 [(f8954)](https://github.com/alchemy-run/alchemy/commit/f8954086d) + +#####     [View changes on GitHub](https://github.com/alchemy-run/alchemy/compare/v2.0.0-beta.77...HEAD) + +--- + +## v2.0.0-beta.77 + +###    🚨 Breaking Changes + +- Profiles overhaul  -  by **Rahul Mishra** in https://github.com/alchemy-run/alchemy/issues/1234 [(af2b9)](https://github.com/alchemy-run/alchemy/commit/af2b9f338) +- Overhaul the Alchemy CLI  -  by **Rahul Mishra** in https://github.com/alchemy-run/alchemy/issues/1235 [(04b83)](https://github.com/alchemy-run/alchemy/commit/04b8366ac) +- **cli**: + - Breaking change, default deploy stage is live_$USER  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1421 [(df6be)](https://github.com/alchemy-run/alchemy/commit/df6be201e) + - Honor ALCHEMY_STAGE instead of STAGE  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1433 [(0f8b2)](https://github.com/alchemy-run/alchemy/commit/0f8b2bd62) + +###    🚀 Features + +- CliKit — a terminal UI layer for the CLI on @alchemy.run/sigil  -  by **Rahul Mishra** in https://github.com/alchemy-run/alchemy/issues/1233 [(bf4d2)](https://github.com/alchemy-run/alchemy/commit/bf4d23590) +- Refactor interaction/auth flows, run ts/tsx files directly under node  -  by **Rahul Mishra** in https://github.com/alchemy-run/alchemy/issues/1430 [(416de)](https://github.com/alchemy-run/alchemy/commit/416de9dd0) +- Bump distilled to 78a9304 (verbNoun operation names, Railway selection fixes)  -  by **Michael K** in https://github.com/alchemy-run/alchemy/issues/1517 [(047db)](https://github.com/alchemy-run/alchemy/commit/047dbb7f1) +- **aws**: + - Add provisioned ElastiCache resources  -  by **Henning Pokriefke** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1422 [(dc983)](https://github.com/alchemy-run/alchemy/commit/dc9831caa) +- **cli**: + - Profile tab scrolling, --profile flag, v0 store migration  -  by **Rahul Mishra** in https://github.com/alchemy-run/alchemy/issues/1429 [(c2e8f)](https://github.com/alchemy-run/alchemy/commit/c2e8ff0cc) + - Unify plan and dev output presentation  -  by **Rahul Mishra** in https://github.com/alchemy-run/alchemy/issues/1458 [(fe7d1)](https://github.com/alchemy-run/alchemy/commit/fe7d1e3db) + - Use oxc for typescript, better reloading of stacks  -  by **Rahul Mishra** in https://github.com/alchemy-run/alchemy/issues/1461 [(37322)](https://github.com/alchemy-run/alchemy/commit/37322bb96) + - Refactor auth profiles into provider files  -  by **Rahul Mishra** in https://github.com/alchemy-run/alchemy/issues/1475 [(486bb)](https://github.com/alchemy-run/alchemy/commit/486bbe9c0) + - Standardize auth provider methods, env precedence, drop AWS local  -  by **Rahul Mishra** in https://github.com/alchemy-run/alchemy/issues/1480 [(22b41)](https://github.com/alchemy-run/alchemy/commit/22b41856d) + - Pointer-focus TUI, tcut demo, and the beta.77 release blog  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1547 [(8aecc)](https://github.com/alchemy-run/alchemy/commit/8aecc3e91) +- **cloudflare**: + - Add Cloudflare.Telemetry native tracing layer  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1444 [(e05c7)](https://github.com/alchemy-run/alchemy/commit/e05c734ea) + - Add abort retryAlarm on DurableObjectState  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1374 [(b357f)](https://github.com/alchemy-run/alchemy/commit/b357f7119) + - Add webSocketError to the Durable Object shape and bridge  -  by **Alex** in https://github.com/alchemy-run/alchemy/issues/1497 [(84bb1)](https://github.com/alchemy-run/alchemy/commit/84bb1ad76) + - **workflows**: Support native cron schedules  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1491 [(533df)](https://github.com/alchemy-run/alchemy/commit/533dfb5bb) +- **cloudflare-runtime**: + - Update workerd and vendored Workers runtime sources  -  by **Rahul Mishra** in https://github.com/alchemy-run/alchemy/issues/1442 [(59ede)](https://github.com/alchemy-run/alchemy/commit/59ede0883) +- **fly**: + - Support health checks  -  by **Adam Svystun** in https://github.com/alchemy-run/alchemy/issues/1463 [(0f814)](https://github.com/alchemy-run/alchemy/commit/0f814dfca) +- **railway**: + - Support service pre-deploy commands  -  by **Dallen Pyrah** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1438 [(d76d7)](https://github.com/alchemy-run/alchemy/commit/d76d7be7b) + - Support private services  -  by **Dallen Pyrah** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1439 [(2ceae)](https://github.com/alchemy-run/alchemy/commit/2ceae5476) + - Support local Docker contexts  -  by **Dallen Pyrah** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1440 [(59573)](https://github.com/alchemy-run/alchemy/commit/59573d7c8) + +###    🐞 Bug Fixes + +- Expose the Worker resource's ref method in its type  -  by **pollux** in https://github.com/alchemy-run/alchemy/issues/1434 [(74e5f)](https://github.com/alchemy-run/alchemy/commit/74e5f36b2) +- Resolve example test failures  -  by **Rahul Mishra** in https://github.com/alchemy-run/alchemy/issues/1527 [(63236)](https://github.com/alchemy-run/alchemy/commit/632360a5e) +- **apply**: + - Persist downstream edges on noop resources  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1552 [(5463c)](https://github.com/alchemy-run/alchemy/commit/5463ca826) +- **auth**: + - Deduplicate environment credential notices  -  by **Rahul Mishra** in https://github.com/alchemy-run/alchemy/issues/1520 [(233a5)](https://github.com/alchemy-run/alchemy/commit/233a5aff8) +- **aws**: + - **organizations**: Unwrap Redacted account name and email  -  by **Michael K** in https://github.com/alchemy-run/alchemy/issues/1556 [(f2957)](https://github.com/alchemy-run/alchemy/commit/f2957a194) +- **cli**: + - Stream apply notes in plain mode and stop wrapping log lines  -  by **Rahul Mishra** in https://github.com/alchemy-run/alchemy/issues/1423 [(e0d72)](https://github.com/alchemy-run/alchemy/commit/e0d72263a) + - Prompt for approval in plain interactive mode  -  by **Rahul Mishra** in https://github.com/alchemy-run/alchemy/issues/1426 [(bd74f)](https://github.com/alchemy-run/alchemy/commit/bd74f6880) + - Show feedback during slow shutdowns  -  by **Rahul Mishra** and **Claude Fable 5** in https://github.com/alchemy-run/alchemy/issues/1427 [(695ba)](https://github.com/alchemy-run/alchemy/commit/695ba55ce) + - Bindings state, persist plan collapse state  -  by **Rahul Mishra** in https://github.com/alchemy-run/alchemy/issues/1492 [(743e6)](https://github.com/alchemy-run/alchemy/commit/743e64013) + - Restore nuke progress and bulk state cleanup  -  by **Rahul Mishra** in https://github.com/alchemy-run/alchemy/issues/1532 [(55f7c)](https://github.com/alchemy-run/alchemy/commit/55f7c0999) + - Virtualize the profile dashboard's provider list  -  by **Rahul Mishra** in https://github.com/alchemy-run/alchemy/issues/1533 [(ab83e)](https://github.com/alchemy-run/alchemy/commit/ab83e7872) + - Honor --profile on cloudflare bootstrap  -  by **Michael K** in https://github.com/alchemy-run/alchemy/issues/1538 [(93b3a)](https://github.com/alchemy-run/alchemy/commit/93b3a7ef9) + - Update Sigil to fix terminal input corruption  -  by **Rahul Mishra** in https://github.com/alchemy-run/alchemy/issues/1545 [(b979f)](https://github.com/alchemy-run/alchemy/commit/b979ffc6a) +- **cloudflare**: + - Local dev crashes when assets contain a _headers or _redirects file  -  by **Michael** and **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1418 [(35a3b)](https://github.com/alchemy-run/alchemy/commit/35a3b9bcb) + - Preserve RpcWorker logical ID  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1487 [(5ab17)](https://github.com/alchemy-run/alchemy/commit/5ab171d92) + - Wrap WorkerLoader.get() as a WorkerStub  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1488 [(39258)](https://github.com/alchemy-run/alchemy/commit/39258554d) + - Run Durable Object callbacks with the caller's context  -  by **Alex** in https://github.com/alchemy-run/alchemy/issues/1498 [(cf8e0)](https://github.com/alchemy-run/alchemy/commit/cf8e00845) + - Fix broken framework dev/builds/tests  -  by **Rahul Mishra** in https://github.com/alchemy-run/alchemy/issues/1534 [(7e375)](https://github.com/alchemy-run/alchemy/commit/7e3757771) +- **cloudflare-runtime**: + - Bump internal isolate compatibility dates  -  by **Sam Goodwin** in https://github.com/alchemy-run/alchemy/issues/1380 [(18880)](https://github.com/alchemy-run/alchemy/commit/18880d16d) +- **frontend**: + - Don't cache in NodeServe  -  by **Sam Goodwin** [(11c95)](https://github.com/alchemy-run/alchemy/commit/11c95dc04) +- **pr-package**: + - Keep PR previews until the pull request closes  -  by **Michael K** in https://github.com/alchemy-run/alchemy/issues/1511 [(b12d8)](https://github.com/alchemy-run/alchemy/commit/b12d8167b) +- **railway**: + - Bump distilled to drop admin-only platformFeatureFlags  -  by **Rahul Mishra** in https://github.com/alchemy-run/alchemy/issues/1479 [(eadf6)](https://github.com/alchemy-run/alchemy/commit/eadf6d9f7) +- **website**: + - SEO, noindex, canonical, nosnippet & more  -  by **Sam Goodwin** and **Rahul Mishra** in https://github.com/alchemy-run/alchemy/issues/1269 [(09f2a)](https://github.com/alchemy-run/alchemy/commit/09f2a23f1) +- **workflows**: + - Omit undefined step config keys from bridge  -  by **Lord Coughmann** in https://github.com/alchemy-run/alchemy/issues/1416 [(034c0)](https://github.com/alchemy-run/alchemy/commit/034c02885) + +###    🏎 Performance + +- **cloudflare**: + - **workers**: Upload asset buckets concurrently  -  by **Rahul Mishra** in https://github.com/alchemy-run/alchemy/issues/1445 [(90b5d)](https://github.com/alchemy-run/alchemy/commit/90b5df7a6) + +#####     [View changes on GitHub](https://github.com/alchemy-run/alchemy/compare/v2.0.0-beta.76...HEAD) + +--- + ## v2.0.0-beta.76 ###    🚀 Features diff --git a/.repos/alchemy-effect/README.md b/.repos/alchemy-effect/README.md index 90c666bf65fc4655cf2a457f380d36270d198907..49a51385b0b65490f13bc60f4965fc2ba3a540c0 100644 --- a/.repos/alchemy-effect/README.md +++ b/.repos/alchemy-effect/README.md @@ -1,7 +1,7 @@
- Alchemy — Infrastructure as Effects + Alchemy — Infrastructure as Effects
diff --git a/.repos/alchemy-effect/THIRD_PARTY_LICENSES.md b/.repos/alchemy-effect/THIRD_PARTY_LICENSES.md index 49ac99af86883796519b8658a3655dc870a212d4..27cacdac3eaa493685132dd99f45ba1b87775adb 100644 --- a/.repos/alchemy-effect/THIRD_PARTY_LICENSES.md +++ b/.repos/alchemy-effect/THIRD_PARTY_LICENSES.md @@ -6,7 +6,7 @@ respective license notices. ## Astro -Portions of `packages/cloudflare-frameworks/src/astro` are derived from +Portions of `packages/frontend-frameworks/src/astro` are derived from `@astrojs/cloudflare`. Copyright (c) 2021 Fred K. Schott @@ -33,7 +33,7 @@ SOFTWARE. ## SvelteKit -Portions of `packages/cloudflare-frameworks/src/sveltekit` are derived from +Portions of `packages/frontend-frameworks/src/sveltekit` are derived from `@sveltejs/adapter-cloudflare`. Copyright (c) 2020 SvelteKit contributors @@ -43,7 +43,7 @@ Astro section above. ## Waku -Portions of `packages/cloudflare-frameworks/src/waku` are derived from Waku. +Portions of `packages/frontend-frameworks/src/waku` are derived from Waku. Copyright (c) 2023 Daishi Kato @@ -65,7 +65,7 @@ License reproduced in the Astro section above. ## OpenNext for Cloudflare -Portions of `packages/cloudflare-frameworks/src/nextjs`, including the build +Portions of `packages/frontend-frameworks/src/nextjs`, including the build runner and development-context integration, are derived from OpenNext for Cloudflare. @@ -122,17 +122,17 @@ been dedicated to the public domain. See ## Upstream source references -- Astro: -- SvelteKit: -- Waku: -- Cloudflare Workers SDK: -- OpenNext for Cloudflare: +- Astro: [`a2240c43830e29c1d85667f4c610655d6b10253f`](https://github.com/withastro/astro/tree/a2240c43830e29c1d85667f4c610655d6b10253f) +- SvelteKit: [`f36b3a4cd307bd8c89e03fa191becc175e7b3077`](https://github.com/sveltejs/kit/tree/f36b3a4cd307bd8c89e03fa191becc175e7b3077) +- Waku: [`39e868f2638c27de9635c68ebf965e14a4112e9b`](https://github.com/wakujs/waku/tree/39e868f2638c27de9635c68ebf965e14a4112e9b) +- Cloudflare Workers SDK: [`b7b4ff84477982e7c770bb93928287893fcf2e03`](https://github.com/cloudflare/workers-sdk/tree/b7b4ff84477982e7c770bb93928287893fcf2e03) +- OpenNext for Cloudflare: [`da4b7fc8c89441a6f96205635fa70e329d1f0df7`](https://github.com/opennextjs/opennextjs-cloudflare/tree/da4b7fc8c89441a6f96205635fa70e329d1f0df7) - SST: - node-sanitize-filename: - node-ignore: - SQLite: -The external `.vendor` Git submodules are separate works governed by the -license files in their respective repositories. This notice covers code copied +The first-class `submodules/` checkouts (distilled, floci) are separate works +governed by the license files in their respective repositories. This notice covers code copied or adapted into the Alchemy repository; it does not replace the license documentation within those submodules. diff --git a/.repos/alchemy-effect/benchmark/container/package.json b/.repos/alchemy-effect/benchmark/container/package.json index 69db671a0fbf2581c90f05dfea6103db57226973..ad12c471f175329932587f53d45c1c11400aa513 100644 --- a/.repos/alchemy-effect/benchmark/container/package.json +++ b/.repos/alchemy-effect/benchmark/container/package.json @@ -13,19 +13,19 @@ "deploy": "alchemy deploy", "destroy": "alchemy destroy", "logs": "alchemy logs", - "tail": "alchemy tail", + "tail": "alchemy logs --tail", "bench": "bun test test/bench.test.ts" }, "dependencies": { "@distilled.cloud/aws": "workspace:*", - "@effect/platform-bun": "catalog:effect", - "@effect/platform-node": "catalog:effect", + "@effect/platform-bun": "catalog:", + "@effect/platform-node": "catalog:", "alchemy": "workspace:*", - "effect": "catalog:effect" + "effect": "catalog:" }, "devDependencies": { - "@cloudflare/containers": "catalog:cloudflare", - "@cloudflare/workers-types": "catalog:cloudflare-runtime", - "@types/bun": "catalog:tooling" + "@cloudflare/containers": "catalog:", + "@cloudflare/workers-types": "catalog:", + "@types/bun": "catalog:" } } diff --git a/.repos/alchemy-effect/bunfig.toml b/.repos/alchemy-effect/bunfig.toml deleted file mode 100644 index 2ab88ad7a58cc8c66921d14e42b464a8608d6b39..0000000000000000000000000000000000000000 --- a/.repos/alchemy-effect/bunfig.toml +++ /dev/null @@ -1,162 +0,0 @@ -[install] -minimumReleaseAge = 259200 -minimumReleaseAgeExcludes = [ - "astro", - "@astrojs/check", - "@astrojs/mdx", - "@astrojs/react", - "@astrojs/starlight", - "@opentui/core", - "@opentui/core-darwin-arm64", - "@opentui/core-darwin-x64", - "@opentui/core-linux-x64", - "@opentui/core-linux-arm64", - "@opentui/core-linux-x64-musl", - "@opentui/core-linux-arm64-musl", - "@opentui/core-win32-x64", - "@opentui/core-win32-arm64", - "@distilled.cloud/aws", - "@distilled.cloud/axiom", - "@alchemy.run/cloudflare-runtime", - "@alchemy.run/cloudflare-frameworks", - "@distilled.cloud/cloudflare", - "@distilled.cloud/core", - "@distilled.cloud/neon", - "@distilled.cloud/planetscale", - # TODO: remove once we pin a drizzle 1.0.0-rc.5 stable that is >3 days old — - # excluded because we track fresh rc5-branch builds for Effect 4 RC support. - "drizzle-kit", - "drizzle-orm", - "effect", - "fast-check", - "foldkit", - "@foldkit/vite-plugin", - "@effect/platform-browser", - "@effect/atom-react", - "@effect/language-service", - "@effect/platform-bun", - "@effect/platform-node-shared", - "@effect/platform-node", - "@effect/sql-d1", - "@effect/sql-mysql2", - "@effect/sql-pg", - "@effect/sql-sqlite-do", - "@effect/vitest", - "miniflare", - "typescript", - "@typescript/typescript-win32-x64", - "@typescript/typescript-win32-arm64", - "@typescript/typescript-linux-x64", - "@typescript/typescript-linux-arm", - "@typescript/typescript-linux-arm64", - "@typescript/typescript-darwin-x64", - "@typescript/typescript-darwin-arm64", - "@typescript/typescript-aix-ppc64", - "@typescript/typescript-freebsd-arm64", - "@typescript/typescript-freebsd-x64", - "@typescript/typescript-linux-loong64", - "@typescript/typescript-linux-mips64el", - "@typescript/typescript-linux-ppc64", - "@typescript/typescript-linux-riscv64", - "@typescript/typescript-linux-s390x", - "@typescript/typescript-netbsd-arm64", - "@typescript/typescript-netbsd-x64", - "@typescript/typescript-openbsd-arm64", - "@typescript/typescript-openbsd-x64", - "@typescript/typescript-sunos-x64", - # AWS SDK (credentials chain only — distilled implements the API calls - # itself; `@aws-sdk/credential-providers` is our sole direct dependency). - # AWS publishes the whole SDK monorepo near-daily, so caret ranges in - # published alchemy tarballs (installed fresh by e.g. the - # StaticSiteBeta67Migration test, with no lockfile) routinely have range - # minimums younger than the gate and hard-fail with "blocked by - # minimum-release-age". Exact names because this bun ignores wildcard - # patterns in this list (verified empirically); the set is every - # @aws-sdk/@smithy/@aws-crypto name in bun.lock — extend it if an AWS - # refactor introduces a new subpackage. - "@aws-sdk/checksums", - "@aws-sdk/client-cloudfront", - "@aws-sdk/client-dynamodb", - "@aws-sdk/client-lambda", - "@aws-sdk/client-s3", - "@aws-sdk/client-sqs", - "@aws-sdk/core", - "@aws-sdk/credential-provider-cognito-identity", - "@aws-sdk/credential-provider-env", - "@aws-sdk/credential-provider-http", - "@aws-sdk/credential-provider-ini", - "@aws-sdk/credential-provider-login", - "@aws-sdk/credential-provider-node", - "@aws-sdk/credential-provider-process", - "@aws-sdk/credential-provider-sso", - "@aws-sdk/credential-provider-web-identity", - "@aws-sdk/credential-providers", - "@aws-sdk/dynamodb-codec", - "@aws-sdk/endpoint-cache", - "@aws-sdk/middleware-bucket-endpoint", - "@aws-sdk/middleware-endpoint-discovery", - "@aws-sdk/middleware-expect-continue", - "@aws-sdk/middleware-flexible-checksums", - "@aws-sdk/middleware-host-header", - "@aws-sdk/middleware-location-constraint", - "@aws-sdk/middleware-logger", - "@aws-sdk/middleware-recursion-detection", - "@aws-sdk/middleware-sdk-s3", - "@aws-sdk/middleware-sdk-sqs", - "@aws-sdk/middleware-ssec", - "@aws-sdk/middleware-user-agent", - "@aws-sdk/nested-clients", - "@aws-sdk/region-config-resolver", - "@aws-sdk/signature-v4-multi-region", - "@aws-sdk/token-providers", - "@aws-sdk/types", - "@aws-sdk/util-endpoints", - "@aws-sdk/util-locate-window", - "@aws-sdk/util-user-agent-browser", - "@aws-sdk/util-user-agent-node", - "@aws-sdk/xml-builder", - "@aws-crypto/crc32", - "@aws-crypto/sha1-browser", - "@aws-crypto/sha256-browser", - "@aws-crypto/sha256-js", - "@aws-crypto/supports-web-crypto", - "@aws-crypto/util", - "@smithy/config-resolver", - "@smithy/core", - "@smithy/credential-provider-imds", - "@smithy/eventstream-serde-browser", - "@smithy/eventstream-serde-config-resolver", - "@smithy/eventstream-serde-node", - "@smithy/fetch-http-handler", - "@smithy/hash-blob-browser", - "@smithy/hash-node", - "@smithy/hash-stream-node", - "@smithy/invalid-dependency", - "@smithy/is-array-buffer", - "@smithy/md5-js", - "@smithy/middleware-content-length", - "@smithy/middleware-endpoint", - "@smithy/middleware-retry", - "@smithy/middleware-serde", - "@smithy/middleware-stack", - "@smithy/node-config-provider", - "@smithy/node-http-handler", - "@smithy/protocol-http", - "@smithy/shared-ini-file-loader", - "@smithy/signature-v4", - "@smithy/smithy-client", - "@smithy/types", - "@smithy/url-parser", - "@smithy/util-base64", - "@smithy/util-body-length-browser", - "@smithy/util-body-length-node", - "@smithy/util-buffer-from", - "@smithy/util-defaults-mode-browser", - "@smithy/util-defaults-mode-node", - "@smithy/util-endpoints", - "@smithy/util-middleware", - "@smithy/util-retry", - "@smithy/util-stream", - "@smithy/util-utf8", - "@smithy/util-waiter", -] diff --git a/.repos/alchemy-effect/demos/cli/.gitignore b/.repos/alchemy-effect/demos/cli/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..331985131736cefbb93ce33dab6646d4daf01785 --- /dev/null +++ b/.repos/alchemy-effect/demos/cli/.gitignore @@ -0,0 +1,5 @@ +# tcut recordings + renders (the .cast is the source of truth for re-renders, +# but it is large and machine-specific — regenerate with `tcut demo.video.ts`) +out/ +# credential file the recording writes for the on-camera `--env-file demo.env` +demo.env diff --git a/.repos/alchemy-effect/demos/cli/alchemy.run.ts b/.repos/alchemy-effect/demos/cli/alchemy.run.ts new file mode 100644 index 0000000000000000000000000000000000000000..a73ef12dbc8533c1d8d627a38e11ccabab16455c --- /dev/null +++ b/.repos/alchemy-effect/demos/cli/alchemy.run.ts @@ -0,0 +1,21 @@ +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import Api from "./src/Api.ts"; +import { Uploads } from "./src/Uploads.ts"; + +export default Alchemy.Stack( + "Demo", + { + providers: Cloudflare.providers(), + state: Alchemy.localState(), + }, + Effect.gen(function* () { + const api = yield* Api; + const uploads = yield* Uploads; + return { + url: api.url.as(), + bucket: uploads.bucketName, + }; + }), +); diff --git a/.repos/alchemy-effect/demos/cli/demo.video.ts b/.repos/alchemy-effect/demos/cli/demo.video.ts new file mode 100644 index 0000000000000000000000000000000000000000..1426a02cb12d873879aaab9a44c262163f8cdc10 --- /dev/null +++ b/.repos/alchemy-effect/demos/cli/demo.video.ts @@ -0,0 +1,745 @@ +/** + * Scripted terminal demo of the Alchemy CLI, recorded with tcut + * (https://github.com/AmanVarshney01/tcut). + * + * tcut demo.video.ts # record + render out/alchemy-cli.mp4 + * tcut test demo.video.ts # replay fast, assertions only (no video) + * tcut render out/alchemy-cli.cast … # re-render the last recording, no shell + * + * The run never touches your real `~/.alchemy`: it starts from an EMPTY + * throwaway `ALCHEMY_HOME` and follows a brand-new user in order, one title + * card (`t.slide`, tcut ≥ 1.3) and mp4 chapter per step: connect Cloudflare + * through the `alchemy profile` TUI (OAuth, + * granted in tcut's browser pane), `alchemy dev`, `alchemy deploy`, an + * out-of-band edit caught and fixed by `alchemy drift`, and `alchemy destroy`. + * Stack state stays in this directory's `.alchemy/`; everything the demo + * deploys is destroyed at the end. + * + * Inputs (environment): + * CLOUDFLARE_ACCOUNT_NAME (env or ./.env, optional) + * The account to arrow down to in the picker when the login sees + * several accounts; defaults to the account id. + * CLOUDFLARE_LOGIN_EMAIL / CLOUDFLARE_LOGIN_PASSWORD (env or ./.env) + * Dashboard login for the Cloudflare account used on camera. If unset, + * the script waits (up to 4 min) for you to sign in by hand in the + * browser window; either way it clicks through the consent page. + * CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID + * API token for the OFF-camera pre-flight that destroys anything a + * previous take left behind. Falls back to the token stored in the + * real `testing` profile. Written to a gitignored `demo.env` that only + * `--env-file demo.env` reads, so the value never lands in the `.cast`. + * + * The CLI opens the OAuth URL with `open`; a shim on the recorded shell's + * PATH captures that URL for tcut's browser instead of your real browser. + * + * `alchemy dev` never returns on its own — the script drives it like a user + * would (edit a file, watch the reload) and then sends Ctrl+C. + */ +import { defineVideo } from "tcut"; +import { + chmodSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const here = path.dirname(new URL(import.meta.url).pathname); +const realHome = path.join(os.homedir(), ".alchemy"); +const demoHome = path.join(os.tmpdir(), "alchemy-cli-demo-home"); +const shimDir = path.join(os.tmpdir(), "alchemy-cli-demo-bin"); +const oauthUrlFile = path.join(shimDir, "oauth-url"); +const apiFile = path.join(here, "src", "Api.ts"); +const envFile = path.join(here, "demo.env"); +/** + * `KEY=value` lines from this directory's gitignored `.env`, if present. + * + * Bun auto-loads the same file into `process.env`, but with shell semantics: + * `$WORD` gets expanded and `#` starts a comment, which silently mangles a + * password containing either. Values are read verbatim here and take + * precedence over `process.env` for exactly the keys the file defines. + */ +const dotenv: Record = Object.fromEntries( + (existsSync(path.join(here, ".env")) + ? readFileSync(path.join(here, ".env"), "utf8").split("\n") + : [] + ) + .map((line) => line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/)) + .filter((m): m is RegExpMatchArray => m !== null) + .map(([, key, value]) => [key, value!.replace(/^(["'])(.*)\1$/, "$2")]), +); +const env = (key: string) => dotenv[key] ?? process.env[key]; +const login = { + email: env("CLOUDFLARE_LOGIN_EMAIL"), + password: env("CLOUDFLARE_LOGIN_PASSWORD"), +}; +/** Matches the local provider's ready line; group 1 is the served URL. */ +const STARTED = /Started in \d+ms → (http:\/\/localhost:\d+\/)/; + +/** + * Resolves once `url` (our worker's favicon route) answers with the worker's + * own empty 404 three times in a row. Cloudflare's "not provisioned yet" page + * is also a 404, but carries an `error code: 1042` body. + */ +const waitForWorker = async (url: string, attempts = 90) => { + let streak = 0; + for (let i = 0; i < attempts; i++) { + const ok = await fetch(url, { signal: AbortSignal.timeout(5_000) }) + .then(async (r) => r.status === 404 && (await r.text()) === "") + .catch(() => false); + streak = ok ? streak + 1 : 0; + if (streak === 3) return; + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + throw new Error(`${url} did not come up after ${attempts}s`); +}; + +/** + * Write `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` to `demo.env` + * (gitignored, removed after the take). The on-camera setup command reads + * them through `--env-file demo.env`, so the value is never typed and never + * lands in the `.cast`; no other command loads the file, so deploy/dev + * authenticate through the profile rather than the environment. + * + * Returns the credentials for the script's own out-of-band API call (the + * drift step). + */ +const writeCredentialEnvFile = () => { + let token = env("CLOUDFLARE_API_TOKEN"); + let accountId = env("CLOUDFLARE_ACCOUNT_ID"); + if (!token || !accountId) { + const stored = JSON.parse( + readFileSync( + path.join(realHome, "profiles", "testing", "cloudflare.json"), + "utf8", + ), + ).values as Record; + if (stored.credentialType !== "apiToken") { + throw new Error( + "set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID (the 'testing' profile has no stored API token to fall back on)", + ); + } + token = stored.apiToken!; + accountId = stored.accountId!; + } + // The account id is also how the script picks the account if the OAuth + // login can see more than one; it is public enough (the dashboard prints it). + process.env.CLOUDFLARE_ACCOUNT_ID = accountId; + writeFileSync( + envFile, + `CLOUDFLARE_API_TOKEN=${token}\nCLOUDFLARE_ACCOUNT_ID=${accountId}\n`, + { mode: 0o600 }, + ); + return { token, accountId }; +}; + +const connectCloudflare = (profile: string) => + `alchemy profile edit --profile ${profile} --add Cloudflare --method stored --set apiToken=env:CLOUDFLARE_API_TOKEN --set accountId=env:CLOUDFLARE_ACCOUNT_ID --env-file demo.env`; + +/** + * The deployed `Visits` KV namespace, read from the stack's local state + * (`alchemy deploy` targets the `live_` stage). + */ +const deployedVisitsNamespace = () => { + const stackDir = path.join(here, ".alchemy", "state", "Demo"); + const stage = readdirSync(stackDir).find((name) => name.startsWith("live_")); + if (stage === undefined) { + throw new Error(`no live stage under ${stackDir}`); + } + const state = JSON.parse( + readFileSync(path.join(stackDir, stage, "Visits.json"), "utf8"), + ) as { attr: { title: string; namespaceId: string; accountId: string } }; + return state.attr; +}; + +/** Direct Cloudflare API access to one KV namespace, bypassing alchemy. */ +const kvNamespaceApi = ( + credentials: { token: string; accountId: string }, + namespaceId: string, +) => { + const call = async (init: RequestInit) => { + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${credentials.accountId}/storage/kv/namespaces/${namespaceId}`, + { + ...init, + headers: { + Authorization: `Bearer ${credentials.token}`, + "Content-Type": "application/json", + }, + }, + ); + const body = (await response.json()) as { result: { title: string } }; + if (!response.ok) { + throw new Error( + `KV namespace ${namespaceId}: ${init.method} failed with ${response.status} ${JSON.stringify(body)}`, + ); + } + return body.result; + }; + return { + title: async () => (await call({ method: "GET" })).title, + /** + * Rename the namespace behind alchemy's back — what a teammate clicking + * around the dashboard would do. This is the drift the demo detects and + * repairs. + */ + rename: (title: string) => + call({ method: "PUT", body: JSON.stringify({ title }) }), + }; +}; + +/** Fresh, empty `ALCHEMY_HOME`. */ +const seedDemoHome = () => { + rmSync(demoHome, { recursive: true, force: true }); + mkdirSync(demoHome, { recursive: true }); +}; + +/** + * `open` shim: instead of launching the system browser, record the URL so the + * script can hand it to tcut's browser pane. + */ +const seedOpenShim = () => { + rmSync(shimDir, { recursive: true, force: true }); + mkdirSync(shimDir, { recursive: true }); + const shim = path.join(shimDir, "open"); + writeFileSync(shim, `#!/bin/sh\nprintf '%s' "$1" > ${oauthUrlFile}\n`); + chmodSync(shim, 0o755); +}; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** The authorization URL the CLI asked `open` to launch. */ +const waitForOAuthUrl = async (attempts = 240) => { + for (let i = 0; i < attempts; i++) { + if (existsSync(oauthUrlFile)) return readFileSync(oauthUrlFile, "utf8"); + await sleep(250); + } + throw new Error("the CLI never opened the OAuth URL"); +}; + +/** + * Cloudflare's login page is a React form; set the controlled inputs through + * the native value setter (so React sees the change) and submit. + */ +const signInScript = (email: string, password: string) => `(() => { + const set = (selector, value) => { + const el = document.querySelector(selector); + if (!el) return false; + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(el, value); + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + return true; + }; + return set("#email", ${JSON.stringify(email)}) && set("#password", ${JSON.stringify(password)}); +})()`; + +/** True once both controlled inputs actually hold the values we set. */ +const signInFilledScript = (email: string, password: string) => + `document.querySelector("#email")?.value === ${JSON.stringify(email)} && + document.querySelector("#password")?.value === ${JSON.stringify(password)}`; + +/** + * True once the login page's Turnstile widget has produced its token. Without + * it Cloudflare answers a perfectly good password with "Incorrect email or + * password", so the submit has to wait for it. (`login_challenge` is a + * different, always-populated field — don't match on `*challenge*`.) + */ +const turnstileReadyScript = `[...document.querySelectorAll( + 'input[name="cf_challenge_response"], input[name="cf-turnstile-response"]' +)].some((el) => el.value.length > 0)`; + +/** + * The account row to pick on the consent screen's "Select account(s)" step: + * the configured account name, or whichever row comes first. + */ +const accountButtonPattern = (() => { + const name = env("CLOUDFLARE_ACCOUNT_NAME"); + return name + ? new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i") + : /^(?!Review permissions$|Cancel$).+/; +})(); + +/** Click the first button whose text matches (consent pages vary in markup). */ +const clickButtonScript = (pattern: RegExp) => `(() => { + const el = [...document.querySelectorAll("button, input[type=submit], a[role=button]")] + .find((b) => ${pattern}.test((b.innerText || b.value || "").trim())); + if (!el) return false; + el.click(); + return true; +})()`; + +/** Logical frame size (rendered at 2x); the 110x32 grid is centred inside. */ +const VIDEO = { width: 1368, height: 816 }; + +export default defineVideo( + { + output: ["out/alchemy-cli.mp4"], + shell: "zsh", + cols: 110, + rows: 32, + width: VIDEO.width, + height: VIDEO.height, + theme: "catppuccin-mocha", + typingSpeed: "15ms", + typingJitter: 0.3, + waitTimeout: "180s", + endPause: "2s", + requires: ["curl"], + // A real browser window, composited over the terminal only while + // focused — used for the OAuth grant and to show the local dev server. + // Sized to the video so it takes the whole frame when it is in front. + browser: { position: "overlay", ...VIDEO, offset: { x: 0, y: 0 } }, + }, + async (t) => { + seedDemoHome(); + seedOpenShim(); + const credentials = writeCredentialEnvFile(); + const originalApi = readFileSync(apiFile, "utf8"); + + /** + * A full-screen transition card between steps (`t.slide`, tcut ≥ 1.3), + * drawn at render time in real typography and faded in and out. It also + * records an mp4 chapter of the same name (`--chapters`, + * `--split-chapters`), and the screen is cleared behind the card so the + * next step starts fresh. + */ + let step = 0; + const slide = async (heading: string, subtitle: string) => { + step += 1; + await t.caption(null); + await t.slide(heading, { + eyebrow: String(step), + subtitle, + duration: "2.2s", + fade: "400ms", + during: async () => { + // `run` waits for a fresh prompt, which never comes when the screen + // is already just a prompt (clear redraws nothing new). + const lines = t + .screen() + .split("\n") + .filter((l) => l.trim()); + if (lines.length > 1) await t.run("clear"); + }, + }); + }; + + /** + * A subtitle over the bottom of the terminal (`t.caption`, tcut ≥ 1.4) for + * the moments where the screen alone doesn't say what is happening. Drawn + * at render time, never typed into the PTY; stays up until the next + * caption, `caption(null)`, or the next slide. + */ + const caption = (text: string, duration?: string) => + t.caption(text, { style: "classic", fontSize: 26, duration }); + + try { + // Off-camera: point the CLI at the empty home, put the `open` shim and + // the workspace `alchemy` bin on PATH, and make sure nothing is left + // from a previous take. + await t.hide(async () => { + await t.run( + `export ALCHEMY_HOME=${JSON.stringify(demoHome)} PATH="${shimDir}:$PWD/node_modules/.bin:$PATH"`, + ); + // The CLI falls back to plain output when it detects a coding agent + // (CURSOR_AGENT, CLAUDECODE, …) or CI in the environment, and drops + // colors under NO_COLOR; the demo is about the TUI, so force both on + // regardless of who launched tcut. + await t.run( + "unset CI CURSOR_AGENT CLAUDECODE CLAUDE_CODE NO_COLOR FORCE_COLOR; export ALCHEMY_TUI=1", + ); + // tcut's pty has no TERM_PROGRAM, so terminal-capability detection + // assumes OSC 8 hyperlinks are unsupported and the CLI falls back to + // printing every URL in full after its label (the OAuth URL is ~1KB). + // Real terminals (Ghostty, iTerm2, kitty, …) render the link inline. + await t.run("export FORCE_HYPERLINK=1"); + // A previous take may have left a live stack behind: tear it down with + // a temporary profile before the home is wiped for the on-camera setup. + await t.run(`${connectCloudflare("default")} --no-input`); + await t.run("alchemy destroy --yes --no-input >/dev/null 2>&1 || true"); + // Local state (including the dev stage's emulated resources) and the + // demo home start from scratch so every take plays out the same way. + const home = JSON.stringify(demoHome); + await t.run(`rm -rf .alchemy ${home} && mkdir -p ${home}`); + await t.run("clear"); + }); + + // ── 1. Connect to Cloudflare ───────────────────────────────────────── + await slide("Connect Cloudflare", "alchemy profile · sign in with OAuth"); + await t.type("alchemy profile"); + await t.enter(); + await t.wait(/e edit/, { scope: "screen" }); + await caption( + "A fresh machine: one empty profile, no providers connected yet", + ); + await t.sleep("2.5s"); + + // Nothing is connected yet — add Cloudflare from the edit screen. + await caption("e edits the profile — space marks a provider to add"); + await t.type("e"); + await t.wait(/esc back/, { scope: "screen" }); + await t.sleep("2s"); + await t.down(2); + await t.sleep("600ms"); + await t.type(" "); + await t.wait(/Cloudflare\s+add/, { scope: "screen" }); + await t.sleep("1.2s"); + await t.enter(); + + // OAuth (the recommended method), basic scopes. + await t.wait(/Cloudflare authentication method/, { scope: "screen" }); + await caption("OAuth is the recommended method — no API token to paste"); + await t.sleep("2s"); + await t.enter(); + await t.wait(/Cloudflare OAuth scopes/, { scope: "screen" }); + await caption("Pick the scopes the token should have"); + await t.sleep("1.5s"); + await t.down(); + await t.sleep("600ms"); + await t.enter(); + + // The CLI hands the grant URL to `open` — our shim catches it and the + // grant happens in the browser pane, exactly as it would in a real tab. + await t.wait(/waiting for browser authorization/, { scope: "screen" }); + await caption("The CLI opens Cloudflare's consent page in the browser"); + const oauthUrl = await waitForOAuthUrl().catch((error) => { + throw new Error(`${error.message}\n--- screen ---\n${t.screen()}`); + }); + await t.sleep("2s"); + await caption( + "Sign in and authorize — the grant is handed back to the CLI", + ); + await t.browser.goto(oauthUrl); + await t.focus("browser"); + const consentPage = /Select account\(s\)|Authorize/i; + await t.browser.waitFor( + new RegExp(`Sign in to Cloudflare|${consentPage.source}`, "i"), + { timeout: "60s" }, + ); + const pageText = async () => + String( + await t.browser.evaluate("document.body.innerText").catch(() => ""), + ); + if (/Sign in to Cloudflare/i.test(await pageText())) { + if (login.email && login.password) { + // The form is React-controlled and re-renders while it hydrates and + // while Turnstile runs, so a fill can be dropped: verify the inputs + // hold our values and the challenge token exists before submitting, + // and retry if Cloudflare rejects a half-filled submission. + for (let attempt = 1; attempt <= 3; attempt++) { + await t.sleep("1.5s"); + for (let i = 0; i < 10; i++) { + await t.browser.evaluate( + signInScript(login.email, login.password), + ); + await t.sleep("400ms"); + if ( + (await t.browser.evaluate( + signInFilledScript(login.email, login.password), + )) === true + ) + break; + } + let turnstile = false; + for (let i = 0; i < 60 && !turnstile; i++) { + turnstile = + (await t.browser.evaluate(turnstileReadyScript)) === true; + if (!turnstile) await t.sleep("500ms"); + } + if (!turnstile) + console.error( + "turnstile token never appeared; submitting anyway", + ); + await t.sleep("500ms"); + await t.browser.click('[data-testid="login-submit-button"]'); + let rejected = false; + for (let i = 0; i < 40; i++) { + await t.sleep("500ms"); + const text = await pageText(); + if (/Incorrect email or password/i.test(text)) { + rejected = true; + break; + } + if (text.trim() !== "" && !/Sign in to Cloudflare/i.test(text)) + break; + } + if (!rejected) break; + console.error( + `cloudflare rejected the sign-in (attempt ${attempt}); retrying`, + `\n url: ${t.browser.url}`, + `\n hidden inputs: ${await t.browser.evaluate( + `JSON.stringify([...document.querySelectorAll("input[type=hidden]")].map((i) => [i.name, i.value.length]))`, + )}`, + `\n page: ${(await pageText()).replace(/\s+/g, " ").slice(0, 400)}`, + ); + } + } + // Either the credentials just submitted, or a human signs in now. + await t.browser.waitFor(consentPage, { timeout: "240s" }); + } + // Cloudflare's consent has two steps when the login can see several + // accounts: pick the account the grant covers, review the permissions, + // then authorize. With a single account it opens on the review step. + if (/Select account\(s\)/i.test(await pageText())) { + await t.sleep("2s"); + await t.browser.evaluate(clickButtonScript(accountButtonPattern)); + await t.sleep("1.2s"); + await t.browser.evaluate(clickButtonScript(/^Review permissions$/i)); + await t.browser.waitFor(/^Authorize$/im, { timeout: "60s" }); + } + await t.sleep("2.5s"); + await t.browser.evaluate(clickButtonScript(/^Authorize$/i)); + // Cloudflare redirects to alchemy.run/auth/callback, which relays the + // code to the CLI's localhost listener and lands on the success page. + // The relay pings localhost with a cross-scheme fetch first; when the + // embedded browser refuses that, the page offers a plain link to the + // local callback instead — take it. + await t.browser.waitFor( + /Authentication Complete|Authentication Error|Couldn't connect to your local|Cloudflare did not authorize/i, + { timeout: "60s" }, + ); + if (/Couldn't connect to your local/i.test(await pageText())) { + await t.browser.click("#relay-local"); + await t.browser.waitFor( + /Authentication Complete|Authentication Error/i, + { + timeout: "60s", + }, + ); + } + await t.sleep("2s"); + await t.focus("terminal"); + + // Back in the terminal the callback has landed and the account is bound. + await t.wait(/Cloudflare added|Select a Cloudflare account/, { + scope: "screen", + }); + if (/Select a Cloudflare account/.test(t.screen())) { + await caption( + "The login can see several accounts — pick the one this profile is for", + ); + await t.sleep("1.5s"); + // Arrow down to the configured account (the focused row starts with + // ❯) instead of typing into the filter; the first row is the default. + const wanted = + env("CLOUDFLARE_ACCOUNT_NAME") ?? process.env.CLOUDFLARE_ACCOUNT_ID; + const focused = () => + t + .screen() + .split("\n") + .find((line) => /^\s*❯/.test(line)) ?? ""; + for (let i = 0; i < 8 && wanted && !focused().includes(wanted); i++) { + await t.down(); + await t.sleep("400ms"); + } + await t.sleep("600ms"); + await t.enter(); + await t.wait(/Cloudflare added/, { scope: "screen" }); + } + // Hold on the dashboard: one profile, one connected provider. + await caption( + "Connected — auth method, token expiry and account id, stored under ~/.alchemy/profiles", + ); + await t.sleep("3.5s"); + await t.type("q"); + await t.wait(); + await t.sleep("1s"); + + // ── 2. Launch alchemy dev ──────────────────────────────────────────── + await slide( + "Launch alchemy dev", + "local emulation · plan view · live reload", + ); + await t.type("alchemy dev"); + await t.enter(); + await t.wait(STARTED, { scope: "screen" }); + await caption( + "The Worker, KV and R2 all run locally — the widget shows the stack's outputs", + ); + await t.sleep("2s"); + + // The widget opens on the stack's output; ←/→ flips it to the plan + // that was just applied. Show the plan — three local resources, all + // created — then come back to the output. + const showDevPlan = async (pattern: RegExp, text: string) => { + await caption("→ flips the widget to the plan that was just applied"); + await t.sleep("1s"); + await t.right(); + await t.wait(/show output/, { scope: "screen" }); + await t.expect(pattern, { scope: "screen" }); + await caption(text); + await t.sleep("4s"); + await caption("← back to the outputs"); + await t.sleep("800ms"); + await t.left(); + await t.wait(/show plan/, { scope: "screen" }); + await t.sleep("1.5s"); + }; + await showDevPlan( + /Cloudflare\.KV\.Namespace\).*created/, + "Three resources created, all emulated locally", + ); + + // Hit the local worker in a browser — at the URL the CLI actually + // printed, never a guess (another dev server may own the default port). + const devUrl = t.screen().match(STARTED)?.[1]; + if (devUrl === undefined) { + throw new Error("local dev URL not found in the terminal output"); + } + await caption("Open the local URL"); + await t.sleep("800ms"); + await t.browser.goto(devUrl); + await t.focus("browser"); + await t.sleep("3s"); + await t.focus("terminal"); + await t.sleep("1s"); + + // Change the greeting while dev is running — the stack reloads and the + // worker restarts. + await caption( + "Editing src/Api.ts while dev runs — the stack reloads and the Worker restarts", + ); + await t.sleep("1.2s"); + writeFileSync( + apiFile, + originalApi.replace( + 'const GREETING = "Hello from Alchemy";', + 'const GREETING = "Hello from alchemy dev";', + ), + ); + await t.wait(/restarting instance/, { scope: "screen" }); + await t.wait(/Started in \d+ms[^]*Started in \d+ms/, { + scope: "screen", + }); + if (!t.screen().split("Started in").pop()!.includes(devUrl)) { + throw new Error("worker restarted on a different URL"); + } + await t.sleep("1.5s"); + // The reload's plan: only the Worker changed, KV and R2 untouched. + await showDevPlan( + /Cloudflare\.Worker\)/, + "Only the Worker was updated — KV and R2 untouched", + ); + + await caption("Reload the page: the new greeting"); + await t.sleep("800ms"); + await t.browser.reload(); + await t.focus("browser"); + await t.sleep("3s"); + await t.focus("terminal"); + await t.sleep("1.5s"); + + // alchemy dev parks forever — Ctrl+C shuts it down. + await caption("Ctrl+C stops dev and tears the local resources down"); + await t.sleep("800ms"); + await t.ctrl("c"); + await t.wait(); + // Put the source back so the deploy ships the original greeting. + writeFileSync(apiFile, originalApi); + await t.sleep("1s"); + + // ── 3. Deploy to the cloud ─────────────────────────────────────────── + await slide( + "Deploy to the cloud", + "alchemy deploy · review the plan · confirm", + ); + await t.type("alchemy deploy"); + await t.enter(); + await t.wait(/Deploy\?/, { scope: "screen" }); + await caption( + "The same stack, now against the real Cloudflare account — review the plan, Enter to confirm", + ); + await t.sleep("3.5s"); + await t.enter(); + await caption( + "Creating the Worker, the KV namespace and the R2 bucket for real", + ); + await t.wait(); + await t.expect(/Stack deployed/, { scope: "scrollback" }); + await caption( + "Deployed — the outputs are the live workers.dev URL and the bucket name", + ); + await t.sleep("2.5s"); + + const url = t + .scrollback() + .match(/https:\/\/[a-z0-9.-]+\.workers\.dev/)?.[0]; + if (url === undefined) { + throw new Error("deployed URL not found in the terminal output"); + } + // A fresh workers.dev subdomain takes a few seconds to start serving + // (Cloudflare error 1042 until then). Poll from the script — not the + // recorded shell — on a route that leaves the visit counter alone, so + // the visible curls read 1 and 2. + await waitForWorker(`${url}/favicon.ico`); + await caption("Hit the live Worker — the visit counter is backed by KV"); + await t.run(`curl -s ${url}`); + await t.expect(/"visits":1/); + await t.sleep("1s"); + await t.run(`curl -s ${url}`); + await t.expect(/"visits":2/); + await t.sleep("2s"); + + // ── 4. Detect and repair drift ─────────────────────────────────────── + // Off camera, "someone" renames the KV namespace in the dashboard. + const visits = deployedVisitsNamespace(); + const visitsApi = kvNamespaceApi(credentials, visits.namespaceId); + await visitsApi.rename("renamed-in-the-dashboard"); + await slide( + "Detect and repair drift", + "alchemy drift · a namespace renamed in the dashboard", + ); + await caption( + "Meanwhile, someone renamed the KV namespace in the Cloudflare dashboard…", + ); + await t.sleep("2.5s"); + await t.type("alchemy drift"); + await t.enter(); + await t.wait(/Drift detected/, { scope: "screen" }); + await t.expect(/renamed-in-the-dashboard/, { scope: "screen" }); + await caption( + "drift re-reads the cloud and diffs it against the stack: the title changed", + ); + await t.sleep("4s"); + // Cancel is preselected; ← moves to Repair. + await caption( + "Repair puts the resource back the way the code declares it", + ); + await t.left(); + await t.sleep("1s"); + await t.enter(); + await caption("Applying the repair"); + await t.wait(); + await t.expect(/Stack deployed/, { scope: "screen" }); + await caption("Repaired — one update, nothing else touched"); + await t.sleep("2.5s"); + if ((await visitsApi.title()) !== visits.title) { + throw new Error("drift repair did not restore the namespace title"); + } + + // ── 5. Tear down ───────────────────────────────────────────────────── + await slide("Tear it down", "alchemy destroy"); + await t.type("alchemy destroy"); + await t.enter(); + await t.wait(/Destroy\?/, { scope: "screen" }); + await caption( + "destroy shows everything it is about to delete before asking", + ); + await t.sleep("2.5s"); + await t.type("y"); + await caption("Deleting the Worker, the KV namespace and the R2 bucket"); + await t.wait(); + await t.expect(/Stack destroyed/, { scope: "scrollback" }); + await caption("All gone — the account is back to where it started"); + await t.sleep("2s"); + } finally { + writeFileSync(apiFile, originalApi); + rmSync(envFile, { force: true }); + rmSync(shimDir, { recursive: true, force: true }); + } + }, +); diff --git a/.repos/alchemy-effect/demos/cli/package.json b/.repos/alchemy-effect/demos/cli/package.json new file mode 100644 index 0000000000000000000000000000000000000000..f924dde7dfe34370244a00bb51c8ba587223f74e --- /dev/null +++ b/.repos/alchemy-effect/demos/cli/package.json @@ -0,0 +1,19 @@ +{ + "name": "alchemy-cli-demo", + "version": "0.0.0", + "private": true, + "license": "Apache-2.0", + "type": "module", + "scripts": { + "deploy": "alchemy deploy", + "dev": "alchemy dev", + "destroy": "alchemy destroy", + "demo": "tcut demo.video.ts" + }, + "dependencies": { + "@effect/platform-bun": "catalog:", + "@effect/platform-node": "catalog:", + "alchemy": "workspace:*", + "effect": "catalog:" + } +} diff --git a/.repos/alchemy-effect/demos/cli/src/Api.ts b/.repos/alchemy-effect/demos/cli/src/Api.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b72b7d88dba30ffd36527cc2b2967d9c50e3a10 --- /dev/null +++ b/.repos/alchemy-effect/demos/cli/src/Api.ts @@ -0,0 +1,74 @@ +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { Uploads } from "./Uploads.ts"; +import { Visits } from "./Visits.ts"; + +const GREETING = "Hello from Alchemy"; + +const page = (message: string) => ` + + + + ${message} + + +

${message}

Cloudflare Worker · KV · R2

+`; + +export default class Api extends Cloudflare.Worker()( + "Api", + { + main: import.meta.url, + // Fixed, uncommon port so the demo never silently lands on another + // process's dev server (1337 is the default and is often taken). + dev: { port: 3111, strictPort: true }, + }, + Effect.gen(function* () { + const visits = yield* Cloudflare.KV.ReadWriteNamespace(Visits); + const uploads = yield* Cloudflare.R2.ReadWriteBucket(Uploads); + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = new URL(request.url, "http://internal"); + + if (url.pathname === "/favicon.ico") { + return HttpServerResponse.empty({ status: 404 }); + } + + if (url.pathname === "/upload") { + const body = yield* request.text; + yield* uploads.put("latest.txt", body).pipe(Effect.orDie); + return yield* HttpServerResponse.json({ stored: body.length }); + } + + const previous = yield* visits.get("count").pipe(Effect.orDie); + const count = Number(previous ?? "0") + 1; + yield* visits.put("count", String(count)).pipe(Effect.orDie); + + // Browsers get a page, curl gets JSON. + if (request.headers.accept?.includes("text/html")) { + return HttpServerResponse.html(page(GREETING)); + } + return yield* HttpServerResponse.json({ + message: GREETING, + visits: count, + }); + }), + }; + }).pipe( + Effect.provide([ + Cloudflare.KV.ReadWriteNamespaceBinding, + Cloudflare.R2.ReadWriteBucketBinding, + ]), + ), +) {} diff --git a/.repos/alchemy-effect/demos/cli/src/Uploads.ts b/.repos/alchemy-effect/demos/cli/src/Uploads.ts new file mode 100644 index 0000000000000000000000000000000000000000..1672253859f354a50010d1e2ce4969c17a676b7b --- /dev/null +++ b/.repos/alchemy-effect/demos/cli/src/Uploads.ts @@ -0,0 +1,5 @@ +import * as Cloudflare from "alchemy/Cloudflare"; + +export const Uploads = Cloudflare.R2.Bucket("Uploads", { + forceDestroy: true, +}); diff --git a/.repos/alchemy-effect/demos/cli/src/Visits.ts b/.repos/alchemy-effect/demos/cli/src/Visits.ts new file mode 100644 index 0000000000000000000000000000000000000000..cef9599c6dab26019c295e78b7f217472b2ad036 --- /dev/null +++ b/.repos/alchemy-effect/demos/cli/src/Visits.ts @@ -0,0 +1,3 @@ +import * as Cloudflare from "alchemy/Cloudflare"; + +export const Visits = Cloudflare.KV.Namespace("Visits"); diff --git a/.repos/alchemy-effect/demos/cli/tsconfig.json b/.repos/alchemy-effect/demos/cli/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..635297e872bd1a1748eac3182fd9262608e7fb29 --- /dev/null +++ b/.repos/alchemy-effect/demos/cli/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + // demo.video.ts imports "tcut", which only resolves inside the tcut binary, + // so it is deliberately left out of the program. + "include": ["alchemy.run.ts", "src/**/*.ts"], + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "module": "Preserve", + "moduleResolution": "Bundler", + "target": "ESNext" + }, + "references": [ + { + "path": "../../packages/alchemy/tsconfig.json" + } + ] +} diff --git a/.repos/alchemy-effect/distilled b/.repos/alchemy-effect/distilled deleted file mode 160000 index cb11c5bf19151b529a6b96497f1c8251e327d618..0000000000000000000000000000000000000000 --- a/.repos/alchemy-effect/distilled +++ /dev/null @@ -1 +0,0 @@ -Subproject commit cb11c5bf19151b529a6b96497f1c8251e327d618 diff --git a/.repos/alchemy-effect/examples/aws-bedrock-ai/package.json b/.repos/alchemy-effect/examples/aws-bedrock-ai/package.json index 109812650fae1155d306c596e5f8ddcb94089333..4bced6c5407677ff13cc2357b2bf6a7641764ad4 100644 --- a/.repos/alchemy-effect/examples/aws-bedrock-ai/package.json +++ b/.repos/alchemy-effect/examples/aws-bedrock-ai/package.json @@ -17,8 +17,8 @@ }, "dependencies": { "@distilled.cloud/aws": "workspace:*", - "@effect/platform-node": "catalog:effect", + "@effect/platform-node": "catalog:", "alchemy": "workspace:*", - "effect": "catalog:effect" + "effect": "catalog:" } } diff --git a/.repos/alchemy-effect/examples/aws-dev/package.json b/.repos/alchemy-effect/examples/aws-dev/package.json index e7fa22578f81cc9048d7ec7a9f20c0796a05e117..197e7bfa674da5d587b9cc0eb6a85737c8454d1b 100644 --- a/.repos/alchemy-effect/examples/aws-dev/package.json +++ b/.repos/alchemy-effect/examples/aws-dev/package.json @@ -16,8 +16,11 @@ "test": "bun test" }, "dependencies": { - "@effect/platform-node": "catalog:effect", + "@effect/platform-node": "catalog:", "alchemy": "workspace:*", - "effect": "catalog:effect" + "effect": "catalog:" + }, + "devDependencies": { + "alchemy-test": "workspace:*" } } diff --git a/.repos/alchemy-effect/examples/aws-dev/src/ApiFunction.ts b/.repos/alchemy-effect/examples/aws-dev/src/ApiFunction.ts index d645777b666ef0808ba6d3bf9ab691b40701cbad..4967f062ba1f6857c9e8aee750e873b4ddad8ca1 100644 --- a/.repos/alchemy-effect/examples/aws-dev/src/ApiFunction.ts +++ b/.repos/alchemy-effect/examples/aws-dev/src/ApiFunction.ts @@ -144,7 +144,7 @@ export default class ApiFunction extends Lambda.Function()( const url = new URL(request.originalUrl); if (url.pathname === "/") { - const variable = yield* Config.string("MY_VARIABLE"); + const variable = yield* Config.String("MY_VARIABLE"); return yield* HttpServerResponse.json({ marker: MARKER, variable }); } diff --git a/.repos/alchemy-effect/examples/aws-dev/test/dev.test.ts b/.repos/alchemy-effect/examples/aws-dev/test/dev.test.ts index 4f1f9566d24cc0412f5e0130d592bfbf4a8d92d3..fbdac8dec03cde89c47613a0263323146bcfce5a 100644 --- a/.repos/alchemy-effect/examples/aws-dev/test/dev.test.ts +++ b/.repos/alchemy-effect/examples/aws-dev/test/dev.test.ts @@ -35,164 +35,57 @@ * the new marker; restoring it must swap back */ import { afterAll, expect, test } from "bun:test"; -import { spawn, spawnSync } from "node:child_process"; +import { DevCli, fetchOk } from "alchemy-test/DevCli"; +import { spawnSync } from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; const root = path.resolve(import.meta.dirname, ".."); -// Spawn the CLI entry directly (not through `bun run` / the cli.js -// launcher) so signals hit the actual CLI process, whose scope teardown -// kills the exec child and the provider sidecars. -const alchemyBin = path.join( - root, - "node_modules", - "alchemy", - "bin", - "alchemy.ts", -); -// Isolated stage so this suite never fights other local runs over state. const STAGE = "dev-cli-test"; - -const markerPath = path.join(root, "src", "marker.ts"); -const markerSource = fs.readFileSync(markerPath, "utf8"); - -// The whole suite needs docker (floci runs as a container). -const dockerAvailable = - spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0; - // Credential-free dev, deterministically: an isolated (unconfigured) // alchemy profile plus stripped AWS env credentials keep this suite // hermetic — it must pass on a machine with zero AWS configuration, and // must never touch a developer's real profile. (Action data-plane calls // are routed to the emulator per bound resource by the engine regardless // of ambient credentials — see Binding.Service's data-plane routing.) -const devEnv: NodeJS.ProcessEnv = { - ...process.env, - ALCHEMY_PROFILE: "aws-dev-cli-test", -}; -delete devEnv.AWS_ACCESS_KEY_ID; -delete devEnv.AWS_SECRET_ACCESS_KEY; -delete devEnv.AWS_SESSION_TOKEN; -delete devEnv.AWS_PROFILE; +const cli = new DevCli({ + root, + stage: STAGE, + env: { + ALCHEMY_PROFILE: "aws-dev-cli-test", + AWS_ACCESS_KEY_ID: undefined, + AWS_SECRET_ACCESS_KEY: undefined, + AWS_SESSION_TOKEN: undefined, + AWS_PROFILE: undefined, + }, +}); -let proc: ReturnType | undefined; -let output = ""; - -const pump = (stream: NodeJS.ReadableStream) => { - stream.on("data", (chunk: Buffer) => { - const text = chunk.toString(); - output += text; - if (process.env.DEBUG) process.stderr.write(text); - }); -}; - -/** Bounded poll for a (possibly async) producer to yield a value. */ -const pollUntil = async ( - what: string, - f: () => T | undefined | Promise, - { tries = 30, delayMs = 1000 }: { tries?: number; delayMs?: number } = {}, -): Promise => { - for (let i = 0; i < tries; i++) { - const value = await f(); - if (value !== undefined) return value; - await Bun.sleep(delayMs); - } - throw new Error( - `Timed out waiting for ${what}.\n--- alchemy dev output (tail) ---\n${output.slice(-4000)}`, - ); -}; - -/** Fetch with retries — the emulator's URL proxy takes a moment to serve. */ -const fetchOk = async ( - url: string | URL, - init?: RequestInit, - { tries = 20, delayMs = 500 }: { tries?: number; delayMs?: number } = {}, -) => { - let last: Response | undefined; - for (let i = 0; i < tries; i++) { - try { - last = await fetch(url, init); - if (last.ok) return last; - } catch { - // proxy not listening yet - } - await Bun.sleep(delayMs); - } - throw new Error( - `${init?.method ?? "GET"} ${url} never returned 2xx (last status: ${last?.status})`, - ); -}; - -/** Extract the api URL from the stack outputs the CLI prints on stdout. */ -const outputUrl = (key: string) => - output.match(new RegExp(`${key}:\\s*['"]?(http[^\\s'",]+)`))?.[1]; - -/** Extract a plain (single-token) stack output value from stdout. */ -const outputValue = (key: string) => - output.match(new RegExp(`${key}:\\s*['"]?([^\\s'",]+)`))?.[1]; +const markerPath = path.join(root, "src", "marker.ts"); +const markerSource = fs.readFileSync(markerPath, "utf8"); +// The whole suite needs docker (floci runs as a container). +const dockerAvailable = + spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0; afterAll(async () => { // Always leave the repo tree clean, even on a mid-reload failure. fs.writeFileSync(markerPath, markerSource); - if (proc?.pid) { - // Ctrl-C semantics: signal the whole PROCESS GROUP (the CLI, its - // `--watch` exec child, and the provider sidecars). Signaling only the - // CLI process orphans the exec child, which then keeps the stack's - // state locked and blocks the destroy below. - const killGroup = (signal: NodeJS.Signals) => { - try { - process.kill(-proc!.pid!, signal); - } catch { - // group already gone - } - }; - const exited = new Promise((resolve) => proc!.once("exit", resolve)); - killGroup("SIGINT"); - await Promise.race([exited, Bun.sleep(15_000)]); - if (proc.exitCode === null && proc.signalCode === null) { - killGroup("SIGKILL"); - await Promise.race([exited, Bun.sleep(5_000)]); - } - } + await cli.stop(); if (!process.env.NO_DESTROY && dockerAvailable) { - const destroyed = spawnSync( - "bun", - [alchemyBin, "destroy", "--stage", STAGE, "--yes"], - { - cwd: root, - stdio: "inherit", - timeout: 120_000, - env: devEnv, - }, - ); - if (destroyed.status !== 0) { - throw new Error( - `alchemy destroy exited ${destroyed.status} — local teardown must succeed`, - ); - } + cli.destroy(); } }, 180_000); test.skipIf(!dockerAvailable)( "alchemy dev serves every local AWS binding end-to-end with hot reload", async () => { - proc = spawn("bun", [alchemyBin, "dev", "--stage", STAGE], { - cwd: root, - // Own process group, so teardown can deliver Ctrl-C to the whole tree - // the way a terminal would. - detached: true, - stdio: ["ignore", "pipe", "pipe"], - env: devEnv, - }); - pump(proc.stdout!); - pump(proc.stderr!); + cli.start(); // The first dev deploy may pull the floci image, provision the local // data plane, and package the function before printing stack outputs. - const api = await pollUntil( + const api = await cli.pollUntil( "api url in stack outputs", - () => outputUrl("api"), + () => cli.outputUrl("api"), { tries: 300, delayMs: 1000 }, ); @@ -207,12 +100,13 @@ test.skipIf(!dockerAvailable)( // CLI's exec process and put/get an object through the S3 bindings. // The dummy account id proves the calls hit the emulator, not real // AWS; the roundtripped body proves the put actually landed. - const seedAccount = await pollUntil("seedAccount in stack outputs", () => - outputValue("seedAccount"), + const seedAccount = await cli.pollUntil( + "seedAccount in stack outputs", + () => cli.outputValue("seedAccount"), ); expect(seedAccount).toBe("000000000000"); - const seedText = await pollUntil("seedText in stack outputs", () => - outputValue("seedText"), + const seedText = await cli.pollUntil("seedText in stack outputs", () => + cli.outputValue("seedText"), ); expect(seedText).toBe("seed-object-body-v1"); @@ -244,7 +138,7 @@ test.skipIf(!dockerAvailable)( headers: { "content-type": "application/json" }, body: JSON.stringify(message), }); - const received = await pollUntil( + const received = await cli.pollUntil( "queue message to be consumed", async () => { const res = await fetch( @@ -269,7 +163,7 @@ test.skipIf(!dockerAvailable)( headers: { "content-type": "application/json" }, body: JSON.stringify(notification), }); - const delivered = await pollUntil( + const delivered = await cli.pollUntil( "topic notification to be consumed", async () => { const res = await fetch( @@ -292,7 +186,7 @@ test.skipIf(!dockerAvailable)( headers: { "content-type": "application/json" }, body: JSON.stringify({ id: itemId }), }); - const change = await pollUntil( + const change = await cli.pollUntil( "table change record to be consumed", async () => { const res = await fetch(new URL(`/changes?id=${itemId}`, api)); @@ -311,7 +205,7 @@ test.skipIf(!dockerAvailable)( markerPath, markerSource.replace("aws-dev-marker-v1", "aws-dev-marker-v2"), ); - await pollUntil( + await cli.pollUntil( "hot-swapped marker v2", async () => { try { @@ -340,7 +234,7 @@ test.skipIf(!dockerAvailable)( // Restore the marker — the swap back is itself a second hot reload, // and leaves the checked-in tree clean. fs.writeFileSync(markerPath, markerSource); - await pollUntil( + await cli.pollUntil( "restored marker v1", async () => { try { diff --git a/.repos/alchemy-effect/examples/aws-ec2/package.json b/.repos/alchemy-effect/examples/aws-ec2/package.json index f8fecbc2b0af2e3440e60a4122618677d00e9ae5..aef06ade9a8e16b6a4e5d012f988bf6959bcf15e 100644 --- a/.repos/alchemy-effect/examples/aws-ec2/package.json +++ b/.repos/alchemy-effect/examples/aws-ec2/package.json @@ -16,8 +16,8 @@ }, "dependencies": { "@distilled.cloud/aws": "workspace:*", - "@effect/platform-node": "catalog:effect", + "@effect/platform-node": "catalog:", "alchemy": "workspace:*", - "effect": "catalog:effect" + "effect": "catalog:" } } diff --git a/.repos/alchemy-effect/examples/aws-ecs/package.json b/.repos/alchemy-effect/examples/aws-ecs/package.json index 52a4461108a22c7654a595e8ea3aa1ea2ef47329..8ab0f0f540fe8698000da138aea8dd32e88fc252 100644 --- a/.repos/alchemy-effect/examples/aws-ecs/package.json +++ b/.repos/alchemy-effect/examples/aws-ecs/package.json @@ -17,8 +17,11 @@ }, "dependencies": { "@distilled.cloud/aws": "workspace:*", - "@effect/platform-node": "catalog:effect", + "@effect/platform-node": "catalog:", "alchemy": "workspace:*", - "effect": "catalog:effect" + "effect": "catalog:" + }, + "devDependencies": { + "alchemy-test": "workspace:*" } } diff --git a/.repos/alchemy-effect/examples/aws-ecs/test/dev.test.ts b/.repos/alchemy-effect/examples/aws-ecs/test/dev.test.ts index a07ea5fcada1870a74448ac5c38156277a16c9d2..17a30cdda9f15625c6bd6657a0b5c2823f648c2c 100644 --- a/.repos/alchemy-effect/examples/aws-ecs/test/dev.test.ts +++ b/.repos/alchemy-effect/examples/aws-ecs/test/dev.test.ts @@ -15,24 +15,14 @@ * image without a redeploy */ import { afterAll, expect, test } from "bun:test"; -import { spawn, spawnSync } from "node:child_process"; +import { DevCli, fetchOk } from "alchemy-test/DevCli"; +import { spawnSync } from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; const root = path.resolve(import.meta.dirname, ".."); -// Spawn the CLI entry directly (not through `bun run` / the cli.js -// launcher) so signals hit the actual CLI process, whose scope teardown -// kills the exec child and the provider sidecars. -const alchemyBin = path.join( - root, - "node_modules", - "alchemy", - "bin", - "alchemy.ts", -); -// Isolated stage so this suite never fights integ.test.ts (same stack -// name) over state rows. const STAGE = "dev-cli-test"; +const cli = new DevCli({ root, stage: STAGE }); // Hot-reload surface: the Api service source. The test rewrites it in // place with the CLI running, then restores it. @@ -45,124 +35,43 @@ const MARKER_V2 = 'service: "orders-api-v2"'; const dockerAvailable = spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0; -let proc: ReturnType | undefined; -let output = ""; - -const pump = (stream: NodeJS.ReadableStream) => { - stream.on("data", (chunk: Buffer) => { - const text = chunk.toString(); - output += text; - if (process.env.DEBUG) process.stderr.write(text); - }); -}; - -/** Bounded poll for a (possibly async) producer to yield a value. */ -const pollUntil = async ( - what: string, - f: () => T | undefined | Promise, - { tries = 30, delayMs = 1000 }: { tries?: number; delayMs?: number } = {}, -): Promise => { - for (let i = 0; i < tries; i++) { - const value = await f(); - if (value !== undefined) return value; - await Bun.sleep(delayMs); - } - throw new Error( - `Timed out waiting for ${what}.\n--- alchemy dev output (tail) ---\n${output.slice(-4000)}`, - ); -}; - -/** Fetch with retries — containers take a moment to come up. */ -const fetchOk = async ( - url: string | URL, - init?: RequestInit, - { tries = 60, delayMs = 1000 }: { tries?: number; delayMs?: number } = {}, -) => { - let last: Response | undefined; - for (let i = 0; i < tries; i++) { - try { - last = await fetch(url, init); - if (last.ok) return last; - } catch { - // ALB route not serving yet - } - await Bun.sleep(delayMs); - } - throw new Error( - `${init?.method ?? "GET"} ${url} never returned 2xx (last status: ${last?.status})`, - ); -}; - -/** Extract a stack-output URL the CLI prints on stdout. */ -const outputUrl = (key: string) => - output.match(new RegExp(`\\b${key}:\\s*['"]?(http[^\\s'",]+)`))?.[1]; - afterAll(async () => { // Always leave the repo tree clean, even on a mid-reload failure. fs.writeFileSync(apiPath, apiSource); - if (proc?.pid) { - // Ctrl-C semantics: signal the whole PROCESS GROUP (the CLI, its - // `--watch` exec child, and the provider sidecars). - const killGroup = (signal: NodeJS.Signals) => { - try { - process.kill(-proc!.pid!, signal); - } catch { - // group already gone - } - }; - const exited = new Promise((resolve) => proc!.once("exit", resolve)); - killGroup("SIGINT"); - await Promise.race([exited, Bun.sleep(15_000)]); - if (proc.exitCode === null && proc.signalCode === null) { - killGroup("SIGKILL"); - await Promise.race([exited, Bun.sleep(5_000)]); - } - } + await cli.stop(); if (!process.env.NO_DESTROY && dockerAvailable) { - spawnSync("bun", [alchemyBin, "destroy", "--stage", STAGE, "--yes"], { - cwd: root, - stdio: "inherit", - timeout: 300_000, - }); + cli.destroy({ timeout: 300_000 }); } }, 400_000); test.skipIf(!dockerAvailable)( "alchemy dev runs the orders app fully locally with hot reload", async () => { - proc = spawn("bun", [alchemyBin, "dev", "--stage", STAGE], { - cwd: root, - // Own process group, so teardown can deliver Ctrl-C to the whole - // tree the way a terminal would. - detached: true, - stdio: ["ignore", "pipe", "pipe"], - }); - pump(proc.stdout!); - pump(proc.stderr!); + cli.start(); // The first dev deploy builds the Api/SeedTask/Report images and pulls // the Web image before printing stack outputs. - const url = await pollUntil( + const url = await cli.pollUntil( "url in stack outputs", - () => outputUrl("url"), + () => cli.outputUrl("url"), { tries: 600, delayMs: 1000, }, ); - const apiUrl = await pollUntil("apiUrl in stack outputs", () => - outputUrl("apiUrl"), + const apiUrl = await cli.pollUntil("apiUrl in stack outputs", () => + cli.outputUrl("apiUrl"), ); - const seedUrl = await pollUntil("seedUrl in stack outputs", () => - outputUrl("seedUrl"), + const seedUrl = await cli.pollUntil("seedUrl in stack outputs", () => + cli.outputUrl("seedUrl"), ); // Dev identity: the emulated ALB's DNS resolves to 127.0.0.1 — no real // AWS. The port is whatever the emulator gateway bound; only the URLs // captured from the CLI's stdout are authoritative. expect(url).not.toContain("amazonaws.com"); - expect(output).not.toContain("apply failed"); + expect(cli.output).not.toContain("apply failed"); // Api service behind the shared ALB's `/api/*` rule. const orders = (await (await fetchOk(apiUrl)).json()) as { @@ -178,7 +87,7 @@ test.skipIf(!dockerAvailable)( // POST /api/seed launches the one-shot SeedTask on the local cluster // via the RunTask binding; it seeds three orders into the local table. await fetchOk(seedUrl, { method: "POST" }); - const seeded = await pollUntil( + const seeded = await cli.pollUntil( "seeded orders to appear", async () => { const res = await fetch(apiUrl); @@ -195,7 +104,7 @@ test.skipIf(!dockerAvailable)( fs.writeFileSync(apiPath, apiSource.replace(MARKER, MARKER_V2)); const fallbackUrl = new URL(apiUrl); fallbackUrl.pathname = "/api/ping"; // unmatched /api/* → fallback JSON - await pollUntil( + await cli.pollUntil( "hot-swapped api (v2 marker)", async () => { try { @@ -213,7 +122,7 @@ test.skipIf(!dockerAvailable)( // Restore — the swap back is itself a second hot reload and leaves // the checked-in tree clean. The seeded data survives the roll. fs.writeFileSync(apiPath, apiSource); - await pollUntil( + await cli.pollUntil( "restored api (v1 marker)", async () => { try { diff --git a/.repos/alchemy-effect/examples/aws-ecs/test/integ.test.ts b/.repos/alchemy-effect/examples/aws-ecs/test/integ.test.ts index ec028bf9d6246c55fc16cfaa51838f956b777b4a..d5e438ea626fe4bf6cdbf3b40a41c8fe3b6051d1 100644 --- a/.repos/alchemy-effect/examples/aws-ecs/test/integ.test.ts +++ b/.repos/alchemy-effect/examples/aws-ecs/test/integ.test.ts @@ -20,7 +20,6 @@ import Stack from "../alchemy.run.ts"; const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ providers: AWS.providers(), state: Alchemy.localState(), - stage: "test", }); // The first deploy builds + pushes container images and waits for two diff --git a/.repos/alchemy-effect/examples/aws-eks/package.json b/.repos/alchemy-effect/examples/aws-eks/package.json index 2aeb55aea1a793cc47fbdd66c48d421d437ade5d..1bdf425fcc65c486fd66f81e712c6fc8c809ec74 100644 --- a/.repos/alchemy-effect/examples/aws-eks/package.json +++ b/.repos/alchemy-effect/examples/aws-eks/package.json @@ -16,8 +16,8 @@ }, "dependencies": { "@distilled.cloud/aws": "workspace:*", - "@effect/platform-node": "catalog:effect", + "@effect/platform-node": "catalog:", "alchemy": "workspace:*", - "effect": "catalog:effect" + "effect": "catalog:" } } diff --git a/.repos/alchemy-effect/examples/aws-hyperpod/README.md b/.repos/alchemy-effect/examples/aws-hyperpod/README.md index 61dec75315043923c32d5518e6e7dd4852300467..5cf07092cc2e26e4d1d4e7fe10f43c0032e973a0 100644 --- a/.repos/alchemy-effect/examples/aws-hyperpod/README.md +++ b/.repos/alchemy-effect/examples/aws-hyperpod/README.md @@ -92,8 +92,8 @@ sbatch --nodes=1 train.sbatch like any other Kubernetes Job or Deployment on EKS. ```sh -bun alchemy deploy ./eks.run.ts # EKS ~10-15 min + HyperPod ~10-20 min -bun alchemy destroy ./eks.run.ts +bun alchemy deploy --config ./eks.run.ts # EKS ~10-15 min + HyperPod ~10-20 min +bun alchemy destroy --config ./eks.run.ts ``` ## Inspection diff --git a/.repos/alchemy-effect/examples/aws-hyperpod/eks.run.ts b/.repos/alchemy-effect/examples/aws-hyperpod/eks.run.ts index 2c182a63f405fffa9fd035111e3fe4e4a4ca3dc4..cfdec2b72c6344bf40aa0e67256b5f259e0df47c 100644 --- a/.repos/alchemy-effect/examples/aws-hyperpod/eks.run.ts +++ b/.repos/alchemy-effect/examples/aws-hyperpod/eks.run.ts @@ -12,7 +12,7 @@ * TypeScript and pinned + governed through HyperPod resource attributes * (`quota.namespace`, `quota.queueName`, the group's `nodeSelector`). * - * Deploy with `bun alchemy deploy ./eks.run.ts`. The EKS control plane + * Deploy with `bun alchemy deploy --config ./eks.run.ts`. The EKS control plane * takes ~10-15 minutes and the HyperPod cluster another ~10-20. */ import * as Alchemy from "alchemy"; diff --git a/.repos/alchemy-effect/examples/aws-hyperpod/package.json b/.repos/alchemy-effect/examples/aws-hyperpod/package.json index 13cd9d078b020009f64371ddf5efc61dbe6c8604..b07de1b885ed608a976549ef1d7abaebdaf25fe7 100644 --- a/.repos/alchemy-effect/examples/aws-hyperpod/package.json +++ b/.repos/alchemy-effect/examples/aws-hyperpod/package.json @@ -16,8 +16,8 @@ }, "dependencies": { "@distilled.cloud/aws": "workspace:*", - "@effect/platform-node": "catalog:effect", + "@effect/platform-node": "catalog:", "alchemy": "workspace:*", - "effect": "catalog:effect" + "effect": "catalog:" } } diff --git a/.repos/alchemy-effect/examples/aws-lambda-httpapi/package.json b/.repos/alchemy-effect/examples/aws-lambda-httpapi/package.json index 39880fc5c77e8b1030ec07e124e5df2a72bbe026..2b8a185c4e7328647d32972bbaa496c3cc0a8d7c 100644 --- a/.repos/alchemy-effect/examples/aws-lambda-httpapi/package.json +++ b/.repos/alchemy-effect/examples/aws-lambda-httpapi/package.json @@ -16,8 +16,8 @@ }, "dependencies": { "@distilled.cloud/aws": "workspace:*", - "@effect/platform-node": "catalog:effect", + "@effect/platform-node": "catalog:", "alchemy": "workspace:*", - "effect": "catalog:effect" + "effect": "catalog:" } } diff --git a/.repos/alchemy-effect/examples/aws-lambda-rpc/package.json b/.repos/alchemy-effect/examples/aws-lambda-rpc/package.json index ed68c27416e1e7f1f5007ce34b8c9432c3b8907a..48b5e2f504acd0f45d22cd96fa1585e0c53019cc 100644 --- a/.repos/alchemy-effect/examples/aws-lambda-rpc/package.json +++ b/.repos/alchemy-effect/examples/aws-lambda-rpc/package.json @@ -16,8 +16,8 @@ }, "dependencies": { "@distilled.cloud/aws": "workspace:*", - "@effect/platform-node": "catalog:effect", + "@effect/platform-node": "catalog:", "alchemy": "workspace:*", - "effect": "catalog:effect" + "effect": "catalog:" } } diff --git a/.repos/alchemy-effect/examples/aws-lambda/package.json b/.repos/alchemy-effect/examples/aws-lambda/package.json index 11fcdee265169b054c8192acdb1e975034058ccd..a46cf4f37d7ed81b142ab0536e4cd9b77d960614 100644 --- a/.repos/alchemy-effect/examples/aws-lambda/package.json +++ b/.repos/alchemy-effect/examples/aws-lambda/package.json @@ -17,8 +17,11 @@ }, "dependencies": { "@distilled.cloud/aws": "workspace:*", - "@effect/platform-node": "catalog:effect", + "@effect/platform-node": "catalog:", "alchemy": "workspace:*", - "effect": "catalog:effect" + "effect": "catalog:" + }, + "devDependencies": { + "alchemy-test": "workspace:*" } } diff --git a/.repos/alchemy-effect/examples/aws-lambda/test/dev.test.ts b/.repos/alchemy-effect/examples/aws-lambda/test/dev.test.ts index 208962dabb7e7fae6045bbdfd10e5190bef415c5..a197b0e130085116d0f89895f1a88369d68409e1 100644 --- a/.repos/alchemy-effect/examples/aws-lambda/test/dev.test.ts +++ b/.repos/alchemy-effect/examples/aws-lambda/test/dev.test.ts @@ -18,135 +18,35 @@ * the notification publish ride the local Subscription. */ import { afterAll, expect, test } from "bun:test"; -import { spawn, spawnSync } from "node:child_process"; +import { DevCli, fetchOk } from "alchemy-test/DevCli"; +import { spawnSync } from "node:child_process"; import * as path from "node:path"; const root = path.resolve(import.meta.dirname, ".."); -// Spawn the CLI entry directly (not through `bun run` / the cli.js -// launcher) so signals hit the actual CLI process, whose scope teardown -// kills the exec child and the provider sidecars. -const alchemyBin = path.join( - root, - "node_modules", - "alchemy", - "bin", - "alchemy.ts", -); -// Isolated stage so this suite never fights integ.test.ts (same stack -// name) over state rows. const STAGE = "dev-cli-test"; +const cli = new DevCli({ root, stage: STAGE }); // The whole suite needs docker (floci runs as a container). const dockerAvailable = spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0; -let proc: ReturnType | undefined; -let output = ""; - -const pump = (stream: NodeJS.ReadableStream) => { - stream.on("data", (chunk: Buffer) => { - const text = chunk.toString(); - output += text; - if (process.env.DEBUG) process.stderr.write(text); - }); -}; - -/** Bounded poll for a (possibly async) producer to yield a value. */ -const pollUntil = async ( - what: string, - f: () => T | undefined | Promise, - { tries = 30, delayMs = 1000 }: { tries?: number; delayMs?: number } = {}, -): Promise => { - for (let i = 0; i < tries; i++) { - const value = await f(); - if (value !== undefined) return value; - await Bun.sleep(delayMs); - } - throw new Error( - `Timed out waiting for ${what}.\n--- alchemy dev output (tail) ---\n${output.slice(-4000)}`, - ); -}; - -/** Fetch with retries — the emulator's URL proxy takes a moment to serve. */ -const fetchOk = async ( - url: string | URL, - init?: RequestInit, - { tries = 20, delayMs = 500 }: { tries?: number; delayMs?: number } = {}, -) => { - let last: Response | undefined; - for (let i = 0; i < tries; i++) { - try { - last = await fetch(url, init); - if (last.ok) return last; - } catch { - // proxy not listening yet - } - await Bun.sleep(delayMs); - } - throw new Error( - `${init?.method ?? "GET"} ${url} never returned 2xx (last status: ${last?.status})`, - ); -}; - -/** Extract a stack-output URL the CLI prints on stdout. */ -const outputUrl = (key: string) => - output.match(new RegExp(`\\b${key}:\\s*['"]?(http[^\\s'",]+)`))?.[1]; - afterAll(async () => { - if (proc?.pid) { - // Ctrl-C semantics: signal the whole PROCESS GROUP (the CLI, its - // `--watch` exec child, and the provider sidecars). - const killGroup = (signal: NodeJS.Signals) => { - try { - process.kill(-proc!.pid!, signal); - } catch { - // group already gone - } - }; - const exited = new Promise((resolve) => proc!.once("exit", resolve)); - killGroup("SIGINT"); - await Promise.race([exited, Bun.sleep(15_000)]); - if (proc.exitCode === null && proc.signalCode === null) { - killGroup("SIGKILL"); - await Promise.race([exited, Bun.sleep(5_000)]); - } - } + await cli.stop(); if (!process.env.NO_DESTROY && dockerAvailable) { - const destroyed = spawnSync( - "bun", - [alchemyBin, "destroy", "--stage", STAGE, "--yes"], - { - cwd: root, - stdio: "inherit", - timeout: 120_000, - }, - ); - if (destroyed.status !== 0) { - throw new Error( - `alchemy destroy exited ${destroyed.status} — local teardown must succeed`, - ); - } + cli.destroy(); } }, 180_000); test.skipIf(!dockerAvailable)( "alchemy dev applies the mixed local/live Job stack and serves jobs", async () => { - proc = spawn("bun", [alchemyBin, "dev", "--stage", STAGE], { - cwd: root, - // Own process group, so teardown can deliver Ctrl-C to the whole - // tree the way a terminal would. - detached: true, - stdio: ["ignore", "pipe", "pipe"], - }); - pump(proc.stdout!); - pump(proc.stderr!); + cli.start(); // The first dev deploy may pull the floci image and provision the // local data plane before printing stack outputs. - const url = await pollUntil( + const url = await cli.pollUntil( "url in stack outputs", - () => outputUrl("url"), + () => cli.outputUrl("url"), { tries: 300, delayMs: 1000 }, ); @@ -161,7 +61,7 @@ test.skipIf(!dockerAvailable)( // against real AWS with floci ARNs. Outputs printing means apply // succeeded, but pin the failure banner too so a partial apply that // still prints outputs can never sneak past. - expect(output).not.toContain("apply failed"); + expect(cli.output).not.toContain("apply failed"); const api = new URL(url); api.search = ""; diff --git a/.repos/alchemy-effect/examples/aws-rds/package.json b/.repos/alchemy-effect/examples/aws-rds/package.json index a11b9c67a2d17fa800ae7eba45b9961abd20e5d6..84eb15c5978288f75554d6bc9cbae09a847e8815 100644 --- a/.repos/alchemy-effect/examples/aws-rds/package.json +++ b/.repos/alchemy-effect/examples/aws-rds/package.json @@ -16,10 +16,10 @@ }, "dependencies": { "@distilled.cloud/aws": "workspace:*", - "@effect/platform-node": "catalog:effect", - "@types/pg": "catalog:database", + "@effect/platform-node": "catalog:", + "@types/pg": "catalog:", "alchemy": "workspace:*", - "effect": "catalog:effect", - "pg": "catalog:database" + "effect": "catalog:", + "pg": "catalog:" } } diff --git a/.repos/alchemy-effect/examples/aws-rest-api/package.json b/.repos/alchemy-effect/examples/aws-rest-api/package.json index d3aba64986c2ba333f0ac3e741d3db9e9cb93eb5..5897b008fac6b77952ee7726ad57e3196adc18d8 100644 --- a/.repos/alchemy-effect/examples/aws-rest-api/package.json +++ b/.repos/alchemy-effect/examples/aws-rest-api/package.json @@ -17,6 +17,6 @@ "dependencies": { "@distilled.cloud/aws": "workspace:*", "alchemy": "workspace:*", - "effect": "catalog:effect" + "effect": "catalog:" } } diff --git a/.repos/alchemy-effect/examples/aws-router/package.json b/.repos/alchemy-effect/examples/aws-router/package.json index 402be4f5ff4ff464df38793904a2f75bb739be59..486b3ab81e67fd10d259deea934cf0c06d985559 100644 --- a/.repos/alchemy-effect/examples/aws-router/package.json +++ b/.repos/alchemy-effect/examples/aws-router/package.json @@ -17,19 +17,19 @@ "dependencies": { "@alchemy.run/frontend-frameworks": "workspace:*", "@distilled.cloud/aws": "workspace:*", - "@effect/platform-node": "catalog:effect", + "@effect/platform-node": "catalog:", "alchemy": "workspace:*", - "effect": "catalog:effect", - "react": "catalog:frontend", - "react-dom": "catalog:frontend" + "effect": "catalog:", + "react": "catalog:", + "react-dom": "catalog:" }, "devDependencies": { - "@tailwindcss/vite": "catalog:frontend", - "@types/react": "catalog:frontend", - "@types/react-dom": "catalog:frontend", - "@vitejs/plugin-react": "catalog:frontend", - "tailwindcss": "catalog:frontend", - "typescript": "catalog:build", - "vite": "catalog:build" + "@tailwindcss/vite": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "tailwindcss": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" } } diff --git a/.repos/alchemy-effect/examples/aws-static-site/package.json b/.repos/alchemy-effect/examples/aws-static-site/package.json index 16fcadf9d6a92068da079e4f06965ce8585be78e..d10546bc413dc47152c3766238da4bcb4b9e08ba 100644 --- a/.repos/alchemy-effect/examples/aws-static-site/package.json +++ b/.repos/alchemy-effect/examples/aws-static-site/package.json @@ -17,8 +17,8 @@ }, "dependencies": { "@distilled.cloud/aws": "workspace:*", - "@effect/platform-node": "catalog:effect", + "@effect/platform-node": "catalog:", "alchemy": "workspace:*", - "effect": "catalog:effect" + "effect": "catalog:" } } diff --git a/.repos/alchemy-effect/examples/aws-website-astro/package.json b/.repos/alchemy-effect/examples/aws-website-astro/package.json index 88c2abd967adc714328c8a6b0c029806e63c5f8a..87a9bd7dad0c8fabc481aff21c9ba0a38a8e67e8 100644 --- a/.repos/alchemy-effect/examples/aws-website-astro/package.json +++ b/.repos/alchemy-effect/examples/aws-website-astro/package.json @@ -10,17 +10,18 @@ "test": "bun test" }, "dependencies": { - "astro": "catalog:frontend" + "astro": "catalog:" }, "devDependencies": { "@alchemy.run/frontend-frameworks": "workspace:*", "@distilled.cloud/aws": "workspace:*", - "@effect/platform-node": "catalog:effect", - "@tailwindcss/vite": "catalog:frontend", + "@effect/platform-node": "catalog:", + "@tailwindcss/vite": "catalog:", "alchemy": "workspace:*", - "effect": "catalog:effect", - "tailwindcss": "catalog:frontend", - "typescript": "catalog:build" + "alchemy-test": "workspace:*", + "effect": "catalog:", + "tailwindcss": "catalog:", + "typescript": "catalog:" }, "engines": { "node": ">=22" diff --git a/.repos/alchemy-effect/examples/aws-website-astro/test/dev.test.ts b/.repos/alchemy-effect/examples/aws-website-astro/test/dev.test.ts index d5b5a67e5f9b5881a687098ab3b2141cb3c1adfa..d48fd26f62dc38f3312f31a61fc1eb0b1ca91cc2 100644 --- a/.repos/alchemy-effect/examples/aws-website-astro/test/dev.test.ts +++ b/.repos/alchemy-effect/examples/aws-website-astro/test/dev.test.ts @@ -14,24 +14,15 @@ * framework's HMR without a redeploy */ import { afterAll, expect, test } from "bun:test"; -import { spawn, spawnSync } from "node:child_process"; +import { DevCli, fetchOk } from "alchemy-test/DevCli"; import * as fs from "node:fs"; import * as path from "node:path"; const root = path.resolve(import.meta.dirname, ".."); -// Spawn the CLI entry directly (not through `bun run` / the cli.js -// launcher) so signals hit the actual CLI process, whose scope teardown -// kills the dev server and the provider sidecars. -const alchemyBin = path.join( - root, - "node_modules", - "alchemy", - "bin", - "alchemy.ts", -); // Isolated stage so this suite never fights integ.test.ts (same stack // name) over state rows. const STAGE = "dev-cli-test"; +const cli = new DevCli({ root, stage: STAGE }); // Hot-reload surface: the SSR index page. The test rewrites it in place // with the CLI running, then restores it. @@ -40,110 +31,20 @@ const pageSource = fs.readFileSync(pagePath, "utf8"); const MARKER = "server-rendered in an AWS Lambda"; const MARKER_V2 = "server-rendered in an AWS Lambda [dev-v2]"; -let proc: ReturnType | undefined; -let output = ""; - -const pump = (stream: NodeJS.ReadableStream) => { - stream.on("data", (chunk: Buffer) => { - const text = chunk.toString(); - output += text; - if (process.env.DEBUG) process.stderr.write(text); - }); -}; - -/** Bounded poll for a (possibly async) producer to yield a value. */ -const pollUntil = async ( - what: string, - f: () => T | undefined | Promise, - { tries = 30, delayMs = 1000 }: { tries?: number; delayMs?: number } = {}, -): Promise => { - for (let i = 0; i < tries; i++) { - const value = await f(); - if (value !== undefined) return value; - await Bun.sleep(delayMs); - } - throw new Error( - `Timed out waiting for ${what}.\n--- alchemy dev output (tail) ---\n${output.slice(-4000)}`, - ); -}; - -/** Fetch with retries — the dev server takes a moment to start serving. */ -const fetchOk = async ( - url: string | URL, - { tries = 30, delayMs = 1000 }: { tries?: number; delayMs?: number } = {}, -) => { - let last: Response | undefined; - for (let i = 0; i < tries; i++) { - try { - last = await fetch(url); - if (last.ok) return last; - } catch { - // dev server not listening yet - } - await Bun.sleep(delayMs); - } - throw new Error( - `GET ${url} never returned 2xx (last status: ${last?.status})`, - ); -}; - -/** Extract the stack-output URL the CLI prints on stdout. */ -const outputUrl = () => output.match(/\burl:\s*['"]?(http[^\s'",]+)/)?.[1]; - afterAll(async () => { // Always leave the repo tree clean, even on a mid-reload failure. fs.writeFileSync(pagePath, pageSource); - if (proc?.pid) { - // Ctrl-C semantics: signal the whole PROCESS GROUP (the CLI, its - // `--watch` exec child, and the provider sidecars). - const killGroup = (signal: NodeJS.Signals) => { - try { - process.kill(-proc!.pid!, signal); - } catch { - // group already gone - } - }; - const exited = new Promise((resolve) => proc!.once("exit", resolve)); - killGroup("SIGINT"); - await Promise.race([exited, Bun.sleep(15_000)]); - if (proc.exitCode === null && proc.signalCode === null) { - killGroup("SIGKILL"); - await Promise.race([exited, Bun.sleep(5_000)]); - } - } - if (!process.env.NO_DESTROY) { - const destroyed = spawnSync( - "bun", - [alchemyBin, "destroy", "--stage", STAGE, "--yes"], - { - cwd: root, - stdio: "inherit", - timeout: 120_000, - }, - ); - if (destroyed.status !== 0) { - throw new Error( - `alchemy destroy exited ${destroyed.status} — local teardown must succeed`, - ); - } - } + await cli.stop(); + if (!process.env.NO_DESTROY) cli.destroy(); }, 180_000); test( "alchemy dev serves the Astro site locally with hot reload", async () => { - proc = spawn("bun", [alchemyBin, "dev", "--stage", STAGE], { - cwd: root, - // Own process group, so teardown can deliver Ctrl-C to the whole - // tree the way a terminal would. - detached: true, - stdio: ["ignore", "pipe", "pipe"], - }); - pump(proc.stdout!); - pump(proc.stderr!); + cli.start(); - const url = await pollUntil("url in stack outputs", outputUrl, { + const url = await cli.outputUrlWhenReady("url", { tries: 180, delayMs: 1000, }); @@ -170,7 +71,7 @@ test( // ── HOT RELOAD: rewrite the index page with the CLI still running — // the framework dev server serves the new markup without a deploy ── fs.writeFileSync(pagePath, pageSource.replace(MARKER, MARKER_V2)); - await pollUntil( + await cli.pollUntil( "hot-reloaded page (v2 marker)", async () => { try { @@ -188,7 +89,7 @@ test( // Restore — the swap back is itself a second hot reload and leaves // the checked-in tree clean. fs.writeFileSync(pagePath, pageSource); - await pollUntil( + await cli.pollUntil( "restored page (v2 marker gone)", async () => { try { diff --git a/.repos/alchemy-effect/examples/aws-website-astro/test/integ.test.ts b/.repos/alchemy-effect/examples/aws-website-astro/test/integ.test.ts index 22a1fb220ae7fe86e258f62106aae15cb431e64b..018d9e33cfb11e971420fe9250587ee37158ca6e 100644 --- a/.repos/alchemy-effect/examples/aws-website-astro/test/integ.test.ts +++ b/.repos/alchemy-effect/examples/aws-website-astro/test/integ.test.ts @@ -45,7 +45,6 @@ const getBodyWhenReady = (url: string, expected: string) => const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ providers: AWS.providers(), state: AWS.state(), - stage: "test", }); // The first deploy runs the full Astro build AND creates a CloudFront diff --git a/.repos/alchemy-effect/examples/aws-website-foldkit/package.json b/.repos/alchemy-effect/examples/aws-website-foldkit/package.json index 527707f5924651268ce24a6de6c99e6599812ff1..9b089a72cb3341322212992caa3219778c30a2b3 100644 --- a/.repos/alchemy-effect/examples/aws-website-foldkit/package.json +++ b/.repos/alchemy-effect/examples/aws-website-foldkit/package.json @@ -10,19 +10,19 @@ "test": "bun test" }, "dependencies": { - "effect": "catalog:effect", - "foldkit": "catalog:frontend" + "effect": "catalog:", + "foldkit": "catalog:" }, "devDependencies": { "@alchemy.run/frontend-frameworks": "workspace:*", "@distilled.cloud/aws": "workspace:*", - "@effect/platform-node": "catalog:effect", - "@foldkit/vite-plugin": "catalog:frontend", - "@tailwindcss/vite": "catalog:frontend", + "@effect/platform-node": "catalog:", + "@foldkit/vite-plugin": "catalog:", + "@tailwindcss/vite": "catalog:", "alchemy": "workspace:*", - "tailwindcss": "catalog:frontend", - "typescript": "catalog:build", - "vite": "catalog:build" + "tailwindcss": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" }, "engines": { "node": ">=22" diff --git a/.repos/alchemy-effect/examples/aws-website-foldkit/src/main.ts b/.repos/alchemy-effect/examples/aws-website-foldkit/src/main.ts index 54e658df10592793fb6bead6b4588f9e37333866..70271292e79c41b7c5fd2a3a256bb163f912cda3 100644 --- a/.repos/alchemy-effect/examples/aws-website-foldkit/src/main.ts +++ b/.repos/alchemy-effect/examples/aws-website-foldkit/src/main.ts @@ -1,5 +1,5 @@ import { Schema as S } from "effect"; -import type { Command, Runtime } from "foldkit"; +import type { Runtime, Update } from "foldkit"; import type { Document, HtmlBuilder } from "foldkit/html"; import { card } from "./components/Card.ts"; @@ -19,11 +19,13 @@ export type Message = never; export const update = ( model: Model, _message: Message, -): readonly [Model, ReadonlyArray>] => [model, []]; +): Update.Return => ({ model }); // INIT -export const init: Runtime.ApplicationInit = () => [{}, []]; +export const init: Runtime.ApplicationInit = () => ({ + model: {}, +}); // VIEW diff --git a/.repos/alchemy-effect/examples/aws-website-foldkit/test/integ.test.ts b/.repos/alchemy-effect/examples/aws-website-foldkit/test/integ.test.ts index 473d763a17c4dcb9c93af24614bc05a02c490f2d..ef6b973ecc5c5f52fc14ec7112a7fd356926e6c7 100644 --- a/.repos/alchemy-effect/examples/aws-website-foldkit/test/integ.test.ts +++ b/.repos/alchemy-effect/examples/aws-website-foldkit/test/integ.test.ts @@ -44,7 +44,6 @@ const getBodyWhenReady = (url: string, expected: string) => const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ providers: AWS.providers(), state: AWS.state(), - stage: "test", }); // The first deploy runs the full Vite build AND creates a CloudFront diff --git a/.repos/alchemy-effect/examples/aws-website-nextjs/package.json b/.repos/alchemy-effect/examples/aws-website-nextjs/package.json index f18b15fdd2396106f6fa8c6add100ed7ce20de3d..4e1c51957ae4fa9ed98ffff878af265270eccedf 100644 --- a/.repos/alchemy-effect/examples/aws-website-nextjs/package.json +++ b/.repos/alchemy-effect/examples/aws-website-nextjs/package.json @@ -11,22 +11,23 @@ "test": "bun test" }, "dependencies": { - "next": "catalog:frontend", - "react": "^19.2.0", - "react-dom": "^19.2.0" + "next": "catalog:", + "react": "catalog:", + "react-dom": "catalog:" }, "devDependencies": { "@alchemy.run/frontend-frameworks": "workspace:*", "@distilled.cloud/aws": "workspace:*", - "@effect/platform-node": "catalog:effect", - "@opennextjs/aws": "catalog:frontend", - "@tailwindcss/postcss": "catalog:frontend", - "@types/react": "^19.2.2", - "@types/react-dom": "^19.2.2", + "@effect/platform-node": "catalog:", + "@opennextjs/aws": "catalog:", + "@tailwindcss/postcss": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", "alchemy": "workspace:*", - "effect": "catalog:effect", - "tailwindcss": "catalog:frontend", - "typescript": "catalog:frontend" + "alchemy-test": "workspace:*", + "effect": "catalog:", + "tailwindcss": "catalog:", + "typescript": "catalog:nextjs" }, "engines": { "node": ">=22" diff --git a/.repos/alchemy-effect/examples/aws-website-nextjs/test/dev.test.ts b/.repos/alchemy-effect/examples/aws-website-nextjs/test/dev.test.ts index 557c557a0f56c11ba6727438492f70b79189db33..94e1a1ca691f7670eb114aab039d2b82e2582676 100644 --- a/.repos/alchemy-effect/examples/aws-website-nextjs/test/dev.test.ts +++ b/.repos/alchemy-effect/examples/aws-website-nextjs/test/dev.test.ts @@ -14,24 +14,15 @@ * without a redeploy */ import { afterAll, expect, test } from "bun:test"; -import { spawn, spawnSync } from "node:child_process"; +import { DevCli, fetchOk } from "alchemy-test/DevCli"; import * as fs from "node:fs"; import * as path from "node:path"; const root = path.resolve(import.meta.dirname, ".."); -// Spawn the CLI entry directly (not through `bun run` / the cli.js -// launcher) so signals hit the actual CLI process, whose scope teardown -// kills the dev server and the provider sidecars. -const alchemyBin = path.join( - root, - "node_modules", - "alchemy", - "bin", - "alchemy.ts", -); // Isolated stage so this suite never fights integ.test.ts (same stack // name) over state rows. const STAGE = "dev-cli-test"; +const cli = new DevCli({ root, stage: STAGE }); // Hot-reload surface: the SSR index page. The test rewrites it in place // with the CLI running, then restores it. @@ -40,101 +31,20 @@ const pageSource = fs.readFileSync(pagePath, "utf8"); const MARKER = "Next.js on AWS"; const MARKER_V2 = "Next.js on AWS [dev-v2]"; -let proc: ReturnType | undefined; -let output = ""; - -const pump = (stream: NodeJS.ReadableStream) => { - stream.on("data", (chunk: Buffer) => { - const text = chunk.toString(); - output += text; - if (process.env.DEBUG) process.stderr.write(text); - }); -}; - -/** Bounded poll for a (possibly async) producer to yield a value. */ -const pollUntil = async ( - what: string, - f: () => T | undefined | Promise, - { tries = 30, delayMs = 1000 }: { tries?: number; delayMs?: number } = {}, -): Promise => { - for (let i = 0; i < tries; i++) { - const value = await f(); - if (value !== undefined) return value; - await Bun.sleep(delayMs); - } - throw new Error( - `Timed out waiting for ${what}.\n--- alchemy dev output (tail) ---\n${output.slice(-4000)}`, - ); -}; - -/** Fetch with retries — the dev server takes a moment to start serving. */ -const fetchOk = async ( - url: string | URL, - { tries = 30, delayMs = 1000 }: { tries?: number; delayMs?: number } = {}, -) => { - let last: Response | undefined; - for (let i = 0; i < tries; i++) { - try { - last = await fetch(url); - if (last.ok) return last; - } catch { - // dev server not listening yet - } - await Bun.sleep(delayMs); - } - throw new Error( - `GET ${url} never returned 2xx (last status: ${last?.status})`, - ); -}; - -/** Extract the stack-output URL the CLI prints on stdout. */ -const outputUrl = () => output.match(/\burl:\s*['"]?(http[^\s'",]+)/)?.[1]; - afterAll(async () => { // Always leave the repo tree clean, even on a mid-reload failure. fs.writeFileSync(pagePath, pageSource); - if (proc?.pid) { - // Ctrl-C semantics: signal the whole PROCESS GROUP (the CLI, its - // `--watch` exec child, and the provider sidecars). - const killGroup = (signal: NodeJS.Signals) => { - try { - process.kill(-proc!.pid!, signal); - } catch { - // group already gone - } - }; - const exited = new Promise((resolve) => proc!.once("exit", resolve)); - killGroup("SIGINT"); - await Promise.race([exited, Bun.sleep(15_000)]); - if (proc.exitCode === null && proc.signalCode === null) { - killGroup("SIGKILL"); - await Promise.race([exited, Bun.sleep(5_000)]); - } - } - if (!process.env.NO_DESTROY) { - spawnSync("bun", [alchemyBin, "destroy", "--stage", STAGE, "--yes"], { - cwd: root, - stdio: "inherit", - timeout: 120_000, - }); - } + await cli.stop(); + if (!process.env.NO_DESTROY) cli.destroy(); }, 180_000); test( "alchemy dev serves the Next.js site locally with hot reload", async () => { - proc = spawn("bun", [alchemyBin, "dev", "--stage", STAGE], { - cwd: root, - // Own process group, so teardown can deliver Ctrl-C to the whole - // tree the way a terminal would. - detached: true, - stdio: ["ignore", "pipe", "pipe"], - }); - pump(proc.stdout!); - pump(proc.stderr!); + cli.start(); - const url = await pollUntil("url in stack outputs", outputUrl, { + const url = await cli.outputUrlWhenReady("url", { tries: 180, delayMs: 1000, }); @@ -163,7 +73,7 @@ test( // ── HOT RELOAD: rewrite the index page with the CLI still running — // the framework dev server serves the new markup without a deploy ── fs.writeFileSync(pagePath, pageSource.replace(MARKER, MARKER_V2)); - await pollUntil( + await cli.pollUntil( "hot-reloaded page (v2 marker)", async () => { try { @@ -181,7 +91,7 @@ test( // Restore — the swap back is itself a second hot reload and leaves // the checked-in tree clean. fs.writeFileSync(pagePath, pageSource); - await pollUntil( + await cli.pollUntil( "restored page (v2 marker gone)", async () => { try { diff --git a/.repos/alchemy-effect/examples/aws-website-nextjs/test/integ.test.ts b/.repos/alchemy-effect/examples/aws-website-nextjs/test/integ.test.ts index e73ea31607d315076cf699d2fdcbe4a23ddf911a..0d9ac904407863a747277bb9227af0edea45aedb 100644 --- a/.repos/alchemy-effect/examples/aws-website-nextjs/test/integ.test.ts +++ b/.repos/alchemy-effect/examples/aws-website-nextjs/test/integ.test.ts @@ -45,7 +45,6 @@ const getBodyWhenReady = (url: string, expected: string) => const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ providers: AWS.providers(), state: AWS.state(), - stage: "test", }); // The first deploy runs the full Next.js + OpenNext build AND creates a diff --git a/.repos/alchemy-effect/examples/aws-website-nuxt/package.json b/.repos/alchemy-effect/examples/aws-website-nuxt/package.json index e1ddf2d10bb915b2477e884e56bfe0e88d008f99..3d4edef455e316a7e17b8a34d80bf53e0257b675 100644 --- a/.repos/alchemy-effect/examples/aws-website-nuxt/package.json +++ b/.repos/alchemy-effect/examples/aws-website-nuxt/package.json @@ -11,18 +11,19 @@ }, "dependencies": { "alchemy": "workspace:*", - "effect": "catalog:effect" + "effect": "catalog:" }, "devDependencies": { "@alchemy.run/frontend-frameworks": "workspace:*", "@distilled.cloud/aws": "workspace:*", - "@effect/platform-node": "catalog:effect", - "@tailwindcss/vite": "catalog:frontend", - "@types/node": "^24.12.0", - "nitropack": "catalog:frontend", - "nuxt": "catalog:frontend", - "tailwindcss": "catalog:frontend", - "typescript": "catalog:build" + "@effect/platform-node": "catalog:", + "@tailwindcss/vite": "catalog:", + "@types/node": "catalog:node24", + "alchemy-test": "workspace:*", + "nitropack": "catalog:", + "nuxt": "catalog:", + "tailwindcss": "catalog:", + "typescript": "catalog:" }, "repository": { "type": "git", diff --git a/.repos/alchemy-effect/examples/aws-website-nuxt/test/dev.test.ts b/.repos/alchemy-effect/examples/aws-website-nuxt/test/dev.test.ts index 21f0e63d223de65a105849fbf79b0fc6fa2a5b86..2f191c71ccdf287b5b1486053bc1596ff723d6c9 100644 --- a/.repos/alchemy-effect/examples/aws-website-nuxt/test/dev.test.ts +++ b/.repos/alchemy-effect/examples/aws-website-nuxt/test/dev.test.ts @@ -15,24 +15,15 @@ * HMR without a redeploy */ import { afterAll, expect, test } from "bun:test"; -import { spawn, spawnSync } from "node:child_process"; +import { DevCli, fetchOk } from "alchemy-test/DevCli"; import * as fs from "node:fs"; import * as path from "node:path"; const root = path.resolve(import.meta.dirname, ".."); -// Spawn the CLI entry directly (not through `bun run` / the cli.js -// launcher) so signals hit the actual CLI process, whose scope teardown -// kills the dev server and the provider sidecars. -const alchemyBin = path.join( - root, - "node_modules", - "alchemy", - "bin", - "alchemy.ts", -); // Isolated stage so this suite never fights integ.test.ts (same stack // name) over state rows. const STAGE = "dev-cli-test"; +const cli = new DevCli({ root, stage: STAGE }); // Hot-reload surface: the SSR index page. The test rewrites it in place // with the CLI running, then restores it. @@ -41,101 +32,20 @@ const pageSource = fs.readFileSync(pagePath, "utf8"); const MARKER = "Nuxt on AWS"; const MARKER_V2 = "Nuxt on AWS [dev-v2]"; -let proc: ReturnType | undefined; -let output = ""; - -const pump = (stream: NodeJS.ReadableStream) => { - stream.on("data", (chunk: Buffer) => { - const text = chunk.toString(); - output += text; - if (process.env.DEBUG) process.stderr.write(text); - }); -}; - -/** Bounded poll for a (possibly async) producer to yield a value. */ -const pollUntil = async ( - what: string, - f: () => T | undefined | Promise, - { tries = 30, delayMs = 1000 }: { tries?: number; delayMs?: number } = {}, -): Promise => { - for (let i = 0; i < tries; i++) { - const value = await f(); - if (value !== undefined) return value; - await Bun.sleep(delayMs); - } - throw new Error( - `Timed out waiting for ${what}.\n--- alchemy dev output (tail) ---\n${output.slice(-4000)}`, - ); -}; - -/** Fetch with retries — the dev server takes a moment to start serving. */ -const fetchOk = async ( - url: string | URL, - { tries = 30, delayMs = 1000 }: { tries?: number; delayMs?: number } = {}, -) => { - let last: Response | undefined; - for (let i = 0; i < tries; i++) { - try { - last = await fetch(url); - if (last.ok) return last; - } catch { - // dev server not listening yet - } - await Bun.sleep(delayMs); - } - throw new Error( - `GET ${url} never returned 2xx (last status: ${last?.status})`, - ); -}; - -/** Extract the stack-output URL the CLI prints on stdout. */ -const outputUrl = () => output.match(/\burl:\s*['"]?(http[^\s'",]+)/)?.[1]; - afterAll(async () => { // Always leave the repo tree clean, even on a mid-reload failure. fs.writeFileSync(pagePath, pageSource); - if (proc?.pid) { - // Ctrl-C semantics: signal the whole PROCESS GROUP (the CLI, its - // `--watch` exec child, and the provider sidecars). - const killGroup = (signal: NodeJS.Signals) => { - try { - process.kill(-proc!.pid!, signal); - } catch { - // group already gone - } - }; - const exited = new Promise((resolve) => proc!.once("exit", resolve)); - killGroup("SIGINT"); - await Promise.race([exited, Bun.sleep(15_000)]); - if (proc.exitCode === null && proc.signalCode === null) { - killGroup("SIGKILL"); - await Promise.race([exited, Bun.sleep(5_000)]); - } - } - if (!process.env.NO_DESTROY) { - spawnSync("bun", [alchemyBin, "destroy", "--stage", STAGE, "--yes"], { - cwd: root, - stdio: "inherit", - timeout: 120_000, - }); - } + await cli.stop(); + if (!process.env.NO_DESTROY) cli.destroy(); }, 180_000); test( "alchemy dev serves the Nuxt site locally with hot reload", async () => { - proc = spawn("bun", [alchemyBin, "dev", "--stage", STAGE], { - cwd: root, - // Own process group, so teardown can deliver Ctrl-C to the whole - // tree the way a terminal would. - detached: true, - stdio: ["ignore", "pipe", "pipe"], - }); - pump(proc.stdout!); - pump(proc.stderr!); + cli.start(); - const url = await pollUntil("url in stack outputs", outputUrl, { + const url = await cli.outputUrlWhenReady("url", { tries: 180, delayMs: 1000, }); @@ -168,7 +78,7 @@ test( // ── HOT RELOAD: rewrite the index page with the CLI still running — // the framework dev server serves the new markup without a deploy ── fs.writeFileSync(pagePath, pageSource.replace(MARKER, MARKER_V2)); - await pollUntil( + await cli.pollUntil( "hot-reloaded page (v2 marker)", async () => { try { @@ -186,7 +96,7 @@ test( // Restore — the swap back is itself a second hot reload and leaves // the checked-in tree clean. fs.writeFileSync(pagePath, pageSource); - await pollUntil( + await cli.pollUntil( "restored page (v2 marker gone)", async () => { try { diff --git a/.repos/alchemy-effect/examples/aws-website-nuxt/test/integ.test.ts b/.repos/alchemy-effect/examples/aws-website-nuxt/test/integ.test.ts index 0ba28b7e47bc0740f3f5825cc6cfb72b3f733f81..774e12ad715402aaf80ec799cd752a09ae22c3fa 100644 --- a/.repos/alchemy-effect/examples/aws-website-nuxt/test/integ.test.ts +++ b/.repos/alchemy-effect/examples/aws-website-nuxt/test/integ.test.ts @@ -45,7 +45,6 @@ const getBodyWhenReady = (url: string, expected: string) => const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ providers: AWS.providers(), state: AWS.state(), - stage: "test", }); // The first deploy runs the full Nuxt build AND creates a CloudFront diff --git a/.repos/alchemy-effect/examples/aws-website-react-router/package.json b/.repos/alchemy-effect/examples/aws-website-react-router/package.json index 23e9643fb6026bb80fcd6857d91f0282b6b00560..d960c493f5e775585dab2eb357e4f85e920354c6 100644 --- a/.repos/alchemy-effect/examples/aws-website-react-router/package.json +++ b/.repos/alchemy-effect/examples/aws-website-react-router/package.json @@ -10,24 +10,24 @@ "test": "bun test" }, "dependencies": { - "@react-router/node": "catalog:frontend", - "isbot": "catalog:frontend", - "react": "catalog:frontend", - "react-dom": "catalog:frontend", - "react-router": "catalog:frontend" + "@react-router/node": "catalog:", + "isbot": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-router": "catalog:" }, "devDependencies": { "@alchemy.run/frontend-frameworks": "workspace:*", "@distilled.cloud/aws": "workspace:*", - "@effect/platform-node": "catalog:effect", - "@react-router/dev": "catalog:frontend", - "@tailwindcss/vite": "catalog:frontend", - "@types/react": "catalog:frontend", + "@effect/platform-node": "catalog:", + "@react-router/dev": "catalog:", + "@tailwindcss/vite": "catalog:", + "@types/react": "catalog:", "alchemy": "workspace:*", - "effect": "catalog:effect", - "tailwindcss": "catalog:frontend", - "typescript": "catalog:build", - "vite": "catalog:build" + "effect": "catalog:", + "tailwindcss": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" }, "engines": { "node": ">=22" diff --git a/.repos/alchemy-effect/examples/aws-website-react-router/test/integ.test.ts b/.repos/alchemy-effect/examples/aws-website-react-router/test/integ.test.ts index d341715e78f85548c2aec27d90ab32a47178c4dd..232bc9d7a537298070fac7bcce42ade84a2d2f81 100644 --- a/.repos/alchemy-effect/examples/aws-website-react-router/test/integ.test.ts +++ b/.repos/alchemy-effect/examples/aws-website-react-router/test/integ.test.ts @@ -45,7 +45,6 @@ const getBodyWhenReady = (url: string, expected: string) => const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ providers: AWS.providers(), state: AWS.state(), - stage: "test", }); // The first deploy runs the full React Router build AND creates a diff --git a/.repos/alchemy-effect/examples/aws-website-solidstart/package.json b/.repos/alchemy-effect/examples/aws-website-solidstart/package.json index 725fc5f1a99b3582ac8f97b571c070617b345ddd..19e3693f1bcbca075bd80929dd5edfe74aac1779 100644 --- a/.repos/alchemy-effect/examples/aws-website-solidstart/package.json +++ b/.repos/alchemy-effect/examples/aws-website-solidstart/package.json @@ -10,21 +10,21 @@ "test": "bun test" }, "dependencies": { - "@solidjs/router": "catalog:frontend", - "@solidjs/start": "catalog:frontend", - "@solidjs/vite-plugin-nitro-2": "catalog:frontend", - "solid-js": "catalog:frontend" + "@solidjs/router": "catalog:", + "@solidjs/start": "catalog:", + "@solidjs/vite-plugin-nitro-2": "catalog:", + "solid-js": "catalog:" }, "devDependencies": { "@alchemy.run/frontend-frameworks": "workspace:*", "@distilled.cloud/aws": "workspace:*", - "@effect/platform-node": "catalog:effect", - "@tailwindcss/vite": "catalog:frontend", + "@effect/platform-node": "catalog:", + "@tailwindcss/vite": "catalog:", "alchemy": "workspace:*", - "effect": "catalog:effect", - "tailwindcss": "catalog:frontend", - "typescript": "catalog:build", - "vite": "catalog:build" + "effect": "catalog:", + "tailwindcss": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" }, "engines": { "node": ">=22" diff --git a/.repos/alchemy-effect/examples/aws-website-solidstart/test/integ.test.ts b/.repos/alchemy-effect/examples/aws-website-solidstart/test/integ.test.ts index 01f70e832e102613bca5d05f420a0b6a6f022947..39e195b281e3667c226152e967fca87fc48425cd 100644 --- a/.repos/alchemy-effect/examples/aws-website-solidstart/test/integ.test.ts +++ b/.repos/alchemy-effect/examples/aws-website-solidstart/test/integ.test.ts @@ -45,7 +45,6 @@ const getBodyWhenReady = (url: string, expected: string) => const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ providers: AWS.providers(), state: AWS.state(), - stage: "test", }); // The first deploy runs the full SolidStart (vite/nitro) build AND diff --git a/.repos/alchemy-effect/examples/aws-website-sveltekit/package.json b/.repos/alchemy-effect/examples/aws-website-sveltekit/package.json index a981da5781130ffc5347784ee62ec1170bc97bfb..1d685edba03d0070cc83ea5ed2c3678c5a32b20a 100644 --- a/.repos/alchemy-effect/examples/aws-website-sveltekit/package.json +++ b/.repos/alchemy-effect/examples/aws-website-sveltekit/package.json @@ -11,20 +11,21 @@ }, "dependencies": { "alchemy": "workspace:*", - "effect": "catalog:effect" + "effect": "catalog:" }, "devDependencies": { "@alchemy.run/frontend-frameworks": "workspace:*", "@distilled.cloud/aws": "workspace:*", - "@effect/platform-node": "catalog:effect", - "@sveltejs/kit": "catalog:frontend", - "@sveltejs/vite-plugin-svelte": "catalog:frontend", - "@tailwindcss/vite": "catalog:frontend", - "@types/node": "^24.12.0", - "svelte": "catalog:frontend", - "tailwindcss": "catalog:frontend", - "typescript": "catalog:build", - "vite": "catalog:build" + "@effect/platform-node": "catalog:", + "@sveltejs/kit": "catalog:", + "@sveltejs/vite-plugin-svelte": "catalog:", + "@tailwindcss/vite": "catalog:", + "@types/node": "catalog:node24", + "alchemy-test": "workspace:*", + "svelte": "catalog:", + "tailwindcss": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" }, "repository": { "type": "git", diff --git a/.repos/alchemy-effect/examples/aws-website-sveltekit/test/dev.test.ts b/.repos/alchemy-effect/examples/aws-website-sveltekit/test/dev.test.ts index 9f40bd6c57a403b7665f456ef710a0a7282501a9..47931f8485fb81c7e68bd942a506f9c0b86f9c10 100644 --- a/.repos/alchemy-effect/examples/aws-website-sveltekit/test/dev.test.ts +++ b/.repos/alchemy-effect/examples/aws-website-sveltekit/test/dev.test.ts @@ -15,24 +15,15 @@ * Vite's HMR without a redeploy */ import { afterAll, expect, test } from "bun:test"; -import { spawn, spawnSync } from "node:child_process"; +import { DevCli, fetchOk } from "alchemy-test/DevCli"; import * as fs from "node:fs"; import * as path from "node:path"; const root = path.resolve(import.meta.dirname, ".."); -// Spawn the CLI entry directly (not through `bun run` / the cli.js -// launcher) so signals hit the actual CLI process, whose scope teardown -// kills the dev server and the provider sidecars. -const alchemyBin = path.join( - root, - "node_modules", - "alchemy", - "bin", - "alchemy.ts", -); // Isolated stage so this suite never fights integ.test.ts (same stack // name) over state rows. const STAGE = "dev-cli-test"; +const cli = new DevCli({ root, stage: STAGE }); // Hot-reload surface: the SSR index page. The test rewrites it in place // with the CLI running, then restores it. @@ -41,101 +32,20 @@ const pageSource = fs.readFileSync(pagePath, "utf8"); const MARKER = "SvelteKit on AWS"; const MARKER_V2 = "SvelteKit on AWS [dev-v2]"; -let proc: ReturnType | undefined; -let output = ""; - -const pump = (stream: NodeJS.ReadableStream) => { - stream.on("data", (chunk: Buffer) => { - const text = chunk.toString(); - output += text; - if (process.env.DEBUG) process.stderr.write(text); - }); -}; - -/** Bounded poll for a (possibly async) producer to yield a value. */ -const pollUntil = async ( - what: string, - f: () => T | undefined | Promise, - { tries = 30, delayMs = 1000 }: { tries?: number; delayMs?: number } = {}, -): Promise => { - for (let i = 0; i < tries; i++) { - const value = await f(); - if (value !== undefined) return value; - await Bun.sleep(delayMs); - } - throw new Error( - `Timed out waiting for ${what}.\n--- alchemy dev output (tail) ---\n${output.slice(-4000)}`, - ); -}; - -/** Fetch with retries — the dev server takes a moment to start serving. */ -const fetchOk = async ( - url: string | URL, - { tries = 30, delayMs = 1000 }: { tries?: number; delayMs?: number } = {}, -) => { - let last: Response | undefined; - for (let i = 0; i < tries; i++) { - try { - last = await fetch(url); - if (last.ok) return last; - } catch { - // dev server not listening yet - } - await Bun.sleep(delayMs); - } - throw new Error( - `GET ${url} never returned 2xx (last status: ${last?.status})`, - ); -}; - -/** Extract the stack-output URL the CLI prints on stdout. */ -const outputUrl = () => output.match(/\burl:\s*['"]?(http[^\s'",]+)/)?.[1]; - afterAll(async () => { // Always leave the repo tree clean, even on a mid-reload failure. fs.writeFileSync(pagePath, pageSource); - if (proc?.pid) { - // Ctrl-C semantics: signal the whole PROCESS GROUP (the CLI, its - // `--watch` exec child, and the provider sidecars). - const killGroup = (signal: NodeJS.Signals) => { - try { - process.kill(-proc!.pid!, signal); - } catch { - // group already gone - } - }; - const exited = new Promise((resolve) => proc!.once("exit", resolve)); - killGroup("SIGINT"); - await Promise.race([exited, Bun.sleep(15_000)]); - if (proc.exitCode === null && proc.signalCode === null) { - killGroup("SIGKILL"); - await Promise.race([exited, Bun.sleep(5_000)]); - } - } - if (!process.env.NO_DESTROY) { - spawnSync("bun", [alchemyBin, "destroy", "--stage", STAGE, "--yes"], { - cwd: root, - stdio: "inherit", - timeout: 120_000, - }); - } + await cli.stop(); + if (!process.env.NO_DESTROY) cli.destroy(); }, 180_000); test( "alchemy dev serves the SvelteKit site locally with hot reload", async () => { - proc = spawn("bun", [alchemyBin, "dev", "--stage", STAGE], { - cwd: root, - // Own process group, so teardown can deliver Ctrl-C to the whole - // tree the way a terminal would. - detached: true, - stdio: ["ignore", "pipe", "pipe"], - }); - pump(proc.stdout!); - pump(proc.stderr!); + cli.start(); - const url = await pollUntil("url in stack outputs", outputUrl, { + const url = await cli.outputUrlWhenReady("url", { tries: 180, delayMs: 1000, }); @@ -163,7 +73,7 @@ test( // ── HOT RELOAD: rewrite the index page with the CLI still running — // the framework dev server serves the new markup without a deploy ── fs.writeFileSync(pagePath, pageSource.replace(MARKER, MARKER_V2)); - await pollUntil( + await cli.pollUntil( "hot-reloaded page (v2 marker)", async () => { try { @@ -181,7 +91,7 @@ test( // Restore — the swap back is itself a second hot reload and leaves // the checked-in tree clean. fs.writeFileSync(pagePath, pageSource); - await pollUntil( + await cli.pollUntil( "restored page (v2 marker gone)", async () => { try { diff --git a/.repos/alchemy-effect/examples/aws-website-sveltekit/test/integ.test.ts b/.repos/alchemy-effect/examples/aws-website-sveltekit/test/integ.test.ts index a4516b4e7cbd13d043cc3c92ec17613315127d6f..fba312e96114605b50fff25a0fc2e6f37f57483e 100644 --- a/.repos/alchemy-effect/examples/aws-website-sveltekit/test/integ.test.ts +++ b/.repos/alchemy-effect/examples/aws-website-sveltekit/test/integ.test.ts @@ -45,7 +45,6 @@ const getBodyWhenReady = (url: string, expected: string) => const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ providers: AWS.providers(), state: AWS.state(), - stage: "test", }); // The first deploy runs the full SvelteKit build AND creates a CloudFront diff --git a/.repos/alchemy-effect/examples/aws-website-tanstack-start/package.json b/.repos/alchemy-effect/examples/aws-website-tanstack-start/package.json index dc62f02022d0782898247c7ebca7b483973397f1..40255c6d8e0fda00767f22c7e63f3480c1be33da 100644 --- a/.repos/alchemy-effect/examples/aws-website-tanstack-start/package.json +++ b/.repos/alchemy-effect/examples/aws-website-tanstack-start/package.json @@ -10,24 +10,24 @@ "test": "bun test" }, "dependencies": { - "@tanstack/react-router": "catalog:frontend", - "@tanstack/react-start": "catalog:frontend", - "react": "catalog:frontend", - "react-dom": "catalog:frontend" + "@tanstack/react-router": "catalog:", + "@tanstack/react-start": "catalog:", + "react": "catalog:", + "react-dom": "catalog:" }, "devDependencies": { "@alchemy.run/frontend-frameworks": "workspace:*", "@distilled.cloud/aws": "workspace:*", - "@effect/platform-node": "catalog:effect", - "@tailwindcss/vite": "catalog:frontend", - "@types/react": "catalog:frontend", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "catalog:frontend", + "@effect/platform-node": "catalog:", + "@tailwindcss/vite": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", "alchemy": "workspace:*", - "effect": "catalog:effect", - "tailwindcss": "catalog:frontend", - "typescript": "catalog:build", - "vite": "catalog:build" + "effect": "catalog:", + "tailwindcss": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" }, "engines": { "node": ">=22" diff --git a/.repos/alchemy-effect/examples/aws-website-tanstack-start/test/integ.test.ts b/.repos/alchemy-effect/examples/aws-website-tanstack-start/test/integ.test.ts index 9b1d598a9b90ce5acaf037d7ab63ce2b200bece7..69d1ad271399520b99833529d175713ddd8bcf61 100644 --- a/.repos/alchemy-effect/examples/aws-website-tanstack-start/test/integ.test.ts +++ b/.repos/alchemy-effect/examples/aws-website-tanstack-start/test/integ.test.ts @@ -45,7 +45,6 @@ const getBodyWhenReady = (url: string, expected: string) => const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ providers: AWS.providers(), state: AWS.state(), - stage: "test", }); // The first deploy runs the full Vite build AND creates a CloudFront diff --git a/.repos/alchemy-effect/examples/aws-website-vite/package.json b/.repos/alchemy-effect/examples/aws-website-vite/package.json index 0dbfb64bf96ac0f6b755f51dd79f3e911d23c751..388deb270083445177a950439e0c4b45bbf7acf9 100644 --- a/.repos/alchemy-effect/examples/aws-website-vite/package.json +++ b/.repos/alchemy-effect/examples/aws-website-vite/package.json @@ -10,22 +10,22 @@ "test": "bun test" }, "dependencies": { - "react": "catalog:frontend", - "react-dom": "catalog:frontend" + "react": "catalog:", + "react-dom": "catalog:" }, "devDependencies": { "@alchemy.run/frontend-frameworks": "workspace:*", "@distilled.cloud/aws": "workspace:*", - "@effect/platform-node": "catalog:effect", - "@tailwindcss/vite": "catalog:frontend", - "@types/react": "catalog:frontend", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "catalog:frontend", + "@effect/platform-node": "catalog:", + "@tailwindcss/vite": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", "alchemy": "workspace:*", - "effect": "catalog:effect", - "tailwindcss": "catalog:frontend", - "typescript": "catalog:build", - "vite": "catalog:build" + "effect": "catalog:", + "tailwindcss": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" }, "engines": { "node": ">=22" diff --git a/.repos/alchemy-effect/examples/aws-website-vite/test/integ.test.ts b/.repos/alchemy-effect/examples/aws-website-vite/test/integ.test.ts index bdf6c0be521ee9e588a51b86bf01afe8b15dfa67..202d70cbcd976b7658989307dda33b3d25733665 100644 --- a/.repos/alchemy-effect/examples/aws-website-vite/test/integ.test.ts +++ b/.repos/alchemy-effect/examples/aws-website-vite/test/integ.test.ts @@ -44,7 +44,6 @@ const getBodyWhenReady = (url: string, expected: string) => const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ providers: AWS.providers(), state: AWS.state(), - stage: "test", }); // The first deploy runs the full Vite build AND creates a CloudFront diff --git a/.repos/alchemy-effect/examples/aws-website-waku/package.json b/.repos/alchemy-effect/examples/aws-website-waku/package.json index a9c41c5000c1497fda52bc0e4171dceca78b537a..b8c35e44712931f1b67d01337ce3026f775bca0a 100644 --- a/.repos/alchemy-effect/examples/aws-website-waku/package.json +++ b/.repos/alchemy-effect/examples/aws-website-waku/package.json @@ -11,24 +11,25 @@ }, "dependencies": { "alchemy": "workspace:*", - "effect": "catalog:effect", - "hono": "catalog:frontend", - "react": "~19.2.6", - "react-dom": "~19.2.6", - "react-server-dom-webpack": "catalog:frontend", - "waku": "catalog:frontend" + "effect": "catalog:", + "hono": "catalog:", + "react": "catalog:react-rsc", + "react-dom": "catalog:react-rsc", + "react-server-dom-webpack": "catalog:", + "waku": "catalog:" }, "devDependencies": { "@alchemy.run/frontend-frameworks": "workspace:*", "@distilled.cloud/aws": "workspace:*", - "@effect/platform-node": "catalog:effect", - "@tailwindcss/vite": "catalog:frontend", - "@types/node": "^24.12.0", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "tailwindcss": "catalog:frontend", - "typescript": "catalog:build", - "vite": "catalog:build" + "@effect/platform-node": "catalog:", + "@tailwindcss/vite": "catalog:", + "@types/node": "catalog:node24", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "alchemy-test": "workspace:*", + "tailwindcss": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" }, "repository": { "type": "git", diff --git a/.repos/alchemy-effect/examples/aws-website-waku/test/dev.test.ts b/.repos/alchemy-effect/examples/aws-website-waku/test/dev.test.ts index 2ee326dc4ed4b17080cdeed1b8aa721fcd8256d0..3349db7ad0dc87878cd42ade1d4e8f068fa5a6ad 100644 --- a/.repos/alchemy-effect/examples/aws-website-waku/test/dev.test.ts +++ b/.repos/alchemy-effect/examples/aws-website-waku/test/dev.test.ts @@ -15,24 +15,15 @@ * server without a redeploy */ import { afterAll, expect, test } from "bun:test"; -import { spawn, spawnSync } from "node:child_process"; +import { DevCli, fetchOk } from "alchemy-test/DevCli"; import * as fs from "node:fs"; import * as path from "node:path"; const root = path.resolve(import.meta.dirname, ".."); -// Spawn the CLI entry directly (not through `bun run` / the cli.js -// launcher) so signals hit the actual CLI process, whose scope teardown -// kills the dev server and the provider sidecars. -const alchemyBin = path.join( - root, - "node_modules", - "alchemy", - "bin", - "alchemy.ts", -); // Isolated stage so this suite never fights integ.test.ts (same stack // name) over state rows. const STAGE = "dev-cli-test"; +const cli = new DevCli({ root, stage: STAGE }); // Hot-reload surface: the SSR index page. The test rewrites it in place // with the CLI running, then restores it. @@ -42,101 +33,20 @@ const MARKER = "This page is rendered by the server on every request."; const MARKER_V2 = "This page is rendered by the server on every request. [dev-v2]"; -let proc: ReturnType | undefined; -let output = ""; - -const pump = (stream: NodeJS.ReadableStream) => { - stream.on("data", (chunk: Buffer) => { - const text = chunk.toString(); - output += text; - if (process.env.DEBUG) process.stderr.write(text); - }); -}; - -/** Bounded poll for a (possibly async) producer to yield a value. */ -const pollUntil = async ( - what: string, - f: () => T | undefined | Promise, - { tries = 30, delayMs = 1000 }: { tries?: number; delayMs?: number } = {}, -): Promise => { - for (let i = 0; i < tries; i++) { - const value = await f(); - if (value !== undefined) return value; - await Bun.sleep(delayMs); - } - throw new Error( - `Timed out waiting for ${what}.\n--- alchemy dev output (tail) ---\n${output.slice(-4000)}`, - ); -}; - -/** Fetch with retries — the dev server takes a moment to start serving. */ -const fetchOk = async ( - url: string | URL, - { tries = 30, delayMs = 1000 }: { tries?: number; delayMs?: number } = {}, -) => { - let last: Response | undefined; - for (let i = 0; i < tries; i++) { - try { - last = await fetch(url); - if (last.ok) return last; - } catch { - // dev server not listening yet - } - await Bun.sleep(delayMs); - } - throw new Error( - `GET ${url} never returned 2xx (last status: ${last?.status})`, - ); -}; - -/** Extract the stack-output URL the CLI prints on stdout. */ -const outputUrl = () => output.match(/\burl:\s*['"]?(http[^\s'",]+)/)?.[1]; - afterAll(async () => { // Always leave the repo tree clean, even on a mid-reload failure. fs.writeFileSync(pagePath, pageSource); - if (proc?.pid) { - // Ctrl-C semantics: signal the whole PROCESS GROUP (the CLI, its - // `--watch` exec child, and the provider sidecars). - const killGroup = (signal: NodeJS.Signals) => { - try { - process.kill(-proc!.pid!, signal); - } catch { - // group already gone - } - }; - const exited = new Promise((resolve) => proc!.once("exit", resolve)); - killGroup("SIGINT"); - await Promise.race([exited, Bun.sleep(15_000)]); - if (proc.exitCode === null && proc.signalCode === null) { - killGroup("SIGKILL"); - await Promise.race([exited, Bun.sleep(5_000)]); - } - } - if (!process.env.NO_DESTROY) { - spawnSync("bun", [alchemyBin, "destroy", "--stage", STAGE, "--yes"], { - cwd: root, - stdio: "inherit", - timeout: 120_000, - }); - } + await cli.stop(); + if (!process.env.NO_DESTROY) cli.destroy(); }, 180_000); test( "alchemy dev serves the Waku site locally with hot reload", async () => { - proc = spawn("bun", [alchemyBin, "dev", "--stage", STAGE], { - cwd: root, - // Own process group, so teardown can deliver Ctrl-C to the whole - // tree the way a terminal would. - detached: true, - stdio: ["ignore", "pipe", "pipe"], - }); - pump(proc.stdout!); - pump(proc.stderr!); + cli.start(); - const url = await pollUntil("url in stack outputs", outputUrl, { + const url = await cli.outputUrlWhenReady("url", { tries: 180, delayMs: 1000, }); @@ -164,7 +74,7 @@ test( // ── HOT RELOAD: rewrite the index page with the CLI still running — // the framework dev server serves the new markup without a deploy ── fs.writeFileSync(pagePath, pageSource.replace(MARKER, MARKER_V2)); - await pollUntil( + await cli.pollUntil( "hot-reloaded page (v2 marker)", async () => { try { @@ -182,7 +92,7 @@ test( // Restore — the swap back is itself a second hot reload and leaves // the checked-in tree clean. fs.writeFileSync(pagePath, pageSource); - await pollUntil( + await cli.pollUntil( "restored page (v2 marker gone)", async () => { try { diff --git a/.repos/alchemy-effect/examples/aws-website-waku/test/integ.test.ts b/.repos/alchemy-effect/examples/aws-website-waku/test/integ.test.ts index 3d4c118ab6593392711904e28d43b0fc69e348fc..731980bd73cb45e836f8edfb591fd805389be78e 100644 --- a/.repos/alchemy-effect/examples/aws-website-waku/test/integ.test.ts +++ b/.repos/alchemy-effect/examples/aws-website-waku/test/integ.test.ts @@ -14,7 +14,6 @@ const { getWhenReady } = Test; const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ providers: AWS.providers(), state: AWS.state(), - stage: "test", }); // The first deploy runs the full Waku build AND creates a CloudFront diff --git a/.repos/alchemy-effect/examples/cloudflare-agent/package.json b/.repos/alchemy-effect/examples/cloudflare-agent/package.json index dcf824a2e0f85c5e06650f7830ede8270daef4db..375f933212dbdd5c74fec55e27c1d202a5255cf7 100644 --- a/.repos/alchemy-effect/examples/cloudflare-agent/package.json +++ b/.repos/alchemy-effect/examples/cloudflare-agent/package.json @@ -14,13 +14,13 @@ "dev": "alchemy dev", "destroy": "alchemy destroy", "logs": "alchemy logs", - "tail": "alchemy tail", + "tail": "alchemy logs --tail", "test": "bun test" }, "dependencies": { - "@effect/platform-bun": "catalog:effect", - "@effect/platform-node": "catalog:effect", + "@effect/platform-bun": "catalog:", + "@effect/platform-node": "catalog:", "alchemy": "workspace:*", - "effect": "catalog:effect" + "effect": "catalog:" } } diff --git a/.repos/alchemy-effect/examples/cloudflare-agent/src/tools/Eval.ts b/.repos/alchemy-effect/examples/cloudflare-agent/src/tools/Eval.ts index 6a0dbcc830ebc46cb3080b110415e500ab0d7fe7..0aac1ab56944d10d96e911c9a4c5fff1c3238af2 100644 --- a/.repos/alchemy-effect/examples/cloudflare-agent/src/tools/Eval.ts +++ b/.repos/alchemy-effect/examples/cloudflare-agent/src/tools/Eval.ts @@ -24,7 +24,7 @@ export const EvalLive = Layer.effect( vm .load({ mainModule: "index.js", - compatibilityDate: "2026-01-28", + compatibilityDate: "2026-08-31", modules: { "code.js": code, "index.js": dedent` diff --git a/.repos/alchemy-effect/examples/cloudflare-d1-drizzle/package.json b/.repos/alchemy-effect/examples/cloudflare-d1-drizzle/package.json index b49eacf67fd94ae44f7e86eb905e093c8228127d..2c944244cfac8475ecef66bea5dc54380528defd 100644 --- a/.repos/alchemy-effect/examples/cloudflare-d1-drizzle/package.json +++ b/.repos/alchemy-effect/examples/cloudflare-d1-drizzle/package.json @@ -16,14 +16,14 @@ "test": "bun test" }, "dependencies": { - "@cloudflare/workers-types": "catalog:cloudflare", - "@effect/platform-bun": "catalog:effect", - "@effect/platform-node": "catalog:effect", + "@cloudflare/workers-types": "catalog:", + "@effect/platform-bun": "catalog:", + "@effect/platform-node": "catalog:", "alchemy": "workspace:*", - "drizzle-orm": "catalog:database", - "effect": "catalog:effect" + "drizzle-orm": "catalog:", + "effect": "catalog:" }, "devDependencies": { - "drizzle-kit": "catalog:database" + "drizzle-kit": "catalog:" } } diff --git a/.repos/alchemy-effect/examples/cloudflare-dev/alchemy.run.ts b/.repos/alchemy-effect/examples/cloudflare-dev/alchemy.run.ts index de1bc81be1b590f3ebbc05c9588f89e406c8d3d7..7a2a55fd13134df974e282f1d45d7f01db05f4a4 100644 --- a/.repos/alchemy-effect/examples/cloudflare-dev/alchemy.run.ts +++ b/.repos/alchemy-effect/examples/cloudflare-dev/alchemy.run.ts @@ -76,7 +76,7 @@ const AsyncWorker = (deps: { className: "QueueMessages", }), MY_VARIABLE: "my-variable-abc123", - MY_SECRET: Config.redacted("MY_SECRET").pipe( + MY_SECRET: Config.Redacted("MY_SECRET").pipe( Config.withDefault(Redacted.make("my-secret-abc123")), ), // The worker's own URL, injected as a plain-text binding (`self_url`). @@ -122,7 +122,6 @@ const MediaWorker = Effect.gen(function* () { }); const worker = yield* Cloudflare.Worker("MediaWorker", { main: "./src/MediaWorker.ts", - compatibility: { flags: ["nodejs_compat"] }, env: { BROWSER: Cloudflare.Browser("BROWSER"), IMAGES: Cloudflare.Images.Images("IMAGES"), diff --git a/.repos/alchemy-effect/examples/cloudflare-dev/package.json b/.repos/alchemy-effect/examples/cloudflare-dev/package.json index bbc906f8a723205f099f9fa281bcc6af0cf8ed3b..cfeca6806417aa343765d2852405a8d2f11679eb 100644 --- a/.repos/alchemy-effect/examples/cloudflare-dev/package.json +++ b/.repos/alchemy-effect/examples/cloudflare-dev/package.json @@ -14,15 +14,18 @@ "dev": "alchemy dev", "destroy": "alchemy destroy", "logs": "alchemy logs", - "tail": "alchemy tail", + "tail": "alchemy logs --tail", "test": "bun test" }, "dependencies": { - "@cloudflare/puppeteer": "catalog:cloudflare", - "@cloudflare/workers-types": "catalog:cloudflare", - "@effect/platform-bun": "catalog:effect", - "@effect/platform-node": "catalog:effect", + "@cloudflare/puppeteer": "catalog:", + "@cloudflare/workers-types": "catalog:", + "@effect/platform-bun": "catalog:", + "@effect/platform-node": "catalog:", "alchemy": "workspace:*", - "effect": "catalog:effect" + "effect": "catalog:" + }, + "devDependencies": { + "alchemy-test": "workspace:*" } } diff --git a/.repos/alchemy-effect/examples/cloudflare-dev/src/EffectWorker.ts b/.repos/alchemy-effect/examples/cloudflare-dev/src/EffectWorker.ts index dffb6cca1c6f9b761cef72a959c030a3ac920638..934966f2750a81664d10f5e91ff4001c8c0850ea 100644 --- a/.repos/alchemy-effect/examples/cloudflare-dev/src/EffectWorker.ts +++ b/.repos/alchemy-effect/examples/cloudflare-dev/src/EffectWorker.ts @@ -42,7 +42,7 @@ export default class EffectWorker extends Cloudflare.Worker()( { main: import.meta.url, dev: { - port: Config.number("PORT").pipe(Config.withDefault(1338)), + port: Config.Number("PORT").pipe(Config.withDefault(1338)), }, build: { bundleAnalyzer: true, diff --git a/.repos/alchemy-effect/examples/cloudflare-dev/src/NotifyWorkflow.ts b/.repos/alchemy-effect/examples/cloudflare-dev/src/NotifyWorkflow.ts index 197cd1db2896eb8a10cbe38bef5170d2b922d9d2..ca9ebc3cce79b604721df856ba2ad0d2ea8ebead 100644 --- a/.repos/alchemy-effect/examples/cloudflare-dev/src/NotifyWorkflow.ts +++ b/.repos/alchemy-effect/examples/cloudflare-dev/src/NotifyWorkflow.ts @@ -14,9 +14,9 @@ export default class NotifyWorkflow extends Cloudflare.Workflow( "Notifier", Effect.gen(function* () { // Bind a `secret_text` on the workflow at plantime. Using a literal - // (instead of `Config.redacted("WORKFLOW_SECRET")`) keeps the integ + // (instead of `Config.Redacted("WORKFLOW_SECRET")`) keeps the integ // test self-contained — no `.env` setup required. - const secret = yield* Config.redacted("WORKFLOW_SECRET").pipe( + const secret = yield* Config.Redacted("WORKFLOW_SECRET").pipe( Config.withDefault(Redacted.make(WORKFLOW_SECRET_VALUE)), ); // Regression guard for https://github.com/alchemy-run/alchemy/pull/71 diff --git a/.repos/alchemy-effect/examples/cloudflare-dev/test/dev.test.ts b/.repos/alchemy-effect/examples/cloudflare-dev/test/dev.test.ts index f925b9e8d699a3754e86c2f72384f4e8052f0a5f..8063b389667ecb14b0725d19a6a3a615c48e1004 100644 --- a/.repos/alchemy-effect/examples/cloudflare-dev/test/dev.test.ts +++ b/.repos/alchemy-effect/examples/cloudflare-dev/test/dev.test.ts @@ -38,136 +38,49 @@ * so this suite pins only the cheap ones on the CLI path. */ import { afterAll, expect, test } from "bun:test"; -import { spawn, spawnSync } from "node:child_process"; +import { DevCli, fetchOk } from "alchemy-test/DevCli"; import * as path from "node:path"; import { WORKFLOW_SECRET_VALUE } from "../src/NotifyWorkflow.ts"; const root = path.resolve(import.meta.dirname, ".."); -// Spawn the CLI entry directly (not through `bun run` / the cli.js -// launcher) so `proc.kill()` signals the actual CLI process, whose scope -// teardown kills the exec child and the provider sidecars. -const alchemyBin = path.join( - root, - "node_modules", - "alchemy", - "bin", - "alchemy.ts", -); -// Isolated stage so this suite never fights `integ.test.ts` (same stack -// name) over state rows. const STAGE = "dev-cli-test"; - -let proc: ReturnType | undefined; -let output = ""; - -const pump = (stream: NodeJS.ReadableStream) => { - stream.on("data", (chunk: Buffer) => { - const text = chunk.toString(); - output += text; - if (process.env.DEBUG) process.stderr.write(text); - }); -}; - -/** Bounded poll for a (possibly async) producer to yield a value. */ -const pollUntil = async ( - what: string, - f: () => T | undefined | Promise, - { tries = 30, delayMs = 1000 }: { tries?: number; delayMs?: number } = {}, -): Promise => { - for (let i = 0; i < tries; i++) { - const value = await f(); - if (value !== undefined) return value; - await Bun.sleep(delayMs); - } - throw new Error( - `Timed out waiting for ${what}.\n--- alchemy dev output (tail) ---\n${output.slice(-4000)}`, - ); -}; - -/** Fetch with retries — a fresh workerd takes a moment to start serving. */ -const fetchOk = async ( - url: string | URL, - init?: RequestInit, - { tries = 20, delayMs = 500 }: { tries?: number; delayMs?: number } = {}, -) => { - let last: Response | undefined; - for (let i = 0; i < tries; i++) { - try { - last = await fetch(url, init); - if (last.ok) return last; - } catch { - // dev proxy not listening yet - } - await Bun.sleep(delayMs); - } - throw new Error( - `${init?.method ?? "GET"} ${url} never returned 2xx (last status: ${last?.status})`, - ); -}; - -/** Extract a worker URL from the stack outputs the CLI prints on stdout. */ -const outputUrl = (key: string) => - output.match(new RegExp(`${key}:\\s*['"]?(http[^\\s'",]+)`))?.[1]; +const cli = new DevCli({ root, stage: STAGE }); afterAll(async () => { - if (proc?.pid) { - // Ctrl-C semantics: signal the whole PROCESS GROUP (the CLI, its - // `--watch` exec child, and the provider sidecars). Signaling only the - // CLI process orphans the exec child, which then keeps the stack's - // state locked and blocks the destroy below. - const killGroup = (signal: NodeJS.Signals) => { - try { - process.kill(-proc!.pid!, signal); - } catch { - // group already gone - } - }; - const exited = new Promise((resolve) => proc!.once("exit", resolve)); - killGroup("SIGINT"); - await Promise.race([exited, Bun.sleep(15_000)]); - if (proc.exitCode === null && proc.signalCode === null) { - killGroup("SIGKILL"); - await Promise.race([exited, Bun.sleep(5_000)]); - } - } + await cli.stop(); if (!process.env.NO_DESTROY) { - spawnSync("bun", [alchemyBin, "destroy", "--stage", STAGE, "--yes"], { - cwd: root, - stdio: "inherit", - timeout: 120_000, - }); + cli.destroy(); } }, 180_000); test( "alchemy dev serves every local binding end-to-end", async () => { - proc = spawn("bun", [alchemyBin, "dev", "--stage", STAGE], { - cwd: root, - // Own process group, so teardown can deliver Ctrl-C to the whole tree - // the way a terminal would. - detached: true, - stdio: ["ignore", "pipe", "pipe"], - }); - pump(proc.stdout!); - pump(proc.stderr!); + // Start from an empty stage. The local D1 provider trusts its state row + // for "migrations applied", so a stage left deployed by an interrupted + // run (remote state store) combined with a wiped `.alchemy/local` (e.g. + // `git clean -fdx`) would skip the migrations and fail `/d1` with + // `no such table: greetings` — a stale-environment failure, not the + // #1007 regression this route exists to pin. + cli.destroy(); + cli.start(); // The first dev deploy applies D1 migrations through the sidecar, // prepares the sandbox image, boots workerd, and then prints the stack // outputs. - const asyncWorker = await pollUntil( + const asyncWorker = await cli.pollUntil( "asyncWorker url in stack outputs", - () => outputUrl("asyncWorker"), + () => cli.outputUrl("asyncWorker"), { tries: 300, delayMs: 1000 }, ); - const effectWorker = await pollUntil( + const effectWorker = await cli.pollUntil( "effectWorker url in stack outputs", - () => outputUrl("effectWorker"), + () => cli.outputUrl("effectWorker"), { tries: 30, delayMs: 1000 }, ); - const mediaWorker = await pollUntil( + const mediaWorker = await cli.pollUntil( "mediaWorker url in stack outputs", - () => outputUrl("mediaWorker"), + () => cli.outputUrl("mediaWorker"), { tries: 30, delayMs: 1000 }, ); @@ -225,7 +138,7 @@ test( headers: { "content-type": "application/json" }, body: JSON.stringify(message), }); - const received = await pollUntil( + const received = await cli.pollUntil( "queue message to be consumed", async () => { const res = await fetch(new URL("/queue/messages", asyncWorker)); @@ -305,7 +218,7 @@ test( method: "POST", }) ).json()) as { instanceId: string }; - const status = await pollUntil( + const status = await cli.pollUntil( "workflow to settle", async () => { const res = await fetch( diff --git a/.repos/alchemy-effect/examples/cloudflare-effect-sql-d1/package.json b/.repos/alchemy-effect/examples/cloudflare-effect-sql-d1/package.json index 8d90ee82d43c0fced20ac846f062caede3bd6031..e5ba227c42086ea8c22a015b05e65d8fa4022685 100644 --- a/.repos/alchemy-effect/examples/cloudflare-effect-sql-d1/package.json +++ b/.repos/alchemy-effect/examples/cloudflare-effect-sql-d1/package.json @@ -16,10 +16,10 @@ "test": "bun test" }, "dependencies": { - "@cloudflare/workers-types": "catalog:cloudflare", - "@effect/platform-bun": "catalog:effect", - "@effect/platform-node": "catalog:effect", + "@cloudflare/workers-types": "catalog:", + "@effect/platform-bun": "catalog:", + "@effect/platform-node": "catalog:", "alchemy": "workspace:*", - "effect": "catalog:effect" + "effect": "catalog:" } } diff --git a/.repos/alchemy-effect/examples/cloudflare-email/package.json b/.repos/alchemy-effect/examples/cloudflare-email/package.json index bb58b866a583f4bd016383998ff2febc30fe0dbb..d91367ce98599847d88a11b4a47d6c87a943a6df 100644 --- a/.repos/alchemy-effect/examples/cloudflare-email/package.json +++ b/.repos/alchemy-effect/examples/cloudflare-email/package.json @@ -15,10 +15,10 @@ "test": "bun test" }, "dependencies": { - "@cloudflare/workers-types": "catalog:cloudflare", - "@effect/platform-bun": "catalog:effect", - "@effect/platform-node": "catalog:effect", + "@cloudflare/workers-types": "catalog:", + "@effect/platform-bun": "catalog:", + "@effect/platform-node": "catalog:", "alchemy": "workspace:*", - "effect": "catalog:effect" + "effect": "catalog:" } } diff --git a/.repos/alchemy-effect/examples/cloudflare-foldkit-ssr/package.json b/.repos/alchemy-effect/examples/cloudflare-foldkit-ssr/package.json index d1ee300a1576c1c36c4d4b138b708f1b77b7db31..01f448b28ab0295140d009f064c19e99ee831ecf 100644 --- a/.repos/alchemy-effect/examples/cloudflare-foldkit-ssr/package.json +++ b/.repos/alchemy-effect/examples/cloudflare-foldkit-ssr/package.json @@ -11,15 +11,15 @@ "test": "bun test test/integ.test.ts" }, "dependencies": { - "effect": "catalog:effect", - "foldkit": "catalog:frontend" + "effect": "catalog:", + "foldkit": "catalog:" }, "devDependencies": { - "@foldkit/vite-plugin": "catalog:frontend", - "@types/node": "^24.12.0", + "@foldkit/vite-plugin": "catalog:", + "@types/node": "catalog:node24", "alchemy": "workspace:*", - "typescript": "catalog:build", - "vite": "catalog:build" + "typescript": "catalog:", + "vite": "catalog:" }, "repository": { "type": "git", diff --git a/.repos/alchemy-effect/examples/cloudflare-foldkit-ssr/src/main.ts b/.repos/alchemy-effect/examples/cloudflare-foldkit-ssr/src/main.ts index 372b27f9eeac5ae4c7933be81d4574472a80242d..98fb5a6077f1829076e238279ce11d552877030a 100644 --- a/.repos/alchemy-effect/examples/cloudflare-foldkit-ssr/src/main.ts +++ b/.repos/alchemy-effect/examples/cloudflare-foldkit-ssr/src/main.ts @@ -1,7 +1,7 @@ import { Match as M, Schema as S } from "effect"; -import { Command } from "foldkit"; +import type { Update } from "foldkit"; import type { Document, HtmlBuilder } from "foldkit/html"; -import { m } from "foldkit/message"; +import { defineMessageUnion } from "foldkit/message"; // MODEL @@ -20,15 +20,12 @@ export type Flags = typeof Flags.Type; // MESSAGE -export const ClickedDecrement = m("ClickedDecrement"); -export const ClickedIncrement = m("ClickedIncrement"); -export const ClickedReset = m("ClickedReset"); - -export const Message = S.Union([ - ClickedDecrement, - ClickedIncrement, - ClickedReset, -]); +export const Message = defineMessageUnion({ + ClickedDecrement: {}, + ClickedIncrement: {}, + ClickedReset: {}, +}); +export const { ClickedDecrement, ClickedIncrement, ClickedReset } = Message; export type Message = typeof Message.Type; // UPDATE @@ -36,15 +33,13 @@ export type Message = typeof Message.Type; export const update = ( model: Model, message: Message, -): readonly [Model, ReadonlyArray>] => +): Update.Return => M.value(message).pipe( - M.withReturnType< - readonly [Model, ReadonlyArray>] - >(), + M.withReturnType>(), M.tagsExhaustive({ - ClickedDecrement: () => [{ count: model.count - 1 }, []], - ClickedIncrement: () => [{ count: model.count + 1 }, []], - ClickedReset: () => [{ count: 0 }, []], + ClickedDecrement: () => ({ model: { count: model.count - 1 } }), + ClickedIncrement: () => ({ model: { count: model.count + 1 } }), + ClickedReset: () => ({ model: { count: 0 } }), }), ); @@ -54,12 +49,9 @@ export const update = ( // with the same Flags. Both sides must reach the same Model or hydration // rebuilds the tree it was supposed to adopt. -export const init = ( - flags: Flags, -): readonly [Model, ReadonlyArray>] => [ - { count: flags.initialCount }, - [], -]; +export const init = (flags: Flags): Update.Return => ({ + model: { count: flags.initialCount }, +}); // VIEW diff --git a/.repos/alchemy-effect/examples/cloudflare-foldkit-ssr/test/integ.test.ts b/.repos/alchemy-effect/examples/cloudflare-foldkit-ssr/test/integ.test.ts index e4159afff3ad780b4e474ad6217d13b8eeb0a2da..3e4c263e3b79d8625271955fc6dd8f0a1e11923e 100644 --- a/.repos/alchemy-effect/examples/cloudflare-foldkit-ssr/test/integ.test.ts +++ b/.repos/alchemy-effect/examples/cloudflare-foldkit-ssr/test/integ.test.ts @@ -41,7 +41,6 @@ const getBodyWhenReady = (url: string, expected: string) => const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ providers: Cloudflare.providers(), state: Cloudflare.state(), - stage: "test", }); // The first deploy runs the full Vite build, so give the hook more headroom diff --git a/.repos/alchemy-effect/examples/cloudflare-foldkit-ssr/vite.config.ts b/.repos/alchemy-effect/examples/cloudflare-foldkit-ssr/vite.config.ts index 54dcc12734fccb4ef3c9b1c69f2bf08170fd55f1..b88c16b85c29bfca98c7ac7b95ffe2a243bafde3 100644 --- a/.repos/alchemy-effect/examples/cloudflare-foldkit-ssr/vite.config.ts +++ b/.repos/alchemy-effect/examples/cloudflare-foldkit-ssr/vite.config.ts @@ -11,6 +11,8 @@ const buildId = process.env.FOLDKIT_BUILD_ID ?? `local-${Date.now().toString(36)}`; export default defineConfig({ + // Client and server must receive the same buildId from this config evaluation. + builder: { sharedConfigBuild: true }, // NOTE: the plugin's `ssr: { serverEntry }` option is deliberately NOT set. // It serves rendered pages from the Vite dev server by loading the entry // through `ssrLoadModule`, which requires a runnable `ssr` environment — diff --git a/.repos/alchemy-effect/examples/cloudflare-foldkit/package.json b/.repos/alchemy-effect/examples/cloudflare-foldkit/package.json index 1f9b7b6f15d6d4c9b737c36d2cfb2a71eb812f1f..2ca1d4b140c769acb440fe04d6c605b9e98fde49 100644 --- a/.repos/alchemy-effect/examples/cloudflare-foldkit/package.json +++ b/.repos/alchemy-effect/examples/cloudflare-foldkit/package.json @@ -12,17 +12,17 @@ "test": "bun test test/integ.test.ts" }, "dependencies": { - "effect": "catalog:effect", - "foldkit": "catalog:frontend" + "effect": "catalog:", + "foldkit": "catalog:" }, "devDependencies": { - "@foldkit/vite-plugin": "catalog:frontend", - "@tailwindcss/vite": "catalog:frontend", - "@types/node": "^24.12.0", + "@foldkit/vite-plugin": "catalog:", + "@tailwindcss/vite": "catalog:", + "@types/node": "catalog:node24", "alchemy": "workspace:*", - "tailwindcss": "catalog:frontend", - "typescript": "catalog:build", - "vite": "catalog:build" + "tailwindcss": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" }, "repository": { "type": "git", diff --git a/.repos/alchemy-effect/examples/cloudflare-foldkit/src/main.ts b/.repos/alchemy-effect/examples/cloudflare-foldkit/src/main.ts index 5ea204d4c98854eb9e514d7af5ce4ec9adaf496e..f260bd70bf66dd03b6c25e45dcc7c1418d4f0c2c 100644 --- a/.repos/alchemy-effect/examples/cloudflare-foldkit/src/main.ts +++ b/.repos/alchemy-effect/examples/cloudflare-foldkit/src/main.ts @@ -1,7 +1,7 @@ import { Match as M, Schema as S } from "effect"; -import { Command, Runtime } from "foldkit"; +import type { Runtime, Update } from "foldkit"; import type { Document, HtmlBuilder } from "foldkit/html"; -import { m } from "foldkit/message"; +import { defineMessageUnion } from "foldkit/message"; // MODEL @@ -10,15 +10,12 @@ export type Model = typeof Model.Type; // MESSAGE -export const ClickedDecrement = m("ClickedDecrement"); -export const ClickedIncrement = m("ClickedIncrement"); -export const ClickedReset = m("ClickedReset"); - -export const Message = S.Union([ - ClickedDecrement, - ClickedIncrement, - ClickedReset, -]); +export const Message = defineMessageUnion({ + ClickedDecrement: {}, + ClickedIncrement: {}, + ClickedReset: {}, +}); +export const { ClickedDecrement, ClickedIncrement, ClickedReset } = Message; export type Message = typeof Message.Type; // UPDATE @@ -26,24 +23,21 @@ export type Message = typeof Message.Type; export const update = ( model: Model, message: Message, -): readonly [Model, ReadonlyArray>] => +): Update.Return => M.value(message).pipe( - M.withReturnType< - readonly [Model, ReadonlyArray>] - >(), + M.withReturnType>(), M.tagsExhaustive({ - ClickedDecrement: () => [{ count: model.count - 1 }, []], - ClickedIncrement: () => [{ count: model.count + 1 }, []], - ClickedReset: () => [{ count: 0 }, []], + ClickedDecrement: () => ({ model: { count: model.count - 1 } }), + ClickedIncrement: () => ({ model: { count: model.count + 1 } }), + ClickedReset: () => ({ model: { count: 0 } }), }), ); // INIT -export const init: Runtime.ApplicationInit = () => [ - { count: 0 }, - [], -]; +export const init: Runtime.ApplicationInit = () => ({ + model: { count: 0 }, +}); // VIEW diff --git a/.repos/alchemy-effect/examples/cloudflare-foldkit/test/integ.test.ts b/.repos/alchemy-effect/examples/cloudflare-foldkit/test/integ.test.ts index 8027d7cd2f79a9c1a5ec65faeccd80059957ecf6..0cdb339dd282ecf29af2f57ae980e33da604a5ff 100644 --- a/.repos/alchemy-effect/examples/cloudflare-foldkit/test/integ.test.ts +++ b/.repos/alchemy-effect/examples/cloudflare-foldkit/test/integ.test.ts @@ -44,7 +44,6 @@ const getBodyWhenReady = (url: string, expected: string) => const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ providers: Cloudflare.providers(), state: Cloudflare.state(), - stage: "test", }); // The first deploy runs the full Vite build, so give the hook more headroom diff --git a/.repos/alchemy-effect/examples/cloudflare-git-artifacts/package.json b/.repos/alchemy-effect/examples/cloudflare-git-artifacts/package.json index a0a0d79c7382c42475b36c238ad22f8f0150d7fb..a1d547151ab65a99a39d179e890b46fa82883afd 100644 --- a/.repos/alchemy-effect/examples/cloudflare-git-artifacts/package.json +++ b/.repos/alchemy-effect/examples/cloudflare-git-artifacts/package.json @@ -14,13 +14,13 @@ "dev": "alchemy dev", "destroy": "alchemy destroy", "logs": "alchemy logs", - "tail": "alchemy tail", + "tail": "alchemy logs --tail", "test": "bun test" }, "dependencies": { - "@effect/platform-bun": "catalog:effect", - "@effect/platform-node": "catalog:effect", + "@effect/platform-bun": "catalog:", + "@effect/platform-node": "catalog:", "alchemy": "workspace:*", - "effect": "catalog:effect" + "effect": "catalog:" } } diff --git a/.repos/alchemy-effect/examples/cloudflare-git-artifacts/src/Worker.ts b/.repos/alchemy-effect/examples/cloudflare-git-artifacts/src/Worker.ts index f7ba0be815636e86c934a9a8e37fe9801fb84dd9..3dd6507fd77368bfdbf88430825e27ee620bf375 100644 --- a/.repos/alchemy-effect/examples/cloudflare-git-artifacts/src/Worker.ts +++ b/.repos/alchemy-effect/examples/cloudflare-git-artifacts/src/Worker.ts @@ -32,8 +32,7 @@ export default class Worker extends Cloudflare.Worker()( main: import.meta.url, observability: { enabled: true }, compatibility: { - flags: ["nodejs_compat"], - date: "2026-03-17", + date: "2026-08-31", }, }, Effect.gen(function* () { diff --git a/.repos/alchemy-effect/examples/cloudflare-git-service/alchemy.run.ts b/.repos/alchemy-effect/examples/cloudflare-git-service/alchemy.run.ts new file mode 100644 index 0000000000000000000000000000000000000000..a7e5ff34cfa160e4851e640f613d3492d3acf108 --- /dev/null +++ b/.repos/alchemy-effect/examples/cloudflare-git-service/alchemy.run.ts @@ -0,0 +1,51 @@ +/** + * The git-service example: a git host embedded in the app's own API, plus + * a GitHub-style web UI, on one origin. + * + * - `src/api/` — the backend, one file per piece: `auth.ts` (Better Auth + * and who a user is), `middleware.ts` (who may call what), `routes.ts` + * (the app's own routes), `api.ts` (the git routes plus ours behind the + * middleware), `git.ts` (the `alchemy/Git` block assembly), and + * `host.ts` (the `Cloudflare.Worker` that serves it). + * - `src/ui/` — the Vite SPA, a plain-fetch client of the API. + * - `src/worker.ts` — the website's Worker: forwards `/api/**` and the git + * wire paths to the GitHost over a service binding and serves the SPA for + * everything else, so clone URLs and the session cookie are same-host. + * + * ```sh + * bun run deploy + * ``` + * + * Open the printed `webUrl`, sign up, mint an API key on the settings page, + * and it is the password of your git remote: + * + * ```sh + * git remote add origin "https://x:@//web.git" + * git push origin main + * ``` + */ +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import GitHost from "./src/api/host.ts"; + +export default Alchemy.Stack( + "GitServiceExample", + { providers: Cloudflare.providers(), state: Alchemy.localState() }, + Effect.gen(function* () { + const git = yield* GitHost; + + const web = yield* Cloudflare.Website.Vite("Web", { + main: "src/worker.ts", + assets: { + notFoundHandling: "single-page-application", + runWorkerFirst: true, + }, + env: { + GIT: GitHost, + }, + }); + + return { url: git.url.as(), webUrl: web.url }; + }), +); diff --git a/.repos/alchemy-effect/examples/cloudflare-git-service/index.html b/.repos/alchemy-effect/examples/cloudflare-git-service/index.html new file mode 100644 index 0000000000000000000000000000000000000000..e74f81ac28c187835a182d3b83b728685849ea85 --- /dev/null +++ b/.repos/alchemy-effect/examples/cloudflare-git-service/index.html @@ -0,0 +1,28 @@ + + + + + + git service + + + +
+ + + diff --git a/.repos/alchemy-effect/examples/cloudflare-git-service/package.json b/.repos/alchemy-effect/examples/cloudflare-git-service/package.json new file mode 100644 index 0000000000000000000000000000000000000000..eda73a89ee1603e1dce0d068c86ed500f14b6e0a --- /dev/null +++ b/.repos/alchemy-effect/examples/cloudflare-git-service/package.json @@ -0,0 +1,42 @@ +{ + "name": "cloudflare-git-service", + "version": "0.0.0", + "private": true, + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/alchemy-run/alchemy.git", + "directory": "examples/cloudflare-git-service" + }, + "type": "module", + "scripts": { + "deploy": "alchemy deploy", + "dev": "alchemy dev", + "destroy": "alchemy destroy", + "logs": "alchemy logs", + "tail": "alchemy tail", + "test": "bun test" + }, + "dependencies": { + "@alchemy.run/better-auth": "workspace:*", + "@better-auth/api-key": "1.6.25", + "@pierre/diffs": "^1.3.5", + "alchemy": "workspace:*", + "better-auth": "catalog:", + "effect": "catalog:", + "highlight.js": "^11.11.1", + "marked": "^16.4.1", + "marked-highlight": "^2.2.2", + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@tailwindcss/vite": "catalog:", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "tailwindcss": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" + } +} diff --git a/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/api.ts b/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/api.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd7346754b5f3da7047207edbc8e211ecaee726c --- /dev/null +++ b/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/api.ts @@ -0,0 +1,23 @@ +import * as Effect from "effect/Effect"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; +import { Session, Unauthorized } from "./auth.ts"; +/** The application owns the API, including Git's groups and its middleware. */ +import * as Git from "alchemy/Git"; +import * as HttpApi from "effect/unstable/httpapi/HttpApi"; +import { Authentication } from "./middleware.ts"; +import { AppRoutes } from "./routes.ts"; + +export class AppApi extends HttpApi.make("app") + .addHttpApi(Git.Api) + .add(AppRoutes) + .middleware(Authentication) {} + +export const MeLive = HttpApiBuilder.group(AppApi, "app", (h) => + h.handle("me", () => + Effect.gen(function* () { + const { user } = yield* Session; + if (user === null) return yield* new Unauthorized(); + return user; + }), + ), +); diff --git a/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/auth.ts b/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..e17d80400812c4b35c38baaae0ca4a48241838fe --- /dev/null +++ b/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/auth.ts @@ -0,0 +1,41 @@ +/** + * Who a user is. Better Auth holds the users, their sessions (browsers), + * and their API keys (`git` clients, `gh`); the engine holds none of it. + */ +import { BetterAuth } from "@alchemy.run/better-auth"; +import { apiKey } from "@better-auth/api-key"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Context from "effect/Context"; +import * as Schema from "effect/Schema"; + +/** Better Auth's users, sessions, and API keys. */ +export const AuthDb = Cloudflare.D1.Database("AuthDb"); + +/** Better Auth, yielded wherever it is needed; the engine de-dupes the declaration. */ +export const Auth = BetterAuth({ + basePath: "/api/auth", + emailAndPassword: { enabled: true }, + plugins: [apiKey()], +}); + +/** The user record routes see. */ +export const User = Schema.Struct({ + id: Schema.String, + name: Schema.optional(Schema.String), +}); + +/** 401 — no usable credential, on a route that needs one. */ +export class Unauthorized extends Schema.TaggedError()( + "Unauthorized", + {}, + { httpApiStatus: 401 }, +) {} + +/** + * Who is calling: the signed-in user, or `null` on an anonymous read of + * a public repository. Every route runs with it in context. + */ +export class Session extends Context.Service< + Session, + { readonly user: typeof User.Type | null } +>()("app/Session") {} diff --git a/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/branch-policy.ts b/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/branch-policy.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd03caee8018850dccee19483021a7da0a59c3c4 --- /dev/null +++ b/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/branch-policy.ts @@ -0,0 +1,30 @@ +/** Application policy, shared by push, REST ref writes, and pull merges. */ +import * as Git from "alchemy/Git"; +import * as Effect from "effect/Effect"; +import { Session, Unauthorized } from "./auth.ts"; + +export const checkRefChanges = ( + repo: Git.RepoMetaData, + updates: ReadonlyArray, +) => + Effect.gen(function* () { + const { user } = yield* Session; + if (user === null) return yield* new Unauthorized(); + for (const update of updates) { + if (user.id.toLowerCase() !== repo.owner.toLowerCase()) { + return yield* new Git.PushDenied({ + ref: update.ref, + reason: "only the repository owner may change refs", + }); + } + if ( + update.ref === `refs/heads/${repo.defaultBranch}` && + /^0+$/.test(update.newOid) + ) { + return yield* new Git.PushDenied({ + ref: update.ref, + reason: "the default branch cannot be deleted", + }); + } + } + }); diff --git a/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/git.ts b/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/git.ts new file mode 100644 index 0000000000000000000000000000000000000000..553ae48b6fea588f34060457a3c83bc8697b6a64 --- /dev/null +++ b/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/git.ts @@ -0,0 +1,48 @@ +/** The application's HTTP routes: our API and Git, with shared authentication. */ +import * as Http from "alchemy/Http"; + +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Git from "alchemy/Git"; +import * as Layer from "effect/Layer"; +import { AppApi, MeLive } from "./api.ts"; +import * as Effect from "effect/Effect"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; +import { ProtocolLive } from "./protocol.ts"; +import { RefsLive, PullsLive } from "./ref-writes.ts"; +import { GitHubLive } from "./github.ts"; +import { Authentication } from "./middleware.ts"; + +/** Packs, clone bundles, and spilled pushes. */ +export const GitObjects = Cloudflare.R2.Bucket("GitObjects", { + // `bun test` sets NODE_ENV=test: the integration test tears the stack + // down with repositories still in the bucket. + forceDestroy: process.env.NODE_ENV === "test", +}); + +const PublicRoutes = HttpApiBuilder.layer(AppApi).pipe( + Layer.provide( + Layer.mergeAll( + MeLive, + ProtocolLive, + RefsLive, + PullsLive, + GitHubLive, + HttpApiBuilder.group(AppApi, "repos", (h) => + Effect.map(Git.Handlers, (defaults) => h.handleAll(defaults.repos)), + ), + HttpApiBuilder.group(AppApi, "objects", (h) => + Effect.map(Git.Handlers, (defaults) => h.handleAll(defaults.objects)), + ), + ), + ), + Layer.provide(Authentication.layer), +); + +export const HttpLive = Layer.mergeAll(PublicRoutes, Git.InternalApiLive).pipe( + Layer.provide(Git.ApiHandlersLive), + Layer.provide(Git.ReposDurableObject), + Layer.provide(Git.RegistryDurableObject), // owner/name → repo + Layer.provide(Git.HasherInline), // push verification in this Worker + Layer.provide(Git.BlobStoreR2(GitObjects)), // packs, bundles, large pushes + Layer.provide(Http.Platform), +); diff --git a/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/github.ts b/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/github.ts new file mode 100644 index 0000000000000000000000000000000000000000..fd6072c40c464a64b6aa69b3795b6c784eac0145 --- /dev/null +++ b/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/github.ts @@ -0,0 +1,71 @@ +/** The compatibility endpoint delegates to the same application write operation. */ +import * as Git from "alchemy/Git"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as HttpRouter from "effect/unstable/http/HttpRouter"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { AppApi } from "./api.ts"; +import { mergePull } from "./ref-writes.ts"; + +const MergeBody = Schema.Struct({ + commit_message: Schema.optional(Schema.String), + sha: Schema.optional(Schema.String), + merge_method: Schema.optional(Schema.String), +}); + +export const GitHubLive = HttpApiBuilder.group(AppApi, "github", (h) => + Effect.gen(function* () { + const defaults = yield* Git.Handlers; + const git = yield* Git.Engine; + const { mergePull: _, ...rest } = defaults.github; + return h.handleAll(rest).handleRaw("mergePull", ({ request }) => + Effect.gen(function* () { + const params = yield* HttpRouter.params; + const number = Number(params.number); + if (!Number.isSafeInteger(number) || number <= 0) + return HttpServerResponse.empty({ status: 404 }); + const body = yield* request.json.pipe( + Effect.flatMap(Schema.decodeUnknownEffect(MergeBody)), + ); + if (body.merge_method !== undefined && body.merge_method !== "merge") + return HttpServerResponse.jsonUnsafe( + { message: "Merge method is not supported" }, + { status: 405 }, + ); + const result = yield* mergePull( + git, + { owner: params.owner ?? "", repo: params.repo ?? "", number }, + { message: body.commit_message, expectedHeadOid: body.sha }, + ); + return HttpServerResponse.jsonUnsafe({ + sha: result.oid, + merged: true, + message: "Pull Request successfully merged", + }); + }).pipe( + Effect.catch((error) => + Effect.succeed( + HttpServerResponse.jsonUnsafe( + { message: error._tag }, + { + status: + error._tag === "PushDenied" + ? 403 + : error._tag === "Unauthorized" + ? 401 + : error._tag === "RepoNotFound" || + error._tag === "PullNotFound" + ? 404 + : error._tag === "SchemaError" || + error._tag === "HttpServerError" + ? 400 + : 409, + }, + ), + ), + ), + ), + ); + }), +); diff --git a/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/host.ts b/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/host.ts new file mode 100644 index 0000000000000000000000000000000000000000..9eada76e5c6a012cff0016bd80ded9cfbaacf66e --- /dev/null +++ b/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/host.ts @@ -0,0 +1,36 @@ +/** + * The Worker that serves the API: Better Auth's routes under `/api/auth`, + * the git server for everything else. `src/worker.ts` fronts it on the + * website's origin. + */ +import * as HttpRouter from "effect/unstable/http/HttpRouter"; + +import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Git from "alchemy/Git"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import { Auth, AuthDb } from "./auth.ts"; +import { HttpLive } from "./git.ts"; + +export default class GitHost extends Cloudflare.Worker()( + "GitHost", + { + main: import.meta.url, + ...Git.GIT_WORKER_OPTIONS, + observability: { enabled: true }, + }, + Effect.gen(function* () { + const auth = yield* Auth; + const fetch = yield* HttpRouter.toHttpEffect(HttpLive); + return { + fetch: Effect.scoped( + Effect.gen(function* () { + const request = yield* HttpServerRequest; + if (request.url.startsWith("/api/auth")) return yield* auth.fetch; + return yield* fetch; + }), + ), + }; + }).pipe(Effect.provide(CloudflareD1(AuthDb))), +) {} diff --git a/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/middleware.ts b/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/middleware.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9ab4c979c5324afc200dcc2ad11da247d1a14a8 --- /dev/null +++ b/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/middleware.ts @@ -0,0 +1,104 @@ +/** + * Who may call what. Native `HttpApiMiddleware` on the application's API, + * git's and ours: REST, the wire, the raw reads, and the GitHub facade. + * A user owns the repositories under their own name; anyone may read a + * public one. + */ +import { RuntimeContext } from "alchemy"; +import * as Git from "alchemy/Git"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as Layer from "effect/Layer"; +import * as HttpApiMiddleware from "effect/unstable/httpapi/HttpApiMiddleware"; +import * as HttpRouter from "effect/unstable/http/HttpRouter"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; +import * as HttpApiSecurity from "effect/unstable/httpapi/HttpApiSecurity"; +import { Auth, Session, Unauthorized } from "./auth.ts"; + +/** A 401 that makes `git` ask for credentials. */ +const unauthorized = HttpServerResponse.jsonUnsafe( + { _tag: "Unauthorized" }, + { status: 401, headers: { "www-authenticate": 'Basic realm="git"' } }, +); + +/** + * An API key in the password field of the remote names a user; otherwise + * the session cookie does; otherwise the request is anonymous. A user may + * do anything under their own owner name and use the routes that have no + * owner (create, list, import); anyone may read a public repository. + */ +export class Authentication extends HttpApiMiddleware.Service< + Authentication, + { provides: Session } +>()("app/Authentication", { error: Unauthorized }) { + static readonly layer = Layer.effect( + Authentication, + Effect.gen(function* () { + const auth = yield* Auth; + const registry = yield* Git.RegistryStore; + + const resolve = Effect.gen(function* () { + const { password } = yield* HttpApiBuilder.securityDecode( + HttpApiSecurity.basic, + ); + const key = Redacted.value(password); + if (key !== "") { + const verified = yield* auth.api + .verifyApiKey({ body: { key } }) + .pipe( + Effect.catchTag("BetterAuthApiError", () => + Effect.succeed({ valid: false as const, key: null }), + ), + ); + return verified.valid && verified.key + ? { id: verified.key.referenceId } + : undefined; + } + const session = yield* auth + .getSession() + .pipe( + Effect.catchTag("BetterAuthApiError", () => Effect.succeed(null)), + ); + return session + ? { id: session.user.id, name: session.user.name } + : undefined; + }); + + /** + * Anonymous may read one public repository. A repository that does + * not exist is the route's 404, so a 401 never confirms a private one. + */ + const publicRead = Effect.gen(function* () { + const params = yield* HttpRouter.params; + const owner = params.owner?.toLowerCase(); + const name = params.repo?.toLowerCase().replace(/\.git$/, ""); + if (owner === undefined || name === undefined) return false; + const entry = yield* registry + .resolve(owner, name) + .pipe(Effect.catchTag("StoreError", () => Effect.succeed(undefined))); + return entry === undefined || entry.public; + }); + + return (httpEffect) => + Effect.gen(function* () { + const user = yield* resolve; + const { owner } = yield* HttpRouter.params; + const own = + owner === undefined || + owner.toLowerCase() === user?.id.toLowerCase(); + if (user !== undefined && own) { + return yield* Effect.provideService(httpEffect, Session, { user }); + } + const request = yield* HttpServerRequest; + if (Git.isRead(request) && (yield* publicRead)) { + return yield* Effect.provideService(httpEffect, Session, { + user: user ?? null, + }); + } + return unauthorized; + }).pipe(Effect.provide(RuntimeContext.phantom)); + }), + ); +} diff --git a/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/protocol.ts b/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/protocol.ts new file mode 100644 index 0000000000000000000000000000000000000000..543696ccef3fe5ab33fb918ec3e04fa17dc69b85 --- /dev/null +++ b/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/protocol.ts @@ -0,0 +1,49 @@ +/** Smart HTTP uses the same API middleware and request services as JSON endpoints. */ +import * as Git from "alchemy/Git"; +import * as GitHttp from "alchemy/Git/Http"; +import * as Effect from "effect/Effect"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; +import { AppApi } from "./api.ts"; +import { checkRefChanges } from "./branch-policy.ts"; + +export const ProtocolLive = HttpApiBuilder.group(AppApi, "protocol", (h) => + Effect.gen(function* () { + const git = yield* Git.Engine; + const defaults = yield* Git.Handlers; + return h + .handleRaw("infoRefs", defaults.protocol.infoRefs) + .handleRaw("uploadPack", defaults.protocol.uploadPack) + .handleRaw("receivePack", ({ params, request }) => + Effect.scoped( + Effect.gen(function* () { + const repo = yield* git.repositories + .get(params) + .pipe(Effect.catchTag("StoreError", Effect.die)); + const push = yield* GitHttp.ReceivePack.decode(request); + if (push._tag === "Probe") + return GitHttp.ReceivePack.probeResponse(); + return yield* Effect.gen(function* () { + yield* checkRefChanges(repo, push.updates); + const prepared = yield* git.preparePush(repo, push.input); + // Additional application validation can read prepared.readObject(oid) here. + return GitHttp.ReceivePack.response( + push, + yield* git.commitPush(prepared), + ); + }).pipe( + Effect.catchTag("PushDenied", (error) => + Effect.succeed(GitHttp.ReceivePack.reject(push, error.reason)), + ), + ); + }), + ).pipe( + Effect.catchTag("StoreError", (error) => + Effect.succeed(GitHttp.ReceivePack.failure(error.reason)), + ), + Effect.catchTag(["WireProtocolError", "PackIngestError"], (error) => + Effect.succeed(GitHttp.ReceivePack.failure(error.reason)), + ), + ), + ); + }), +); diff --git a/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/ref-writes.ts b/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/ref-writes.ts new file mode 100644 index 0000000000000000000000000000000000000000..dbc83229f11ebc3069e2bb3f450d276233c50eb7 --- /dev/null +++ b/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/ref-writes.ts @@ -0,0 +1,96 @@ +/** Authorization and commit stay in the request effect, across every write transport. */ +import * as Git from "alchemy/Git"; +import * as Effect from "effect/Effect"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; +import { AppApi } from "./api.ts"; +import { checkRefChanges } from "./branch-policy.ts"; + +export const RefsLive = HttpApiBuilder.group(AppApi, "refs", (h) => + Effect.gen(function* () { + const git = yield* Git.Engine; + const defaults = yield* Git.Handlers; + return h.handleAll({ + ...defaults.refs, + update: ({ params, query, payload }) => + Effect.scoped( + Effect.gen(function* () { + if (/^0+$/.test(payload.newOid)) + return yield* new Git.PushDenied({ + ref: query.name, + reason: "use the DELETE endpoint to remove a ref", + }); + const repo = yield* git.repositories.get(params); + const prepared = yield* git.prepareRefUpdate(repo, { + ref: query.name, + ...payload, + }); + yield* checkRefChanges(repo, prepared.updates); + const result = yield* prepared.commit; + if (result === undefined) + return yield* new Git.PushDenied({ + ref: query.name, + reason: "use the DELETE endpoint to remove a ref", + }); + return new Git.Ref({ + name: result.name, + oid: result.oid as Git.Oid, + }); + }), + ).pipe(Effect.catchTag("StoreError", Effect.die)), + remove: ({ params, query, payload }) => + Effect.scoped( + Effect.gen(function* () { + const repo = yield* git.repositories.get(params); + const prepared = yield* git.prepareRefRemoval(repo, { + ref: query.name, + ...payload, + }); + yield* checkRefChanges(repo, prepared.updates); + yield* prepared.commit; + }), + ).pipe(Effect.catchTag("StoreError", Effect.die)), + }); + }), +); + +/** Used by both the typed REST endpoint and the GitHub facade. */ +export const mergePull = ( + git: Effect.Success, + params: { owner: string; repo: string; number: number }, + input: { message?: string; expectedHeadOid?: string }, +) => + Effect.scoped( + Effect.gen(function* () { + const repo = yield* git.repositories.get(params); + const prepared = yield* git.prepareMerge(repo, { + number: params.number, + ...input, + }); + yield* checkRefChanges(repo, prepared.updates); + return yield* prepared.commit; + }), + ).pipe(Effect.catchTag("StoreError", Effect.die)); + +export const PullsLive = HttpApiBuilder.group(AppApi, "pulls", (h) => + Effect.gen(function* () { + const defaults = yield* Git.Handlers; + const git = yield* Git.Engine; + return h.handleAll({ + ...defaults.pulls, + merge: ({ params, payload }) => + mergePull(git, params, payload).pipe( + Effect.map( + (result) => + new Git.MergeResult({ + ...result, + oid: result.oid as Git.Oid, + pull: new Git.Pull({ + ...result.pull, + mergeCommit: result.pull.mergeCommit as Git.Oid | null, + }), + }), + ), + ), + }); + }), +); diff --git a/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/routes.ts b/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/routes.ts new file mode 100644 index 0000000000000000000000000000000000000000..87cd98f1a4695a48f069ea487441c4b3860c07d8 --- /dev/null +++ b/.repos/alchemy-effect/examples/cloudflare-git-service/src/api/routes.ts @@ -0,0 +1,12 @@ +/** The app's own Effect HTTP endpoints. */ +import * as HttpApiEndpoint from "effect/unstable/httpapi/HttpApiEndpoint"; +import * as HttpApiGroup from "effect/unstable/httpapi/HttpApiGroup"; +import { Unauthorized, User } from "./auth.ts"; + +/** Who am I. Signed-in only. */ +export const Me = HttpApiEndpoint.get("me", "/api/v1/me", { + success: User, + error: Unauthorized, +}); + +export class AppRoutes extends HttpApiGroup.make("app").add(Me) {} diff --git a/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/client.ts b/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/client.ts new file mode 100644 index 0000000000000000000000000000000000000000..39c41c2667c11fe3954ed1528dbdec6939c1795b --- /dev/null +++ b/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/client.ts @@ -0,0 +1,633 @@ +/** + * Plain-fetch client of the git-service REST API (`/api/v1`). + * + * The types mirror `alchemy/Git`'s API schemas (`src/Git/Api/Schema.ts`) + * by hand — the SPA deliberately ships no Effect runtime; it is an example + * of consuming the service from any plain JS frontend. + */ + +// ── configuration ─────────────────────────────────────────────────────────── + +const builtinUrl: string | undefined = import.meta.env.VITE_GIT_URL; + +/** + * Where the service lives. Same origin as the SPA in the single-origin + * deployment, so the Better Auth session cookie rides along with every + * request (`credentials: "include"`). + */ +export interface Connection { + readonly url: string; +} + +export const getConnection = (): Connection => ({ + url: (builtinUrl ?? location.origin).replace(/\/+$/, ""), +}); + +// ── auth (Better Auth, mounted at /api/auth) ──────────────────────────────── + +export interface User { + readonly id: string; + readonly name: string; + readonly email: string; +} + +const authRequest = async ( + c: Connection, + method: string, + path: string, + body?: unknown, +): Promise => { + const res = await fetch(`${c.url}/api/auth${path}`, { + method, + credentials: "include", + headers: body !== undefined ? { "Content-Type": "application/json" } : {}, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (!res.ok) { + const text = await res.text(); + let message = text; + try { + message = (JSON.parse(text) as { message?: string }).message ?? text; + } catch { + /* not JSON */ + } + throw new ApiError(res.status, "AuthError", message || res.statusText); + } + const text = await res.text(); + return (text.length === 0 ? null : JSON.parse(text)) as T; +}; + +export const getSession = (c: Connection): Promise => + authRequest<{ user: User } | null>(c, "GET", "/get-session").then( + (session) => session?.user ?? null, + ); + +export const signUp = ( + c: Connection, + input: { name: string; email: string; password: string }, +): Promise => + authRequest<{ user: User }>(c, "POST", "/sign-up/email", input).then( + (r) => r.user, + ); + +export const signIn = ( + c: Connection, + input: { email: string; password: string }, +): Promise => + authRequest<{ user: User }>(c, "POST", "/sign-in/email", input).then( + (r) => r.user, + ); + +export const signOut = (c: Connection): Promise => + authRequest(c, "POST", "/sign-out", {}).then(() => undefined); + +/** An API key: the password a `git` remote carries. Values are shown once. */ +export interface ApiKey { + readonly id: string; + readonly name: string | null; + /** The first characters of the key, for recognition. */ + readonly start: string | null; + readonly createdAt: string; + readonly expiresAt: string | null; +} + +export const listApiKeys = (c: Connection): Promise => + authRequest(c, "GET", "/api-key/list"); + +export const createApiKey = ( + c: Connection, + name: string, +): Promise => + authRequest(c, "POST", "/api-key/create", { name }); + +export const deleteApiKey = (c: Connection, keyId: string): Promise => + authRequest(c, "POST", "/api-key/delete", { keyId }).then( + () => undefined, + ); + +export type RepoStatus = "ready" | "importing" | "forking" | "deleting"; + +export interface ObjectStats { + loose: number; + packed: number; + r2: number; + bytes: number; +} + +export interface PushStats { + objects: number; + bytes: number; + ingestMs: number; + stageMs: number; + connectivityMs: number; + finalizeMs: number; + totalMs: number; +} + +export interface Repo { + owner: string; + name: string; + repoId: string; + defaultBranch: string; + description: string | null; + readOnly: boolean; + /** Readable (REST + clone) without signing in. */ + public: boolean; + forkOf: string | null; + status: RepoStatus; + createdAt: number; + objects: ObjectStats; + lastPush: PushStats | null; +} + +export interface Ref { + name: string; + oid: string; + peeled?: string; +} + +export interface Signature { + name: string; + email: string; + /** Unix timestamp (seconds). */ + date: number; + /** Timezone offset as written, e.g. `+0200`. */ + tz: string; +} + +export interface CommitInfo { + oid: string; + tree: string; + parents: string[]; + author: Signature; + committer: Signature; + message: string; +} + +export interface TreeEntry { + mode: string; + name: string; + oid: string; + type: "blob" | "tree" | "commit"; +} + +export type FileStatus = "added" | "removed" | "modified"; + +/** + * One changed file in a commit diff or comparison. Content is NOT + * included — clients fetch old/new blobs by oid (`getBlob`) and diff + * locally. `oldSize`/`newSize` gate binary/oversize files without a + * round trip. Gitlinks (mode `160000`) carry commit oids — render + * "Subproject commit …", never fetch them as blobs. No rename detection + * in v1 (a rename is `removed` + `added`); a mode-only change is + * `modified` with `oldOid === newOid`. + */ +export interface DiffEntry { + path: string; + status: FileStatus; + oldOid?: string | null; + newOid?: string | null; + oldMode?: string | null; + newMode?: string | null; + oldSize?: number | null; + newSize?: number | null; +} + +/** Changed files of one commit vs its FIRST parent (null for a root commit). */ +export interface CommitDiff { + oid: string; + parent: string | null; + files: DiffEntry[]; + /** `true` when the list was cut at the server cap (1000 files). */ + truncated: boolean; +} + +/** GitHub-style three-dot comparison of two revisions. */ +export interface Comparison { + base: string; + head: string; + mergeBase: string; + aheadBy: number; + behindBy: number; + /** Head-side commits, committer-time descending, capped at 250. */ + commits: CommitInfo[]; + commitsTruncated: boolean; + /** File diff of mergeBase..head (three-dot). */ + files: DiffEntry[]; + filesTruncated: boolean; +} + +export interface RepoCreated { + repo: Repo; + remote: string; +} + +export interface Page { + items: T[]; + nextCursor: string | null; + hasMore: boolean; +} + +// ── errors ────────────────────────────────────────────────────────────────── + +export class ApiError extends Error { + constructor( + readonly status: number, + readonly tag: string, + override readonly message: string, + ) { + super(message); + } +} + +const parseError = async (res: Response): Promise => { + let tag = `HTTP ${res.status}`; + let message = res.statusText; + try { + const body: unknown = await res.clone().json(); + if (typeof body === "object" && body !== null) { + const record = body as Record; + if (typeof record._tag === "string") tag = record._tag; + if (typeof record.message === "string") message = record.message; + else if (typeof record.reason === "string") message = record.reason; + } + } catch { + try { + message = (await res.text()) || message; + } catch { + /* keep statusText */ + } + } + return new ApiError(res.status, tag, message || tag); +}; + +// ── transport ─────────────────────────────────────────────────────────────── + +const request = async ( + connection: Connection, + method: string, + path: string, + body?: unknown, +): Promise => { + const res = await fetch(`${connection.url}/api/v1${path}`, { + method, + // The Better Auth session cookie is the credential; anonymous + // requests carry none and the policy confines them to public reads. + credentials: "include", + headers: body !== undefined ? { "Content-Type": "application/json" } : {}, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (!res.ok) throw await parseError(res); + if (res.status === 204) return undefined as T; + const text = await res.text(); + return (text.length === 0 ? undefined : JSON.parse(text)) as T; +}; + +const seg = encodeURIComponent; + +// ── repos ─────────────────────────────────────────────────────────────────── + +export const listRepos = ( + c: Connection, + query?: { owner?: string; cursor?: string; limit?: number }, +): Promise> => { + const params = new URLSearchParams(); + if (query?.owner) params.set("owner", query.owner); + if (query?.cursor) params.set("cursor", query.cursor); + if (query?.limit) params.set("limit", String(query.limit)); + const qs = params.size > 0 ? `?${params}` : ""; + return request(c, "GET", `/repos${qs}`); +}; + +export const getRepo = (c: Connection, owner: string, repo: string) => + request(c, "GET", `/repos/${seg(owner)}/${seg(repo)}`); + +export const createRepo = ( + c: Connection, + payload: { + owner: string; + name: string; + description?: string; + public?: boolean; + }, +) => request(c, "POST", `/repos`, payload); + +export const updateRepo = ( + c: Connection, + owner: string, + repo: string, + payload: { + description?: string | null; + defaultBranch?: string; + readOnly?: boolean; + public?: boolean; + }, +) => request(c, "PATCH", `/repos/${seg(owner)}/${seg(repo)}`, payload); + +export const deleteRepo = (c: Connection, owner: string, repo: string) => + request(c, "DELETE", `/repos/${seg(owner)}/${seg(repo)}`); + +export const compactRepo = (c: Connection, owner: string, repo: string) => + request(c, "POST", `/repos/${seg(owner)}/${seg(repo)}/compact`); + +// ── refs ──────────────────────────────────────────────────────────────────── + +export const listRefs = (c: Connection, owner: string, repo: string) => + request<{ head: string | null; refs: Ref[] }>( + c, + "GET", + `/repos/${seg(owner)}/${seg(repo)}/refs`, + ); + +// ── objects ───────────────────────────────────────────────────────────────── + +export const getCommit = ( + c: Connection, + owner: string, + repo: string, + oid: string, +) => + request( + c, + "GET", + `/repos/${seg(owner)}/${seg(repo)}/commits/${seg(oid)}`, + ); + +export const getLog = ( + c: Connection, + owner: string, + repo: string, + query?: { ref?: string; cursor?: string; limit?: number }, +): Promise> => { + const params = new URLSearchParams(); + if (query?.ref) params.set("ref", query.ref); + if (query?.cursor) params.set("cursor", query.cursor); + if (query?.limit) params.set("limit", String(query.limit)); + const qs = params.size > 0 ? `?${params}` : ""; + return request(c, "GET", `/repos/${seg(owner)}/${seg(repo)}/log${qs}`); +}; + +export const getTree = ( + c: Connection, + owner: string, + repo: string, + oid: string, +) => + request<{ oid: string; entries: TreeEntry[] }>( + c, + "GET", + `/repos/${seg(owner)}/${seg(repo)}/trees/${seg(oid)}`, + ); + +/** Raw file bytes at `ref` + `path` (the streaming non-JSON route). */ +export const getFile = async ( + c: Connection, + owner: string, + repo: string, + options: { ref?: string; path: string }, +): Promise => { + const params = new URLSearchParams({ path: options.path }); + if (options.ref) params.set("ref", options.ref); + const res = await fetch( + `${c.url}/api/v1/repos/${seg(owner)}/${seg(repo)}/file?${params}`, + { + credentials: "include", + }, + ); + if (!res.ok) throw await parseError(res); + return new Uint8Array(await res.arrayBuffer()); +}; + +/** Changed files of a commit vs its first parent (empty tree for a root). */ +export const getCommitDiff = ( + c: Connection, + owner: string, + repo: string, + oid: string, +) => + request( + c, + "GET", + `/repos/${seg(owner)}/${seg(repo)}/commits/${seg(oid)}/diff`, + ); + +/** + * Three-dot comparison of two revisions (short/full refname or 40-hex + * oid; annotated tags peeled): merge base, ahead/behind, head-side + * commits, and the mergeBase..head file diff. + */ +export const compareCommits = ( + c: Connection, + owner: string, + repo: string, + query: { base: string; head: string }, +) => { + const params = new URLSearchParams({ base: query.base, head: query.head }); + return request( + c, + "GET", + `/repos/${seg(owner)}/${seg(repo)}/compare?${params}`, + ); +}; + +/** JSON blob endpoint serves ≤ 1 MiB only (422 beyond; use /raw). */ +const MAX_JSON_BLOB = 1024 * 1024; + +/** Raw blob bytes by oid (the streaming non-JSON route, any size). */ +const getBlobRaw = async ( + c: Connection, + owner: string, + repo: string, + oid: string, +): Promise => { + const res = await fetch( + `${c.url}/api/v1/repos/${seg(owner)}/${seg(repo)}/blobs/${seg(oid)}/raw`, + { credentials: "include" }, + ); + if (!res.ok) throw await parseError(res); + return new Uint8Array(await res.arrayBuffer()); +}; + +/** + * Blob bytes by oid. Uses the JSON base64 route for blobs ≤ 1 MiB and + * falls back to `/raw` for bigger ones — pass `size` (known from a + * {@link DiffEntry}) to skip the doomed JSON attempt entirely. + */ +export const getBlob = async ( + c: Connection, + owner: string, + repo: string, + oid: string, + options?: { size?: number }, +): Promise => { + if (options?.size !== undefined && options.size > MAX_JSON_BLOB) { + return getBlobRaw(c, owner, repo, oid); + } + try { + const json = await request<{ + oid: string; + size: number; + encoding: "base64"; + content: string; + }>(c, "GET", `/repos/${seg(owner)}/${seg(repo)}/blobs/${seg(oid)}`); + const binary = atob(json.content); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; + } catch (cause) { + // ObjectTooLarge — the size hint was absent or stale; stream it raw. + if (cause instanceof ApiError && cause.tag === "ObjectTooLarge") { + return getBlobRaw(c, owner, repo, oid); + } + throw cause; + } +}; + +// ── pull requests ─────────────────────────────────────────────────────────── + +export type PullState = "open" | "closed" | "merged"; + +/** Why `mergeable` is what it is (see {@link PullDetail}). */ +export type MergeableReason = + | "ff" + | "merge-commit" + | "conflict" + | "up-to-date" + | "unknown"; + +/** + * A pull request. PRs track **live** branches by ref name — the record + * stores intent + lifecycle, never a diff snapshot; diff and mergeability + * are recomputed from current ref tips on every read. + */ +export interface Pull { + /** Per-repo monotonic PR number (1-based, never reused). */ + number: number; + title: string; + body: string | null; + /** Full base ref name, e.g. `refs/heads/main`. */ + baseRef: string; + /** Full head ref name, e.g. `refs/heads/feature`. */ + headRef: string; + state: PullState; + /** Epoch milliseconds. */ + createdAt: number; + updatedAt: number; + /** Epoch milliseconds; `null` unless `state` is `merged`. */ + mergedAt: number | null; + /** FF: the head tip; the merge commit otherwise. Set iff merged. */ + mergeCommit: string | null; +} + +/** + * PR detail = the row + live computed compare fields. Live fields are + * `null` when uncomputable: a missing base/head branch, a saturated + * ancestor walk, or a merged PR (its record is `mergeCommit`). + */ +export interface PullDetail extends Pull { + /** Current tip of `baseRef`; `null` if the branch is gone. */ + baseOid: string | null; + /** Current tip of `headRef`; `null` if the branch is gone. */ + headOid: string | null; + mergeBase: string | null; + /** Commits on head not on base (`null` when the walk saturates). */ + aheadBy: number | null; + /** Commits on base not on head. */ + behindBy: number | null; + /** + * `true` = FF-able or trivially merge-able; `false` = conflicting + * paths or up-to-date; `null` = uncomputable. + */ + mergeable: boolean | null; + mergeableReason: MergeableReason | null; +} + +/** Response of a successful PR merge. */ +export interface MergeResult { + method: "ff" | "merge-commit"; + /** The oid the base ref now points at. */ + oid: string; + /** The PR after the merge (`state: "merged"`). */ + pull: Pull; +} + +/** Lists PRs, newest first. `state` defaults to `open` server-side. */ +export const listPulls = ( + c: Connection, + owner: string, + repo: string, + query?: { + state?: PullState | "all"; + cursor?: string; + limit?: number; + }, +): Promise> => { + const params = new URLSearchParams(); + if (query?.state) params.set("state", query.state); + if (query?.cursor) params.set("cursor", query.cursor); + if (query?.limit) params.set("limit", String(query.limit)); + const qs = params.size > 0 ? `?${params}` : ""; + return request(c, "GET", `/repos/${seg(owner)}/${seg(repo)}/pulls${qs}`); +}; + +/** Reads one PR with live compare fields (ahead/behind/mergeable). */ +export const getPull = ( + c: Connection, + owner: string, + repo: string, + number: number, +) => + request( + c, + "GET", + `/repos/${seg(owner)}/${seg(repo)}/pulls/${number}`, + ); + +/** Opens a PR. `base`/`head` accept short (`main`) or full branch names. */ +export const createPull = ( + c: Connection, + owner: string, + repo: string, + payload: { title: string; body?: string; base: string; head: string }, +) => + request(c, "POST", `/repos/${seg(owner)}/${seg(repo)}/pulls`, payload); + +/** Patches title/body, or closes/reopens via `state`. */ +export const updatePull = ( + c: Connection, + owner: string, + repo: string, + number: number, + payload: { + title?: string; + body?: string | null; + state?: "open" | "closed"; + }, +) => + request( + c, + "PATCH", + `/repos/${seg(owner)}/${seg(repo)}/pulls/${number}`, + payload, + ); + +/** + * Merges an open PR: fast-forward when possible, else a merge commit iff + * the three-way tree merge is trivial. `expectedHeadOid` guards against + * a force-push racing the merge (409 RefConflict when stale). + */ +export const mergePull = ( + c: Connection, + owner: string, + repo: string, + number: number, + payload?: { message?: string; expectedHeadOid?: string }, +) => + request( + c, + "POST", + `/repos/${seg(owner)}/${seg(repo)}/pulls/${number}/merge`, + payload ?? {}, + ); diff --git a/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/components.tsx b/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/components.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e5c64971c96bec5f7bf58b597b3b3cdc0afcae23 --- /dev/null +++ b/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/components.tsx @@ -0,0 +1,425 @@ +/** Shared UI atoms — GitHub-flavored. */ +import hljs from "highlight.js/lib/core"; +import bash from "highlight.js/lib/languages/bash"; +import cLang from "highlight.js/lib/languages/c"; +import cpp from "highlight.js/lib/languages/cpp"; +import css from "highlight.js/lib/languages/css"; +import diff from "highlight.js/lib/languages/diff"; +import go from "highlight.js/lib/languages/go"; +import java from "highlight.js/lib/languages/java"; +import javascript from "highlight.js/lib/languages/javascript"; +import json from "highlight.js/lib/languages/json"; +import markdown from "highlight.js/lib/languages/markdown"; +import python from "highlight.js/lib/languages/python"; +import rust from "highlight.js/lib/languages/rust"; +import sql from "highlight.js/lib/languages/sql"; +import typescript from "highlight.js/lib/languages/typescript"; +import xml from "highlight.js/lib/languages/xml"; +import yaml from "highlight.js/lib/languages/yaml"; +import { Marked } from "marked"; +import { markedHighlight } from "marked-highlight"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { ApiError } from "./client.ts"; +import { useTheme } from "./theme.tsx"; + +// ── markdown rendering ────────────────────────────────────────────────────── + +for (const [name, language] of Object.entries({ + bash, + c: cLang, + cpp, + css, + diff, + go, + java, + javascript, + json, + markdown, + python, + rust, + sql, + typescript, + xml, + yaml, +})) { + hljs.registerLanguage(name, language); +} +hljs.registerAliases(["ts", "tsx", "mts", "cts"], { + languageName: "typescript", +}); +hljs.registerAliases(["js", "jsx", "mjs", "cjs"], { + languageName: "javascript", +}); +hljs.registerAliases(["sh", "shell", "zsh"], { languageName: "bash" }); +hljs.registerAliases(["yml"], { languageName: "yaml" }); +hljs.registerAliases(["html", "svg"], { languageName: "xml" }); +hljs.registerAliases(["py"], { languageName: "python" }); +hljs.registerAliases(["md"], { languageName: "markdown" }); + +const escapeHtml = (code: string): string => + code.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); + +const highlightExtension = markedHighlight({ + langPrefix: "hljs language-", + highlight(code, lang) { + const language = lang.split(/\s/)[0] ?? ""; + // Always return HTML we produced: either highlighted tokens or the + // escaped source — never the raw code string. + return hljs.getLanguage(language) !== undefined + ? hljs.highlight(code, { language }).value + : escapeHtml(code); + }, +}); + +const renderer = new Marked(highlightExtension); + +/** + * Resolves a README-relative asset path against the repo's raw-file + * endpoint, so `![](./docs/logo.png)` renders. Handles `./`, `../`, and + * root-relative (`/images/x.png`) forms; absolute URLs pass through. + */ +export const makeAssetResolver = + (options: { + /** Base URL of the file endpoint for the browsed repo. */ + readonly fileUrl: (path: string) => string; + /** Directory (path segments) the markdown file lives in. */ + readonly dir: ReadonlyArray; + }) => + (href: string): string => { + if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(href)) return href; + const root = href.startsWith("/"); + const segments: Array = root ? [] : [...options.dir]; + for (const part of href.replace(/^\//, "").split("/")) { + if (part === "" || part === ".") continue; + if (part === "..") segments.pop(); + else segments.push(part); + } + return options.fileUrl(segments.join("/")); + }; + +// ── icons (Octicon paths, MIT) ────────────────────────────────────────────── + +const Icon = ({ d, className }: { d: string; className?: string }) => ( + +); + +export const RepoIcon = ({ className }: { className?: string }) => ( + +); + +export const FolderIcon = ({ className }: { className?: string }) => ( + +); + +export const FileIcon = ({ className }: { className?: string }) => ( + +); + +export const BranchIcon = ({ className }: { className?: string }) => ( + +); + +export const TagIcon = ({ className }: { className?: string }) => ( + +); + +export const CommitIcon = ({ className }: { className?: string }) => ( + +); + +export const ForkIcon = ({ className }: { className?: string }) => ( + +); + +export const PullRequestIcon = ({ className }: { className?: string }) => ( + +); + +export const GitMergeIcon = ({ className }: { className?: string }) => ( + +); + +export const PullClosedIcon = ({ className }: { className?: string }) => ( + +); + +export const GearIcon = ({ className }: { className?: string }) => ( + +); + +export const CopyIcon = ({ className }: { className?: string }) => ( + +); + +export const SunIcon = ({ className }: { className?: string }) => ( + +); + +export const MoonIcon = ({ className }: { className?: string }) => ( + +); + +export const DeviceIcon = ({ className }: { className?: string }) => ( + +); + +// ── atoms ─────────────────────────────────────────────────────────────────── + +/** Cycles light → dark → system. Icon shows the current preference. */ +export const ThemeToggle = () => { + const { preference, setPreference } = useTheme(); + const next = + preference === "light" + ? "dark" + : preference === "dark" + ? "system" + : "light"; + return ( + + ); +}; + +export const Spinner = () => ( +
+
+
+); + +export const ErrorBox = ({ error }: { error: unknown }) => { + const message = + error instanceof ApiError + ? `${error.tag}: ${error.message}` + : error instanceof Error + ? error.message + : String(error); + return ( +
+ {message} +
+ ); +}; + +export const Badge = ({ + children, + tone = "default", +}: { + children: ReactNode; + tone?: "default" | "attention" | "danger"; +}) => ( + + {children} + +); + +export const Button = ({ + children, + onClick, + kind = "default", + disabled, + type, +}: { + children: ReactNode; + onClick?: () => void; + kind?: "default" | "primary" | "danger"; + disabled?: boolean; + type?: "submit" | "button"; +}) => ( + +); + +export const Input = ({ + value, + onChange, + placeholder, + type, + mono, +}: { + value: string; + onChange: (value: string) => void; + placeholder?: string; + type?: string; + mono?: boolean; +}) => ( + onChange(event.target.value)} + placeholder={placeholder} + className={`w-full rounded-md border border-border-muted bg-canvas px-3 py-1.5 text-sm outline-none focus:border-accent focus:ring-2 focus:ring-accent/30 ${mono ? "font-mono" : ""}`} + /> +); + +/** One-click copy affordance for remotes, oids, and tokens. */ +export const CopyButton = ({ text }: { text: string }) => { + const [copied, setCopied] = useState(false); + useEffect(() => { + if (!copied) return; + const timer = setTimeout(() => setCopied(false), 1500); + return () => clearTimeout(timer); + }, [copied]); + return ( + + ); +}; + +/** + * README / markdown rendering: code blocks are syntax-highlighted + * (highlight.js over marked) and relative image paths resolve through + * `resolveAsset` to the repo's raw-file endpoint. Repo content is the + * same trust model as an IDE preview, so `marked` output is injected + * without a sanitizer pass. + */ +export const Markdown = ({ + source, + resolveAsset, +}: { + source: string; + /** Maps a relative image/link target to a fetchable URL. */ + resolveAsset?: ((href: string) => string) | undefined; +}) => { + const html = useMemo(() => { + const walked = + resolveAsset === undefined + ? renderer + : new Marked(highlightExtension, { + walkTokens(token) { + if (token.type === "image" && typeof token.href === "string") { + token.href = resolveAsset(token.href); + } + // READMEs routinely use raw `` HTML (centered heroes, + // width attributes) — rewrite those src values too. + if ( + (token.type === "html" || token.type === "text") && + typeof token.text === "string" && + token.text.includes("]*?\bsrc=)("([^"]*)"|'([^']*)')/gi, + ( + _match, + prefix: string, + _quoted, + doubleQuoted, + singleQuoted, + ) => { + const href = (doubleQuoted ?? singleQuoted) as string; + return `${prefix}"${resolveAsset(href)}"`; + }, + ); + } + }, + }); + return walked.parse(source, { async: false }); + }, [source, resolveAsset]); + return ( +
+ ); +}; diff --git a/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/diff.tsx b/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/diff.tsx new file mode 100644 index 0000000000000000000000000000000000000000..509bda649727eab9060ce9335fbc8e17e69aa410 --- /dev/null +++ b/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/diff.tsx @@ -0,0 +1,76 @@ +/** + * Shared `@pierre/diffs` plumbing for the lazy-loaded code/diff views. + * + * IMPORTANT: this module (transitively) pulls in the whole diffs+shiki + * runtime. Only `React.lazy` chunks (`pages/Commit.tsx`, + * `pages/FileView.tsx`) may import it — never `main.tsx`/`Repo.tsx` + * statically — so the library stays out of the initial bundle. + * + * Reference equality is the library's re-render contract: every + * `options`/`file`/`fileDiff` object handed to a component must be + * memoized (the hooks below key their `useMemo` on the resolved theme). + * `themeType` is always pinned to our toggle — the components render + * into a shadow root that would otherwise follow the OS, not the app's + * `.dark` class. + */ +import type { FileDiffOptions, FileOptions } from "@pierre/diffs"; +import { useMemo } from "react"; +import { useTheme } from "./theme.tsx"; + +/** Shiki theme pair matching the app palette (see `useDiffThemeOptions`). */ +export const DIFF_THEMES = { + light: "github-light-default", + dark: "github-dark-default", +} as const; + +/** Memoized options for ``: unified view, app-owned header. */ +export const useFileDiffOptions = (): FileDiffOptions => { + const { resolved } = useTheme(); + return useMemo( + () => ({ + theme: DIFF_THEMES, + themeType: resolved, + diffStyle: "unified", + disableFileHeader: true, + }), + [resolved], + ); +}; + +/** Memoized options for `` (plain blob view, app-owned header). */ +export const useFileOptions = (): FileOptions => { + const { resolved } = useTheme(); + return useMemo( + () => ({ + theme: DIFF_THEMES, + themeType: resolved, + disableFileHeader: true, + }), + [resolved], + ); +}; + +/** Blobs bigger than this are never fetched/rendered as text diffs. */ +export const MAX_RENDER_BYTES = 1024 * 1024; + +/** Plain promise concurrency limiter (the SPA ships no Effect runtime). */ +export const makeLimiter = (max: number) => { + let active = 0; + const queue: Array<() => void> = []; + const release = () => { + active--; + queue.shift()?.(); + }; + return (task: () => Promise): Promise => + new Promise((resolve, reject) => { + const run = () => { + active++; + task().then(resolve, reject).finally(release); + }; + if (active < max) run(); + else queue.push(run); + }); +}; + +/** App-wide cap on concurrent blob fetches from the diff views. */ +export const blobLimiter = makeLimiter(4); diff --git a/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/format.ts b/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/format.ts new file mode 100644 index 0000000000000000000000000000000000000000..66d92576efc9d2e5e235eb87b4659f73eab404e8 --- /dev/null +++ b/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/format.ts @@ -0,0 +1,64 @@ +/** Presentation helpers shared by the pages. */ + +export const timeAgo = (epochMs: number): string => { + const s = Math.max(0, Math.floor((Date.now() - epochMs) / 1000)); + if (s < 60) return "just now"; + const m = Math.floor(s / 60); + if (m < 60) return `${m} minute${m === 1 ? "" : "s"} ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h} hour${h === 1 ? "" : "s"} ago`; + const d = Math.floor(h / 24); + if (d < 30) return `${d} day${d === 1 ? "" : "s"} ago`; + const mo = Math.floor(d / 30); + if (mo < 12) return `${mo} month${mo === 1 ? "" : "s"} ago`; + const y = Math.floor(mo / 12); + return `${y} year${y === 1 ? "" : "s"} ago`; +}; + +export const formatBytes = (bytes: number): string => { + if (bytes < 1024) return `${bytes} B`; + const units = ["KiB", "MiB", "GiB", "TiB"]; + let value = bytes / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[unit]}`; +}; + +export const shortOid = (oid: string): string => oid.slice(0, 7); + +/** First line of a commit message. */ +export const subject = (message: string): string => + message.split("\n", 1)[0] ?? ""; + +const decoder = new TextDecoder("utf-8", { fatal: true }); + +/** Decodes bytes as UTF-8, or `null` if the content is binary. */ +export const decodeText = (bytes: Uint8Array): string | null => { + // Quick binary sniff: NUL byte in the first 8 KiB. + const head = bytes.subarray(0, 8192); + for (const byte of head) if (byte === 0) return null; + try { + return decoder.decode(bytes); + } catch { + return null; + } +}; + +const IMAGE_TYPES: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + svg: "image/svg+xml", + webp: "image/webp", + ico: "image/x-icon", +}; + +export const imageMime = (path: string): string | undefined => + IMAGE_TYPES[path.split(".").pop()?.toLowerCase() ?? ""]; + +export const isMarkdown = (path: string): boolean => + /\.(md|markdown)$/i.test(path); diff --git a/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/main.tsx b/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/main.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4b3bdbf34b7fb613425c13d65ad041b44360536f --- /dev/null +++ b/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/main.tsx @@ -0,0 +1,137 @@ +import React, { useEffect, useState } from "react"; +import ReactDOM from "react-dom/client"; +import { getConnection, getSession, signOut, type User } from "./client.ts"; +import { RepoIcon, ThemeToggle } from "./components.tsx"; +import { RepoPage } from "./pages/Repo.tsx"; +import { ReposPage } from "./pages/Repos.tsx"; +import { SignInPage } from "./pages/SignIn.tsx"; +import { Link, Router, segments, useRouter } from "./router.tsx"; +import "./styles.css"; +import { ThemeProvider } from "./theme.tsx"; + +/** + * A GitHub-style browser for the deployed git-service — a plain React SPA + * (no framework router, no Effect in the bundle) that drives the service's + * REST API (`/api/v1`) with the Better Auth session cookie. + * + * Routes (GitHub-shaped): + * + * / → repository list + * /:owner/:repo → code view (tree @ default branch) + * /:owner/:repo/tree/:ref/*path → tree at path + * /:owner/:repo/blob/:ref/*path → file view + * /:owner/:repo/commits/:ref → commit log + * /:owner/:repo/commit/:oid → one commit: message + file diffs + * /:owner/:repo/pulls → pull requests (open/closed/merged) + * /:owner/:repo/pulls/:number → one pull request: merge box + diffs + * /:owner/:repo/settings → API keys / storage / danger zone + */ +const Header = ({ + user, + onSignIn, + onSignOut, +}: { + user: User | null; + onSignIn: () => void; + onSignOut: () => void; +}) => ( +
+
+ + + git service + +
+ + {user !== null ? ( + <> + {user.name || user.email} + + + ) : ( + + )} +
+
+
+); + +const Routes = () => { + const { path } = useRouter(); + const connection = getConnection(); + // `undefined` while the session probe is in flight. + const [user, setUser] = useState(undefined); + const [showSignIn, setShowSignIn] = useState(false); + + useEffect(() => { + getSession(connection) + .then(setUser) + .catch(() => setUser(null)); + }, [connection.url]); + + if (user === undefined) return null; + + // Browsing is ANONYMOUS by default — public repos need no account. The + // sign-in screen appears only when the user asks for it. + if (showSignIn) { + return ( + { + setUser(signedIn); + setShowSignIn(false); + }} + onCancel={() => setShowSignIn(false)} + /> + ); + } + + const parts = segments(path); + return ( + <> +
setShowSignIn(true)} + onSignOut={() => { + void signOut(connection).finally(() => setUser(null)); + }} + /> +
+ {parts.length === 0 ? ( + + ) : parts.length >= 2 ? ( + + ) : ( +
Not found.
+ )} +
+ + ); +}; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + + + , +); diff --git a/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/pages/Commit.tsx b/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/pages/Commit.tsx new file mode 100644 index 0000000000000000000000000000000000000000..69374119b40b6fb13d3820aff88e3c7b4d87e90a --- /dev/null +++ b/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/pages/Commit.tsx @@ -0,0 +1,144 @@ +/** + * The commit view (`/:owner/:repo/commit/:oid`): GitHub-style page with + * the commit message/metadata on top and one rendered diff per changed + * file below. + * + * The server returns the changed-file *list* only (`commits/:oid/diff`); + * the shared `FileDiffList` (see `DiffList.tsx`) fetches blob contents + * and diffs locally with `@pierre/diffs`. This module is + * `React.lazy`-loaded from `Repo.tsx` so the diffs+shiki runtime stays + * out of the initial bundle. + */ +import { useEffect, useMemo, useState } from "react"; +import { + getCommit, + getCommitDiff, + type CommitDiff, + type CommitInfo, +} from "../client.ts"; +import { CopyButton, ErrorBox, Spinner } from "../components.tsx"; +import { shortOid, subject, timeAgo } from "../format.ts"; +import { href, Link } from "../router.tsx"; +import FileDiffList from "./DiffList.tsx"; +import type { RepoContext } from "./Repo.tsx"; + +const CommitPage = ({ + context, + oid, +}: { + context: RepoContext; + /** Full 40-hex commit oid (links always carry the full oid). */ + oid: string; +}) => { + const [data, setData] = useState<{ + commit: CommitInfo; + diff: CommitDiff; + } | null>(null); + const [error, setError] = useState(null); + const { connection, repo } = context; + + useEffect(() => { + let cancelled = false; + setData(null); + setError(null); + void Promise.all([ + getCommit(connection, repo.owner, repo.name, oid), + getCommitDiff(connection, repo.owner, repo.name, oid), + ]) + .then(([commit, diff]) => { + if (!cancelled) setData({ commit, diff }); + }) + .catch((cause) => { + if (!cancelled) setError(cause); + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [oid, repo.repoId]); + + const body = useMemo( + () => + data === null + ? "" + : data.commit.message.split("\n").slice(1).join("\n").trim(), + [data], + ); + + if (error != null) return ; + if (data === null) return ; + const { commit, diff } = data; + const files = diff.files; + + return ( +
+ {/* commit header */} +
+
+

{subject(commit.message)}

+ {body.length > 0 && ( +
+              {body}
+            
+ )} +

+ + {commit.author.name} + {" "} + committed {timeAgo(commit.author.date * 1000)} + {commit.committer.name !== commit.author.name && ( + <> · committed by {commit.committer.name} + )} +

+
+
+ + {commit.parents.length === 0 ? ( + root commit + ) : ( + <> + {commit.parents.length === 1 ? "parent" : "parents"}{" "} + {commit.parents.map((parent, index) => ( + + {index > 0 && +} + + {shortOid(parent)} + + + ))} + + )} + + + commit{" "} + + {shortOid(commit.oid)} + + + +
+
+ + {/* summary + truncation notice */} +

+ {files.length} file{files.length === 1 ? "" : "s"} changed + {diff.parent === null && + " (root commit — diffed against the empty tree)"} +

+ {diff.truncated && ( +
+ This commit changes more files than shown — the list was truncated at{" "} + {files.length} files by the server. +
+ )} + + {/* file diffs */} + +
+ ); +}; + +export default CommitPage; diff --git a/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/pages/Commits.tsx b/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/pages/Commits.tsx new file mode 100644 index 0000000000000000000000000000000000000000..840c896ff9fdeea0182f00f50b894f87fc3a933f --- /dev/null +++ b/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/pages/Commits.tsx @@ -0,0 +1,117 @@ +/** The Commits tab: paged history of the selected ref. */ +import { useEffect, useState } from "react"; +import { getLog, type CommitInfo } from "../client.ts"; +import { Button, CopyButton, ErrorBox, Spinner } from "../components.tsx"; +import { shortOid, subject, timeAgo } from "../format.ts"; +import { href, Link } from "../router.tsx"; +import type { RepoContext } from "./Repo.tsx"; + +const CommitRow = ({ + context, + commit, +}: { + context: RepoContext; + commit: CommitInfo; +}) => { + const [expanded, setExpanded] = useState(false); + const body = commit.message.split("\n").slice(1).join("\n").trim(); + const commitHref = href( + context.repo.owner, + context.repo.name, + "commit", + commit.oid, + ); + return ( +
  • +
    +
    + + {subject(commit.message)} + + {body.length > 0 && ( + + )} +

    + {commit.author.name} committed {timeAgo(commit.author.date * 1000)} +

    + {expanded && body.length > 0 && ( +
    +              {body}
    +            
    + )} +
    +
    + + {shortOid(commit.oid)} + + +
    +
    +
  • + ); +}; + +export const CommitsTab = ({ context }: { context: RepoContext }) => { + const [commits, setCommits] = useState(null); + const [cursor, setCursor] = useState(null); + const [error, setError] = useState(null); + const { connection, repo, refName } = context; + + const load = async (nextCursor?: string) => { + try { + const page = await getLog(connection, repo.owner, repo.name, { + ref: refName, + limit: 50, + ...(nextCursor ? { cursor: nextCursor } : {}), + }); + setCommits((existing) => + nextCursor ? [...(existing ?? []), ...page.items] : page.items, + ); + setCursor(page.hasMore ? page.nextCursor : null); + } catch (cause) { + setError(cause); + } + }; + + useEffect(() => { + setCommits(null); + void load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [refName, repo.repoId]); + + if (error != null) return ; + if (commits === null) return ; + + return ( +
    +
      + {commits.map((commit) => ( + + ))} + {commits.length === 0 && ( +
    • + No commits on {refName} +
    • + )} +
    + {cursor !== null && ( +
    + +
    + )} +
    + ); +}; diff --git a/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/pages/DiffList.tsx b/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/pages/DiffList.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f04ec7bee3b1938722cef04a6373fd6abc3f37b0 --- /dev/null +++ b/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/pages/DiffList.tsx @@ -0,0 +1,230 @@ +/** + * Shared changed-file renderer used by the commit view (`Commit.tsx`) + * and the pull-request view (`Pull.tsx`): one card per `DiffEntry` with + * a status badge header and a `@pierre/diffs` `` body, plus + * "Show more files" pagination. + * + * IMPORTANT: this module imports the diffs+shiki runtime (via + * `../diff.tsx`). Only `React.lazy` chunks may import it — never + * `main.tsx`/`Repo.tsx` statically — so the library stays out of the + * initial bundle. + * + * Each card fetches its own old/new blob contents by oid (shared + * 4-concurrent limiter) and diffs locally. Gitlinks, mode-only changes, + * oversize and binary blobs render placeholder rows and never + * fetch/parse. + */ +import { + parseDiffFromFile, + type FileContents, + type FileDiffMetadata, +} from "@pierre/diffs"; +import { FileDiff } from "@pierre/diffs/react"; +import { useEffect, useState, type ReactNode } from "react"; +import { getBlob, type DiffEntry, type FileStatus } from "../client.ts"; +import { Button, ErrorBox } from "../components.tsx"; +import { blobLimiter, MAX_RENDER_BYTES, useFileDiffOptions } from "../diff.tsx"; +import { decodeText, formatBytes } from "../format.ts"; +import type { RepoContext } from "./Repo.tsx"; + +// ── per-file diff card ────────────────────────────────────────────────────── + +type FileState = + | { kind: "loading" } + | { kind: "text"; fileDiff: FileDiffMetadata } + | { kind: "binary" } + | { kind: "error"; error: unknown }; + +const StatusBadge = ({ status }: { status: FileStatus }) => ( + + {status} + +); + +/** Placeholder body for files we deliberately don't render as a diff. */ +const NoteRow = ({ children }: { children: ReactNode }) => ( +
    {children}
    +); + +const sizeLabel = (entry: DiffEntry): string | null => { + const size = entry.newSize ?? entry.oldSize; + return size == null ? null : formatBytes(size); +}; + +export const FileDiffCard = ({ + context, + entry, +}: { + context: RepoContext; + entry: DiffEntry; +}) => { + const options = useFileDiffOptions(); + const { connection, repo } = context; + + // Cases that never fetch contents. + const gitlink = entry.oldMode === "160000" || entry.newMode === "160000"; + const modeOnly = + entry.status === "modified" && + entry.oldOid != null && + entry.oldOid === entry.newOid; + const oversize = + (entry.oldSize ?? 0) > MAX_RENDER_BYTES || + (entry.newSize ?? 0) > MAX_RENDER_BYTES; + const skip = gitlink || modeOnly || oversize; + + const [state, setState] = useState({ kind: "loading" }); + + useEffect(() => { + if (skip) return; + let cancelled = false; + setState({ kind: "loading" }); + void blobLimiter(async () => { + // Absent sides arrive as null over JSON (added/removed files) — + // treat null and undefined alike or we fetch "blobs/null". + const side = ( + oid: string | null | undefined, + size: number | null | undefined, + ) => + oid == null + ? Promise.resolve(null) + : getBlob(connection, repo.owner, repo.name, oid, { + size: size ?? undefined, + }); + const [oldBytes, newBytes] = await Promise.all([ + side(entry.oldOid, entry.oldSize), + side(entry.newOid, entry.newSize), + ]); + if (cancelled) return; + const oldText = oldBytes === null ? null : decodeText(oldBytes); + const newText = newBytes === null ? null : decodeText(newBytes); + // A side that exists but doesn't decode as UTF-8 is binary. + if ( + (oldBytes !== null && oldText === null) || + (newBytes !== null && newText === null) + ) { + setState({ kind: "binary" }); + return; + } + // The blob oid is a perfect cacheKey: content-addressed identity. + const contents = ( + text: string | null, + oid: string | null | undefined, + ): FileContents | null => + text === null + ? null + : { name: entry.path, contents: text, cacheKey: oid ?? undefined }; + // The server guarantees ≥ 1 side, so this never sees (null, null). + const fileDiff = parseDiffFromFile( + contents(oldText, entry.oldOid), + contents(newText, entry.newOid), + ); + setState({ kind: "text", fileDiff }); + }).catch((error: unknown) => { + if (!cancelled) setState({ kind: "error", error }); + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [entry.oldOid, entry.newOid, skip, repo.repoId]); + + const size = sizeLabel(entry); + const modeChanged = + entry.oldMode !== undefined && + entry.newMode !== undefined && + entry.oldMode !== entry.newMode; + + return ( +
    +
    +
    + + {entry.path} + {modeChanged && ( + + {entry.oldMode} → {entry.newMode} + + )} +
    + {size !== null && ( + {size} + )} +
    + {gitlink ? ( + + Subproject commit{" "} + {entry.newOid ?? entry.oldOid} + + ) : modeOnly ? ( + File mode changed — contents unchanged + ) : oversize ? ( + Large file not rendered ({size}) + ) : state.kind === "loading" ? ( +
    +
    +
    + ) : state.kind === "binary" ? ( + Binary file not shown{size !== null && ` (${size})`} + ) : state.kind === "error" ? ( + + ) : ( + + )} +
    + ); +}; + +// ── the paged list ────────────────────────────────────────────────────────── + +/** Files rendered before the "Show more" affordance kicks in. */ +const PAGE_SIZE = 25; + +/** Paged list of file-diff cards (resets paging when `files` changes). */ +const FileDiffList = ({ + context, + files, +}: { + context: RepoContext; + files: DiffEntry[]; +}) => { + const [visible, setVisible] = useState(PAGE_SIZE); + useEffect(() => { + setVisible(PAGE_SIZE); + }, [files]); + + return ( +
    +
    + {files.slice(0, visible).map((entry) => ( + + ))} + {files.length === 0 && ( +
    + No changes +
    + )} +
    + {visible < files.length && ( +
    + +
    + )} +
    + ); +}; + +export default FileDiffList; diff --git a/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/pages/FileView.tsx b/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/pages/FileView.tsx new file mode 100644 index 0000000000000000000000000000000000000000..60da00ff15fe3a84c8b6a6986345d1b0e7b7bd87 --- /dev/null +++ b/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/pages/FileView.tsx @@ -0,0 +1,23 @@ +/** + * Syntax-highlighted blob rendering via `@pierre/diffs`' ``. + * + * `React.lazy`-loaded from `Repo.tsx`'s BlobTab so the diffs+shiki + * runtime stays out of the initial bundle. The filename drives language + * detection (by extension); the built-in header is disabled — the tab + * keeps its own lines/bytes bar. + */ +import type { FileContents } from "@pierre/diffs"; +import { File } from "@pierre/diffs/react"; +import { useMemo } from "react"; +import { useFileOptions } from "../diff.tsx"; + +const FileView = ({ name, text }: { name: string; text: string }) => { + const file = useMemo( + () => ({ name, contents: text }), + [name, text], + ); + const options = useFileOptions(); + return ; +}; + +export default FileView; diff --git a/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/pages/Pull.tsx b/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/pages/Pull.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1da67812f1005246c8d606bdf3eeb7e8de9ec862 --- /dev/null +++ b/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/pages/Pull.tsx @@ -0,0 +1,427 @@ +/** + * The pull-request view (`/:owner/:repo/pulls/:number`): GitHub-style + * title/state header, branch line, Markdown body, mergeability box with + * Merge/Close/Reopen actions, and tabbed Commits + Files changed from + * the three-dot `/compare` endpoint. + * + * `React.lazy`-loaded from `Repo.tsx` — the Files-changed tab renders + * through the shared `FileDiffList` (diffs+shiki runtime), which must + * stay out of the initial bundle. + */ +import { useEffect, useState } from "react"; +import { + ApiError, + compareCommits, + getPull, + mergePull, + updatePull, + type Comparison, + type PullDetail, +} from "../client.ts"; +import { + Button, + CopyButton, + ErrorBox, + Markdown, + Spinner, +} from "../components.tsx"; +import { shortOid, subject, timeAgo } from "../format.ts"; +import { href, Link } from "../router.tsx"; +import FileDiffList from "./DiffList.tsx"; +import { PullStateBadge, shortRef } from "./Pulls.tsx"; +import type { RepoContext } from "./Repo.tsx"; + +// ── typed-error presentation ──────────────────────────────────────────────── + +/** Maps the service's typed merge/update errors to inline messages. */ +const friendlyError = (cause: unknown): string => { + if (!(cause instanceof ApiError)) { + return cause instanceof Error ? cause.message : String(cause); + } + switch (cause.tag) { + case "MergeConflict": + return "Merge conflict — files were changed on both branches relative to the merge base. Resolve the conflict locally and push, or close this pull request."; + case "BranchMissing": + return "The base or head branch no longer exists."; + case "NothingToMerge": + return "Nothing to merge — the head branch is already part of the base branch."; + case "RefConflict": + return "A branch moved while merging (force-push or concurrent update) — review the new tip and try again."; + case "PullStateConflict": + return "The pull request's state changed underneath this page — it has been refreshed."; + case "ReadOnlyRepo": + return "This repository is read-only — merging is disabled."; + case "Forbidden": + return "Your token does not have write access to this repository."; + default: + return `${cause.tag}: ${cause.message}`; + } +}; + +// ── mergeability box ──────────────────────────────────────────────────────── + +const mergeStatus = ( + detail: PullDetail, +): { tone: "success" | "danger" | "muted"; text: string } => { + if (detail.mergeable === true) { + return { + tone: "success", + text: + detail.mergeableReason === "ff" + ? "This branch has no conflicts with the base branch — it can be fast-forwarded." + : "This branch has no conflicts with the base branch — a merge commit will be created.", + }; + } + if (detail.mergeable === false) { + return detail.mergeableReason === "up-to-date" + ? { + tone: "muted", + text: "Nothing to merge — the head branch is already part of the base branch.", + } + : { + tone: "danger", + text: "This branch has conflicts with the base branch — the same files were changed on both sides.", + }; + } + return { + tone: "muted", + text: + detail.baseOid === null || detail.headOid === null + ? "Mergeability unknown — the base or head branch no longer exists." + : "Mergeability could not be computed for this pull request.", + }; +}; + +// ── the page ──────────────────────────────────────────────────────────────── + +type CompareState = Comparison | "unavailable" | null; // null = loading + +const PullPage = ({ + context, + number, +}: { + context: RepoContext; + number: number; +}) => { + const [detail, setDetail] = useState(null); + const [compare, setCompare] = useState(null); + const [error, setError] = useState(null); + const [tab, setTab] = useState<"files" | "commits">("files"); + const [actionError, setActionError] = useState(null); + const [busy, setBusy] = useState(false); + const [generation, setGeneration] = useState(0); + const { connection, repo } = context; + const signedIn = context.user !== null; + + useEffect(() => { + let cancelled = false; + setDetail(null); + setCompare(null); + setError(null); + void (async () => { + try { + const pull = await getPull(connection, repo.owner, repo.name, number); + if (cancelled) return; + setDetail(pull); + // Live compare only when both branches still exist (merged PRs + // return null tips — their record is the merge commit). + if (pull.baseOid !== null && pull.headOid !== null) { + const comparison = await compareCommits( + connection, + repo.owner, + repo.name, + { base: pull.baseRef, head: pull.headRef }, + ); + if (!cancelled) setCompare(comparison); + } else { + setCompare("unavailable"); + } + } catch (cause) { + if (!cancelled) setError(cause); + } + })(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [number, repo.repoId, generation]); + + const act = (run: () => Promise) => { + setBusy(true); + setActionError(null); + void run() + .then(() => setGeneration((value) => value + 1)) + .catch((cause: unknown) => { + setActionError(friendlyError(cause)); + // The state/tips this page shows are stale — reload them so the + // message matches what the user now sees. + if ( + cause instanceof ApiError && + (cause.tag === "PullStateConflict" || cause.tag === "RefConflict") + ) { + setGeneration((value) => value + 1); + } + }) + .finally(() => setBusy(false)); + }; + + if (error != null) return ; + if (detail === null) return ; + + const { owner, name } = repo; + const status = mergeStatus(detail); + const boxTone = + status.tone === "success" + ? "border-success/40 bg-success/5" + : status.tone === "danger" + ? "border-danger/40 bg-danger/5" + : "border-border-muted bg-canvas-subtle"; + + return ( +
    + {/* header */} +
    +

    + {detail.title}{" "} + #{detail.number} +

    +
    + + {detail.state === "merged" ? ( + + merged {timeAgo(detail.mergedAt ?? detail.updatedAt)} + {detail.mergeCommit !== null && ( + <> + {" as "} + + {shortOid(detail.mergeCommit)} + + + )} + {" — "} + {shortRef(detail.headRef)} + {" into "} + {shortRef(detail.baseRef)} + + ) : ( + + wants to merge{" "} + + {shortRef(detail.headRef)} + {" "} + into{" "} + + {shortRef(detail.baseRef)} + + {" · opened "} + {timeAgo(detail.createdAt)} + {detail.aheadBy !== null && detail.behindBy !== null && ( + <> + {" · "} + {detail.aheadBy} commit{detail.aheadBy === 1 ? "" : "s"}{" "} + ahead, {detail.behindBy} behind + + )} + + )} +
    +
    + + {/* body */} + {detail.body !== null && detail.body.trim().length > 0 && ( +
    + +
    + )} + + {/* mergeability + actions */} + {detail.state === "open" && ( +
    +

    + {status.text} +

    + {signedIn && ( +
    + + + {detail.mergeable !== true && ( + + merging is disabled until the branch is mergeable + + )} +
    + )} + {actionError !== null && ( +

    {actionError}

    + )} +
    + )} + {detail.state === "closed" && ( +
    +

    + This pull request is closed without being merged. +

    + {signedIn && ( +
    + +
    + )} + {actionError !== null && ( +

    {actionError}

    + )} +
    + )} + + {/* compare: commits + files changed */} + {compare === null ? ( + + ) : compare === "unavailable" ? ( +
    + {detail.state === "merged" && detail.mergeCommit !== null ? ( + <> + Live comparison is not available for merged pull requests — see + the{" "} + + merge commit + + . + + ) : ( + "Comparison unavailable — the base or head branch no longer exists." + )} +
    + ) : ( + <> +
    + {( + [ + ["files", `Files changed (${compare.files.length})`], + ["commits", `Commits (${compare.commits.length})`], + ] as const + ).map(([key, label]) => ( + + ))} +
    + + {tab === "commits" ? ( +
      + {compare.commits.map((commit) => ( +
    • +
      + + {subject(commit.message)} + +

      + {commit.author.name} committed{" "} + {timeAgo(commit.author.date * 1000)} +

      +
      +
      + + {shortOid(commit.oid)} + + +
      +
    • + ))} + {compare.commits.length === 0 && ( +
    • + No commits — the head branch is not ahead of the base. +
    • + )} + {compare.commitsTruncated && ( +
    • + Commit list truncated by the server. +
    • + )} +
    + ) : ( + <> + {compare.filesTruncated && ( +
    + This pull request changes more files than shown — the list was + truncated at {compare.files.length} files by the server. +
    + )} + + + )} + + )} +
    + ); +}; + +export default PullPage; diff --git a/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/pages/Pulls.tsx b/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/pages/Pulls.tsx new file mode 100644 index 0000000000000000000000000000000000000000..741939f253676083e1776122efb726601373c46d --- /dev/null +++ b/.repos/alchemy-effect/examples/cloudflare-git-service/src/ui/pages/Pulls.tsx @@ -0,0 +1,326 @@ +/** + * The Pull requests tab (`/:owner/:repo/pulls`): GitHub-style list with + * open/closed/merged filter tabs and a "New pull request" form + * (base/head branch selectors from the refs advertisement) for + * signed-in users. + * + * Deliberately free of `@pierre/diffs` imports — this module is statically + * imported by `Repo.tsx` and lives in the initial bundle; the diff-heavy + * detail page (`Pull.tsx`) is a separate lazy chunk. + */ +import { useEffect, useState } from "react"; +import { createPull, listPulls, type Pull, type PullState } from "../client.ts"; +import { + Button, + ErrorBox, + GitMergeIcon, + Input, + PullClosedIcon, + PullRequestIcon, + Spinner, +} from "../components.tsx"; +import { timeAgo } from "../format.ts"; +import { href, Link, useRouter } from "../router.tsx"; +import type { RepoContext } from "./Repo.tsx"; + +// ── shared PR presentation atoms (also used by Pull.tsx) ──────────────────── + +/** `refs/heads/main` → `main` (display only; API calls keep full names). */ +export const shortRef = (ref: string): string => + ref.replace(/^refs\/(heads|tags)\//, ""); + +/** The octicon for a PR state (uncolored — callers pick the tone). */ +export const PullStateGlyph = ({ + state, + className, +}: { + state: PullState; + className?: string; +}) => + state === "open" ? ( + + ) : state === "merged" ? ( + + ) : ( + + ); + +/** Foreground tone matching a PR state (list rows, inline glyphs). */ +export const stateTone: Record = { + open: "text-success", + merged: "text-done", + closed: "text-danger", +}; + +/** GitHub-style filled state pill: green Open, purple Merged, red Closed. */ +export const PullStateBadge = ({ state }: { state: PullState }) => ( + + + {state === "open" ? "Open" : state === "merged" ? "Merged" : "Closed"} + +); + +// ── list row ──────────────────────────────────────────────────────────────── + +const PullRow = ({ context, pull }: { context: RepoContext; pull: Pull }) => { + const { owner, name } = context.repo; + return ( +
  • + +
    + + {pull.title} + +

    + #{pull.number}{" "} + {pull.state === "merged" + ? `merged ${timeAgo(pull.mergedAt ?? pull.updatedAt)}` + : pull.state === "closed" + ? `closed ${timeAgo(pull.updatedAt)}` + : `opened ${timeAgo(pull.createdAt)}`}{" "} + · {shortRef(pull.baseRef)} + {" ← "} + {shortRef(pull.headRef)} +

    +
    +
  • + ); +}; + +// ── new pull request form ─────────────────────────────────────────────────── + +const BranchSelect = ({ + label, + value, + onChange, + branches, +}: { + label: string; + value: string; + onChange: (value: string) => void; + branches: string[]; +}) => ( + +); + +const NewPullForm = ({ + context, + onCreated, +}: { + context: RepoContext; + onCreated: (pull: Pull) => void; +}) => { + const branches = context.refs.refs + .filter((ref) => ref.name.startsWith("refs/heads/")) + .map((ref) => shortRef(ref.name)); + const defaultBase = branches.includes(context.repo.defaultBranch) + ? context.repo.defaultBranch + : (branches[0] ?? ""); + const [base, setBase] = useState(defaultBase); + const [head, setHead] = useState( + branches.find((branch) => branch !== defaultBase) ?? defaultBase, + ); + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const { connection, repo } = context; + + const submit = async () => { + setBusy(true); + setError(null); + try { + const pull = await createPull(connection, repo.owner, repo.name, { + title: title.trim(), + base, + head, + ...(body.trim() ? { body: body.trim() } : {}), + }); + onCreated(pull); + } catch (cause) { + setError(cause); + } finally { + setBusy(false); + } + }; + + return ( +
    { + event.preventDefault(); + void submit(); + }} + > +
    + + ← + + {head === base && ( + + choose two different branches + + )} +
    + +