diff --git a/.agents/skills/ios-debugger-agent/LICENSE b/.agents/skills/ios-debugger-agent/LICENSE deleted file mode 100644 index 0d193abe04f2..000000000000 --- a/.agents/skills/ios-debugger-agent/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) OpenAI - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/.agents/skills/ios-debugger-agent/SKILL.md b/.agents/skills/ios-debugger-agent/SKILL.md deleted file mode 100644 index 8d204ff201bc..000000000000 --- a/.agents/skills/ios-debugger-agent/SKILL.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -name: ios-debugger-agent -description: Build, launch, inspect, and drive iOS apps with the repository-configured XcodeBuildMCP server. Use on macOS for iOS Simulator builds, focused native test runs, semantic UI automation, screenshots, logs, or debugging, including T3 Code Mobile verification. ---- - -# iOS Debugger Agent - -Use the repository-configured `xcodebuildmcp` tools instead of requiring a globally installed Codex plugin. Prefer MCP tools over raw `xcodebuild`, `xcrun`, or `simctl` when the client exposes them. - -## Confirm availability - -This workflow requires macOS 14.5 or newer, Xcode 16 or newer, and Node.js 18 or newer. The repository pins XcodeBuildMCP in both `.mcp.json` for Claude Code and `.codex/config.toml` for Codex. Project MCP servers may require one-time trust or approval followed by a new session. - -If the tools are missing: - -1. Confirm the repository is trusted and its project MCP server was approved. -2. Restart or recreate the agent session after approving configuration. -3. Run `npx --yes xcodebuildmcp@2.6.2 doctor` when the server starts but simulator or UI-automation tools are unavailable. Follow its actionable Xcode or AXe setup guidance. -4. Fall back to the pinned XcodeBuildMCP CLI or native Apple CLIs only when the current agent client cannot expose project MCP tools. - -Do not ask contributors to install the OpenAI `build-ios-apps` plugin globally. - -## Establish one simulator context - -1. Call `session_show_defaults` before discovery, build, launch, or UI work. -2. Call `list_sims` and select one explicit simulator UDID. Prefer a simulator that is already booted; boot an installed simulator when verification requires it, but do not create or download runtimes without user authorization. -3. Call `session_set_defaults` with the project or workspace, scheme, Debug configuration, simulator ID, and bundle identifier when known. -4. Keep every subsequent build, launch, screenshot, log capture, and UI action pinned to that same UDID. - -Avoid generic Mac window automation for switching among Simulator windows. Explicit device identity is more reliable. - -## Choose build or launch - -For T3 Code Mobile, run `node scripts/mobile-native-client.ts ensure ios ` from the checkout on the simulator host first. It checks the local Expo native fingerprint against the installed client and builds/installs when stale, missing, or unknown. Then launch with the intended Metro bundle. Authorized verification includes native builds and installs; do not stop because the existing client is old. Use `check` instead of `ensure` only when the user explicitly prohibits rebuilding or requests a read-only check. - -- Use `build_run_sim` when native source, native dependencies, entitlements, or project configuration changed. -- Use `test_sim` for the smallest relevant native test target or test cases; do not run an entire workspace test matrix routinely. -- Use `launch_app_sim` when a compatible app is already installed and no native rebuild is needed. -- To reuse an existing build artifact, use `get_sim_app_path` or `get_app_bundle_id`, install it with `install_app_sim` when necessary, and then launch it. -- Do not run a build-only action immediately before `build_run_sim` unless the task requires both artifacts. - -After launch, call `snapshot_ui` or `screenshot` before interacting. An open Simulator window alone is not evidence that the intended app launched. - -## Drive the UI semantically - -1. Call `snapshot_ui` to obtain the current accessibility hierarchy and element references. -2. Use only current `elementRef` values whose snapshot entries list the intended action. XcodeBuildMCP `2.6.2` does not accept coordinates for `tap`; when the app exposes no actionable reference, prefer a registered deep link or another app-supported route and otherwise report the accessibility blocker. -3. Refresh with `snapshot_ui` after navigation or layout changes. Element references are snapshot-specific. -4. Use `wait_for_ui` for asynchronous transitions when available rather than fixed sleeps. -5. Capture a final `screenshot` for the state that proves the affected flow. - -Use `gesture` or scoped swipe actions when needed. If a gesture is unreliable, return to a known route or relaunch rather than switching to generic desktop automation. - -## Capture logs and debug - -- Use `start_sim_log_cap` and `stop_sim_log_cap` with the exact bundle identifier for focused runtime logs. -- Use debugger tools only when the task requires runtime diagnosis; attach to the selected simulator and app rather than an ambiguous process. -- Summarize relevant errors instead of returning unbounded logs. - -## Clean up - -Stop only log captures, debugger sessions, apps, or simulators started for the current test. Leave pre-existing simulators and unrelated sessions alone. - -## Upstream - -Adapted from OpenAI's [`build-ios-apps`](https://github.com/openai/plugins/tree/main/plugins/build-ios-apps) plugin version `0.1.2` (`ios-debugger-agent`, MIT) and aligned with XcodeBuildMCP `2.6.2` tool names. diff --git a/.agents/skills/ios-debugger-agent/agents/openai.yaml b/.agents/skills/ios-debugger-agent/agents/openai.yaml deleted file mode 100644 index 094bae6620be..000000000000 --- a/.agents/skills/ios-debugger-agent/agents/openai.yaml +++ /dev/null @@ -1,9 +0,0 @@ -interface: - display_name: "iOS Debugger Agent" - short_description: "Build and drive iOS Simulator apps" - default_prompt: "Use $ios-debugger-agent to build, launch, and inspect the current iOS app on Simulator." -dependencies: - tools: - - type: "mcp" - value: "xcodebuildmcp" - description: "Repository-configured Xcode build, simulator, logging, debugging, and semantic UI tools" diff --git a/.agents/skills/ios-simulator-browser/LICENSE b/.agents/skills/ios-simulator-browser/LICENSE deleted file mode 100644 index 0d193abe04f2..000000000000 --- a/.agents/skills/ios-simulator-browser/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) OpenAI - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/.agents/skills/ios-simulator-browser/SKILL.md b/.agents/skills/ios-simulator-browser/SKILL.md deleted file mode 100644 index 74b97530799c..000000000000 --- a/.agents/skills/ios-simulator-browser/SKILL.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -name: ios-simulator-browser -description: Stream an explicit iOS Simulator through pinned serve-sim into the T3 Code in-app browser or another agent browser. Use on Apple Silicon macOS when the user should watch simulator verification live or when browser-visible simulator evidence is needed. ---- - -# iOS Simulator Browser - -Use serve-sim as the shared visual feed for an iOS Simulator. Use `ios-debugger-agent` and XcodeBuildMCP semantic UI tools to drive the app; do not treat browser-canvas coordinates as a substitute for missing app accessibility. - -## Confirm availability - -serve-sim `0.1.45` requires Apple Silicon macOS, Xcode command-line tools, and Node.js 20 or newer. If the host is unsupported, continue with XcodeBuildMCP screenshots and report that live streaming was unavailable. - -When running inside T3 Code, use its product-native browser MCP to open the stream. Other agent hosts may use their own browser or preview surface. - -Keep serve-sim on its default `127.0.0.1` binding. Do not expose its preview to a LAN or tunnel unless the user explicitly requests that access and the network is trusted; the preview includes a token-gated shell-execution route. - -## Start one owned stream - -1. Obtain the exact simulator UDID from the iOS build or launch workflow. -2. Check whether an existing serve-sim stream for that UDID belongs to another task. Reuse it only when explicitly shared; never kill another task's stream. -3. Otherwise, clear only a stale stream for that UDID and start the pinned version with scoped cleanup: - - ```bash - SIMULATOR_ID= - cleanup_serve_sim() { - npx --yes serve-sim@0.1.45 --kill "$SIMULATOR_ID" >/dev/null 2>&1 || true - } - trap cleanup_serve_sim EXIT INT TERM HUP - cleanup_serve_sim - npx --yes serve-sim@0.1.45 "$SIMULATOR_ID" - ``` - -4. Keep the terminal alive and open the exact local URL printed by serve-sim in the agent's browser. -5. Verify that a live simulator frame renders. A loaded wrapper page is not sufficient evidence. - -## Observe while driving semantically - -- Let the user watch the serve-sim stream while XcodeBuildMCP performs `snapshot_ui`, semantic taps, typing, gestures, and screenshots. -- Keep the browser and Xcode tooling pinned to the same simulator UDID. -- Do not switch to generic desktop automation or browser-canvas clicking merely because the stream is visible. - -If the in-app browser explicitly reports that previews are unavailable, do not install unrelated browser automation. Continue through XcodeBuildMCP, capture a simulator screenshot, report the unavailable live stream, and clean up the owned serve-sim process. - -## Finish - -Stop the long-running terminal and wait for its cleanup trap to finish. If it disappeared without cleanup, run `npx --yes serve-sim@0.1.45 --kill ` for that exact simulator. Never run an unscoped `--kill`. - -## Upstream - -Adapted from OpenAI's [`build-ios-apps`](https://github.com/openai/plugins/tree/main/plugins/build-ios-apps) plugin version `0.1.2` (`ios-simulator-browser`, MIT). It invokes serve-sim `0.1.45` under its Apache-2.0 license without vendoring the package. diff --git a/.agents/skills/ios-simulator-browser/agents/openai.yaml b/.agents/skills/ios-simulator-browser/agents/openai.yaml deleted file mode 100644 index 9e8cdf7a16b0..000000000000 --- a/.agents/skills/ios-simulator-browser/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "iOS Simulator Browser" - short_description: "Stream iOS Simulator in the browser" - default_prompt: "Use $ios-simulator-browser to stream the selected iOS Simulator into the T3 Code in-app browser." diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index 0e11b50e1c83..06fe9867b15a 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -1,90 +1,41 @@ --- name: test-t3-app -description: Launch, retain, and test the T3 Code web app in isolated development environments, including first-try browser authentication with one-time pairing URLs, pairing-token recovery, worktree-safe state directories, cross-turn dev server lifecycle, and direct SQLite inspection or fixture seeding. Use when an agent needs to run T3 locally, iteratively test UI behavior with a human, recover from an expired or consumed pairing token, isolate dev state, or prepare test data in state.sqlite. +description: Test T3 Code's web and desktop UI through its built-in Browser panel against isolated development state. Use for browser verification, browser pairing recovery, and test fixtures. Use test-t3-mobile for native mobile verification. --- -# Test T3 App +# Test T3 web and desktop -Use this skill for the web client. For iOS Simulator, Android Emulator, or physical-device testing against an isolated T3 backend, use the sibling [`test-t3-mobile`](../test-t3-mobile/SKILL.md) skill. +Use T3's built-in Browser panel for verification. If its tools are absent or +the panel reports unavailable, explain the blocker and stop verification. +Do not install or switch to another automation system. For native mobile +testing, use [test-t3-mobile](../test-t3-mobile/SKILL.md). -## Start an isolated web environment +## Start the app -1. Run commands from the repository root. -2. Choose a base directory that belongs only to the current worktree or test: - - Use the repository's ignored `.t3` directory for reusable worktree-local state. - - Use `mktemp -d /tmp/t3code-test.XXXXXX` for disposable state and retain the printed absolute path. -3. Start the full web stack with `vp run dev`. Add `--share` when the user needs to open it from another tailnet device. In a linked worktree it defaults to that worktree's gitignored `.t3`; pass `--home-dir ` only when the test needs a different isolated directory. -4. Keep the terminal session alive and read the selected server port, web port, base directory, and pairing URL from its output. +Reuse this task's healthy dev server. Otherwise run `vp run dev` from the +repository root and retain its terminal session. Use the worktree's ignored +`.t3` state and read the actual ports and pairing URL from the dev-runner output. +Never run against `~/.t3/userdata` or set `VITE_HTTP_URL` or `VITE_WS_URL`. -Treat a base directory as disposable only when it was created or deliberately selected for the current test. Never delete or directly seed the shared `~/.t3` directory. Prefer starting with a new temporary base directory over clearing state of uncertain ownership. +Test with meaningful project and thread data. Read +[references/sqlite-fixtures.md](references/sqlite-fixtures.md) only when +inspecting or seeding SQLite. Stop the test server before direct fixture writes. -The worktree-local default deliberately outranks an ambient `T3CODE_HOME`; do not pass the shared home through to a worktree dev server. +## Use the Browser panel -Ports are derived from the worktree path but can shift when occupied. Always read the actual values from the `[dev-runner]` line. +Call `preview_status`, then `preview_open` if the Browser panel is +closed. Navigate to the complete startup pairing URL once with +`preview_navigate`, then use `preview_snapshot` and T3's interaction tools. +If the token was consumed or expired, run `node apps/server/src/bin.ts pair` +for a fresh one. Keep using the same tab. -Shared browser dev is single-origin: Vite proxies the backend paths, so never set `VITE_HTTP_URL` or `VITE_WS_URL` for `dev`/`dev:web`. +## Verify and retain -The dev runner disables browser auto-open by default. Do not pass `--browser` during automated testing: an automatically opened page can consume the one-time bootstrap token before the controlled browser uses it. +Exercise the affected flow and capture the state that proves it works. Keep +the server, state, and panel available while the user inspects or iterates. +An assistant turn ending is not teardown. Stop only processes you started, +using retained terminal sessions or captured PIDs. -### Verify a shared environment before human handoff - -When another person will use the printed pairing URL, first open the shared origin without the pairing path or fragment in the controlled browser and confirm the T3 Code app loads. This browser navigation is required even when curl succeeds because browsers block some otherwise reachable ports before making a network request. - -Do not open the other person's complete pairing URL during this reachability check; doing so consumes its one-time token. If the agent also needs an authenticated browser, create and consume a separate pairing token, then leave a fresh token for the other person. - -## Preserve the environment while iterating - -Treat the overall testing or implementation loop—not an assistant turn or one verification pass—as the environment lifecycle boundary. - -- Keep the dev process, base directory, selected ports, authenticated browser tab, registered projects, and seeded fixtures alive while the user may inspect the result or request follow-up changes. -- Do not stop the server merely because one verification pass completed or because you are yielding a response to the user. -- Before starting another environment, check whether the existing process and browser tab still serve the task. Reuse them when healthy instead of discarding useful state. -- On a later turn, verify that the existing process is alive and reuse its printed ports and base directory. If it exited, restart with the same base directory; create a new pairing token only when the browser session is no longer valid. -- Tell the user when a test environment remains available, including its non-secret web URL when useful. Include a pairing token only when the user still needs to pair (see below). - -## Authenticate the browser on the first navigation - -1. Wait for the server log that says authentication is required and includes a URL ending in `/pair#token=...`. -2. Use the controlled in-app browser or browser-automation surface available to the agent. Do not use a system-browser launch command during automated testing. -3. Open that complete URL exactly once as the controlled browser's first navigation. Preserve the fragment and token verbatim. -4. Wait for the pairing exchange and redirect to finish before navigating elsewhere. -5. Continue in the same browser context so its stored bearer session remains available. - -Keep pairing URLs out of screenshots, committed files, and durable logs. When the user asked for a shared environment, the deliverable IS the full pairing URL — paste it in your reply, token and all; a bare origin is useless to them. A pairing token is short-lived and single-use; opening the URL in another browser or opening it twice can consume it, so never open a URL you handed to the user. - -## Recover a consumed or expired pairing token - -Run `node apps/server/src/bin.ts pair` from the repository root. It discovers the running dev server (worktree `.t3` first, same precedence as the dev runner) and prints a fresh `Pair URL` against the server's current web origin, including a `--share` tailnet origin. Pass `--base-dir ` only when the server was started with `--home-dir`, using the identical path. - -Tokens from `pair` carry standard client scopes. The startup pairing URL carries admin scopes; if the user needs Settings → Connections management (`access:write`), restart the server and hand over the new startup URL instead. - -## Inspect or seed SQLite state - -Read [references/sqlite-fixtures.md](references/sqlite-fixtures.md) before changing the database. - -- Use `node apps/server/scripts/t3-sqlite-state.ts query` for schema discovery and read-only checks. -- Stop the dev server before using `node apps/server/scripts/t3-sqlite-state.ts exec`, then restart it with the same base directory. -- Seed projection tables only for disposable UI fixtures. Use application commands and APIs when testing business behavior or projection correctness. -- Use the auth CLI, not direct `auth_*` table edits, for pairing and sessions. - -The helper refuses to write to the shared `~/.t3` directory by default and creates a database backup before each mutation. - -## Tear down only when the testing loop is finished - -Tear down when the user explicitly asks, confirms the iteration is finished, or the overall task is genuinely complete with no pending human review. Do not infer completion from the end of an assistant turn. - -When teardown is appropriate: - -1. Stop the dev process with its terminal interrupt. -2. Preserve the isolated base directory when it contains useful reproduction evidence or state for a likely follow-up. -3. Otherwise remove only a path created for this test after resolving and verifying the exact target. - -If completion is uncertain, keep the environment alive and mention that it is retained for further iteration. A fresh isolated base directory remains the safest reset when authentication, migrations, or fixture state becomes ambiguous. - -## Troubleshoot predictably - -- If the browser shows an unauthenticated pairing screen, issue a new token instead of retrying the consumed URL. -- If the pairing URL is no longer visible, create a replacement token with both `--dev-url` and `--base-url`. -- If the replacement token is rejected, verify that the CLI and server use the identical absolute base directory and web URL. -- If the UI shows unexpected data, verify that every command uses the identical explicit base directory before editing anything. -- If ports move because another instance is running, trust the current dev-runner output rather than assuming ports `13773` and `5733`. +When sharing is requested, start with `vp run dev --share` and give the user +a fresh complete pairing URL that you have not consumed. Keep other credentials +out of screenshots, commits, and replies. diff --git a/.agents/skills/test-t3-app/agents/openai.yaml b/.agents/skills/test-t3-app/agents/openai.yaml index 0445ee4cb9ec..2f161a27ee0b 100644 --- a/.agents/skills/test-t3-app/agents/openai.yaml +++ b/.agents/skills/test-t3-app/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "T3 App Testing" - short_description: "Launch and retain isolated T3 test environments" - default_prompt: "Use $test-t3-app to launch an isolated T3 environment and iteratively test it in the browser while preserving state." + short_description: "Test web and desktop in T3's Browser panel" + default_prompt: "Use $test-t3-app to verify the affected web or desktop flow through T3's Browser panel." diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index afebcb7aa7d5..1222449cd8a6 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -1,190 +1,65 @@ --- name: test-t3-mobile -description: Launch and test T3 Code Mobile on an iOS Simulator or Android Emulator against disposable local T3 environments, including Metro and dev-client reuse, native rebuild decisions, per-client pairing, seeded projects, semantic UI control, screenshots, and iOS serve-sim streaming. Use after mobile UI or native changes, when reproducing phone or tablet behavior, pairing an emulator to isolated state, or verifying mobile behavior on macOS, Linux, or Windows. +description: Test T3 Code's native iOS and Android app through its Device panel and returned AgentDevice command. Use for mobile verification, native-client builds, Metro launch, and mobile pairing against isolated development state. --- # Test T3 Mobile -Run one focused, end-to-end mobile verification pass against disposable T3 state. Use the sibling [`test-t3-app`](../test-t3-app/SKILL.md) skill as the detailed reference for pairing-token semantics and SQLite fixtures. +## Open the device -Command examples use POSIX shell syntax. On Windows, use PowerShell equivalents: set variables with `$env:NAME = "value"`, use an explicit temporary directory from `[System.IO.Path]::GetTempPath()`, and run multiline examples on one line or with PowerShell backticks. Use `$env:ANDROID_HOME\platform-tools\adb.exe` when `adb` is not already on `PATH`. +Call `device_list`, then `device_open` with the selected host and device IDs. +T3 boots the device and shows its live stream in the Device panel. Follow its +returned `quickStart`, using the exact `agentDevice.command` and all `targetArgs` +on every operation. Use `device_screenshot` to inspect the screen. -## Select a viable platform +If T3 device tools or the selected device are unavailable, report the blocker +and stop verification. Do not install or switch to another automation system. -Inspect the host and the affected code before launching processes: +## Use an isolated backend -- On macOS with Xcode, prefer one representative iOS Simulator when the change is cross-platform so the user can watch through serve-sim. Load and follow [`ios-debugger-agent`](../ios-debugger-agent/SKILL.md), and load [`ios-simulator-browser`](../ios-simulator-browser/SKILL.md) when live streaming is available. -- On macOS, Linux, or Windows with the Android SDK, use one Android Emulator when Android is the affected surface or iOS tooling is unavailable. -- When the change is platform-specific, test that platform. When neither platform is viable, report the missing SDK or emulator prerequisite rather than claiming verification. A missing development client is a build step, not a blocker. +Reuse this task's healthy backend. Otherwise run `vp run dev` from the +repository root, retain its terminal session, and read the actual backend port +from the dev-runner output. Use the worktree's ignored `.t3` state. Never run +against `~/.t3/userdata`. The Browser panel is not required for this workflow. -Do not treat unavailable iOS tooling as a blocker when Android is a valid representative target. +Test with meaningful project and thread data. Read the shared +[SQLite fixture reference](../test-t3-app/references/sqlite-fixtures.md) only +when inspecting or seeding SQLite. Stop the test server before fixture writes. -## Ensure a compatible native client +## Launch T3 Code Dev -Authorized mobile verification includes building and installing a development client. A missing, stale, or unknown native client is not a reason to skip verification or leave a PR in draft. Build and install it, then continue. Respect an explicit user instruction not to rebuild; otherwise do not ask for separate permission. - -Run this from the checkout being tested, on the machine that hosts the selected simulator or emulator. Select and boot one explicit iOS UDID or Android emulator serial first: - -```bash -node scripts/mobile-native-client.ts ensure ios -node scripts/mobile-native-client.ts ensure android -``` - -`ensure` compares the checkout's local Expo development fingerprint and the installed app's binary contents against the last successful build record. It reuses a matching client; otherwise it runs a clean prebuild, builds and installs the development app, and records the successful result. It does not start Metro. Start Metro below after it succeeds. On hosts with an `agent-job` requirement, run the entire `ensure` command through that queue. - -For a read-only decision, use `check` in place of `ensure`. Exit 0 means compatible, 2 means build required, and 1 means an operational error. An app installed outside this helper is initially unknown and gets rebuilt once. Records are local to the simulator host under `~/.cache/t3code/native-clients` and work across checkouts. Do not copy records between machines or write them manually. - -A JavaScript-only diff, bundle identifier, app version, or recent install date does not prove native compatibility. Always check the whole checkout. Expo fingerprints are computed locally with `APP_VARIANT=development`; no EAS credentials or cloud build are required. Generated `ios/` and `android/` directories are excluded by `.fingerprintignore`, so edit native source modules or config plugins rather than generated output. - -The development identity is `T3 Code Dev`, bundle/package `com.t3tools.t3code.dev`, scheme `t3code-dev`. If a build fails, investigate the build error and fix the local prerequisites. Report the concrete failure if it cannot be resolved, not “no compatible client.” - -## Start one disposable T3 environment - -Run backend commands from the repository root. Use the ignored, worktree-local `.t3` directory or create a fresh directory with the host OS's temporary-directory mechanism. An explicit base directory stores state in `/userdata`; never point testing at shared `~/.t3` state. - -Seed a small number of meaningful Git projects before starting the backend: - -```bash -node apps/server/src/bin.ts project add \ - --base-dir \ - --title -``` - -Running `project add` before the backend starts gives it exclusive offline database access. If a backend is already running, wait until it is ready so the CLI dispatches through the live server; never run offline mutations concurrently with the server. - -Use direct SQLite mutation only for disposable projection fixtures. Follow `test-t3-app` and stop the backend before writing. - -Start a headless backend after seeding: - -```bash -node apps/server/src/bin.ts serve \ - --host 127.0.0.1 \ - --port \ - --base-dir \ - --no-browser -``` - -Use these client origins: - -- iOS Simulator: `http://127.0.0.1:` -- Android Emulator: `http://10.0.2.2:` -- Physical device: bind the backend to `0.0.0.0` and use the host's reachable LAN origin - -Enter the complete `http://` origin to make the test transport explicit. Bare IP addresses default to HTTP, while bare hostnames default to HTTPS. When testing web and mobile together, run `vp run dev --home-dir --host 127.0.0.1` instead and do not launch a second backend over the same base directory. - -## Start or reuse Metro safely - -Run Metro from `apps/mobile`. - -1. Inspect any process on the intended Metro port and its `/status` response. Reuse it only when it is healthy, belongs to this worktree, and matches `APP_VARIANT=development`, `--dev-client`, and scheme `t3code-dev`. -2. Never kill another worktree's Metro. Use a free explicit port when necessary. -3. Run `vp run dev:client` on the standard port. For another port, retain the complete development identity: - - ```bash - APP_VARIANT=development vp exec expo start \ - --dev-client \ - --scheme t3code-dev \ - --lan \ - --port - ``` - - In PowerShell, set `$env:APP_VARIANT = "development"` first and then run the `vp exec expo start ...` command without the leading assignment. - -4. Open the exact development-client URL for the selected device and confirm the loaded bundle belongs to this worktree and Metro port. - -### iOS launch - -Use `ios-debugger-agent` to select one UDID and set these XcodeBuildMCP session defaults: - -- Workspace: `/apps/mobile/ios/T3CodeDev.xcworkspace` -- Scheme: `T3CodeDev` -- Configuration: `Debug` -- Simulator ID: the selected UDID -- Bundle ID: `com.t3tools.t3code.dev` - -After `ensure` succeeds, open the Metro URL: +From the checkout being tested on the selected device host, run: ```bash -xcrun simctl openurl +node scripts/mobile-native-client.ts ensure ``` -Accept the iOS confirmation prompt and dismiss the developer menu when it obscures the app. +This reuses a matching native client or builds and installs one. Authorized +mobile verification includes that build step unless the user prohibits it. -### Android launch - -Use the emulator serial already checked by `ensure`: - -```bash -adb -s reverse tcp: tcp: -adb -s shell am start -W \ - -a android.intent.action.VIEW \ - -d '' \ - com.t3tools.t3code.dev -``` +Start `vp run dev:client` from `apps/mobile`, or reuse a healthy Metro belonging +to this checkout. Open its printed development-client URL with AgentDevice +`open com.t3tools.t3code.dev ` and all returned target arguments. +The device must be able to reach both Metro and the isolated backend. -Do not start, stop, erase, or reconfigure an emulator owned by another task. Track and later stop only processes owned by this test. +## Pair and verify -## Pair each client once - -Use the bundled helper from the repository root. It issues a fresh credential against the running backend's exact base directory, opens the existing Add Environment route with the credential in an encoded query parameter, and asks that route to connect once: +Use the helper from the repository root, with the returned executable and target +arguments stored in `agent_device_command` and the Bash array +`agent_device_target_args`: ```bash .agents/skills/test-t3-mobile/scripts/pair-client.sh \ - ios - -.agents/skills/test-t3-mobile/scripts/pair-client.sh \ - android -``` - -Run only the command for the selected platform. The helper uses `http://127.0.0.1:` for iOS and `http://10.0.2.2:` for Android. Pass a fifth argument only when testing a non-development URL scheme. - -The helper opens this registered route: - -```text -t3code-dev://connections/new?pairingUrl=&autoConnect=1 + \ + "$agent_device_command" "${agent_device_target_args[@]}" ``` -The Add Environment route owns the behavior: `pairingUrl` prefills its normal host and token inputs, while `autoConnect=1` submits once in development builds and returns to Home after success. Without `autoConnect`, the same route only prefills the form for manual inspection. - -Do not enter pairing hosts or tokens through simulator keyboard automation. Xcode's semantic typer sends HID-style key events through the simulator's active keyboard state, which can corrupt uppercase tokens and punctuation even when the host Mac uses a U.S. input source. The one-shot route is the deterministic pairing path. Use the visible form only as a fallback, and paste credentials rather than typing them character by character. - -Verify the expected seeded projects appear before exercising the affected flow. - -Pairing credentials are secret, short-lived, and single-use. Create a different credential for every simulator, emulator, physical device, or browser. If an attempt fails, issue a new credential rather than retrying the old one. Do not expose tokens in screenshots, commits, or final responses. - -## Drive and observe the affected flow - -### iOS - -Use `snapshot_ui` and current element references from XcodeBuildMCP for taps and typing. Stream the same UDID through `ios-simulator-browser` so the user can watch in T3 Code when the host supports it. Use the stream as a visual feed rather than a reason to switch to fragile browser coordinates. - -### Android - -Prefer semantic Android automation exposed by the current agent host. Otherwise inspect the current hierarchy with `adb shell uiautomator dump`, target stable resource IDs, content descriptions, text, or bounds, and use scoped `adb shell input` actions. Refresh the hierarchy after navigation. Capture the final state with `adb exec-out screencap -p`. - -Android does not use serve-sim. Use a browser-compatible Android mirror when the host already provides one; otherwise return focused emulator screenshots as evidence rather than installing unrelated streaming infrastructure during verification. - -## Verify and clean up - -Exercise only the affected flow on one representative device unless the change specifically concerns platform, OS version, or screen size. Before finishing: - -1. Confirm the app connected to the intended disposable environment instead of merely rendering an empty disconnected state. -2. Capture the relevant final state. -3. Remove the disposable environment from T3 Code Dev. -4. Remove any `adb reverse` rule created for this test with `adb -s reverse --remove tcp:`. -5. Stop only the serve-sim, Metro, backend, emulator, and log processes started by this test. -6. Remove only base directories and temporary Git repositories deliberately created for this test. Preserve them when they contain useful reproduction evidence. - -Keep local verification focused. Do not turn this workflow into a full repository test run. - -## Troubleshoot predictable failures +It issues a fresh credential and opens T3 Code Dev's existing pairing route +through AgentDevice. For a backend on the device host, use +`http://127.0.0.1:` on iOS or `http://10.0.2.2:` +on Android. For a remote backend, use its reachable origin. -- **Old UI or an old error appears:** verify Metro's worktree, variant, URL, and port before diagnosing the app. -- **Metro serves stale or invalid transforms after those checks:** stop the owned Metro process and run `vp run dev:client:reset` once on the standard port. For a custom port, add `--clear` to the complete explicit `expo start` command above. -- **The environment remains empty:** verify the platform-specific HTTP origin, use a fresh token, and confirm project seeding used the identical base directory. -- **A second client cannot pair:** pairing tokens are single-use; issue another token. -- **The pairing form opens but does not connect:** confirm the deep link uses the existing `connections/new` route, includes `autoConnect=1`, and carries a freshly minted encoded `pairingUrl`. -- **Pairing text changes case or punctuation:** do not retry semantic typing. Use `scripts/pair-client.sh`; the simulator keyboard layout and HID input path are not reliable for credentials. -- **iOS semantic actions fail:** set explicit XcodeBuildMCP defaults and refresh with `snapshot_ui`. -- **Android cannot reach Metro:** verify `adb reverse` for the exact Metro port and relaunch the development-client URL. -- **Android cannot reach the backend:** use `10.0.2.2`, not `127.0.0.1`, for the Android Emulator. +Confirm the intended projects appear, exercise the affected flow, and capture +evidence. Retain the app and environment while iterating. At teardown, remove +the disposable connection, close the AgentDevice session, call `device_close`, +and stop only your backend and Metro processes. diff --git a/.agents/skills/test-t3-mobile/agents/openai.yaml b/.agents/skills/test-t3-mobile/agents/openai.yaml index e9518ce9e04a..bda3335f47e5 100644 --- a/.agents/skills/test-t3-mobile/agents/openai.yaml +++ b/.agents/skills/test-t3-mobile/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Test T3 Mobile" - short_description: "Test T3 Code on iOS or Android" - default_prompt: "Use $test-t3-mobile to launch T3 Code against an isolated backend and verify the affected flow on an available simulator or emulator." + short_description: "Test iOS and Android in T3's Device panel" + default_prompt: "Use $test-t3-mobile to verify the affected native mobile flow through T3's Device panel and AgentDevice." diff --git a/.agents/skills/test-t3-mobile/scripts/pair-client.sh b/.agents/skills/test-t3-mobile/scripts/pair-client.sh index 9caa060728ec..6eeb14844a65 100755 --- a/.agents/skills/test-t3-mobile/scripts/pair-client.sh +++ b/.agents/skills/test-t3-mobile/scripts/pair-client.sh @@ -3,29 +3,17 @@ set -euo pipefail usage() { - echo "Usage: $0 [url-scheme]" >&2 + echo "Usage: $0 " >&2 exit 2 } -[[ $# -ge 4 && $# -le 5 ]] || usage +[[ $# -ge 5 ]] || usage -platform="$1" -device_id="$2" -server_port="$3" -base_dir="$4" -url_scheme="${5:-t3code-dev}" - -case "$platform" in - ios) - mobile_origin="http://127.0.0.1:${server_port}" - ;; - android) - mobile_origin="http://10.0.2.2:${server_port}" - ;; - *) - usage - ;; -esac +server_port="$1" +base_dir="$2" +mobile_origin="$3" +agent_device_command="$4" +shift 4 repo_root="$(git rev-parse --show-toplevel)" cd "$repo_root" @@ -35,7 +23,7 @@ if ! pairing_output="$({ --base-dir "$base_dir" \ --base-url "$mobile_origin" \ --ttl 15m \ - --label "agent-mobile-${device_id:0:8}" + --label "agent-mobile" } 2>&1)"; then echo "Could not mint a mobile pairing credential." >&2 exit 1 @@ -47,26 +35,19 @@ if [[ -z "$pairing_url" ]]; then exit 1 fi -deep_link="$(PAIRING_URL="$pairing_url" URL_SCHEME="$url_scheme" node - <<'NODE' +deep_link="$(PAIRING_URL="$pairing_url" node - <<'NODE' const query = new URLSearchParams({ pairingUrl: process.env.PAIRING_URL, autoConnect: "1", }); -process.stdout.write(`${process.env.URL_SCHEME}://connections/new?${query}`); +process.stdout.write(`t3code-dev://connections/new?${query}`); NODE )" -case "$platform" in - ios) - xcrun simctl openurl "$device_id" "$deep_link" - ;; - android) - # adb shell re-joins its arguments and evaluates them through the device - # shell, so the deep link's `?`/`&` must be quoted once more for that shell. - adb -s "$device_id" shell \ - "am start -W -a android.intent.action.VIEW -d '$deep_link' com.t3tools.t3code.dev" \ - >/dev/null - ;; -esac +if ! "$agent_device_command" open com.t3tools.t3code.dev "$deep_link" "$@" \ + >/dev/null 2>&1; then + echo "AgentDevice could not open the pairing route. Check the Device panel and retry with a fresh credential." >&2 + exit 1 +fi echo "Opened the existing Add Environment route with a fresh pairing credential." diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 32d907240b3c..ccfeca966dfa 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -2,3 +2,7 @@ reviews: review_status: false auto_review: enabled: true + path_filters: + # Vendored read-only reference checkouts of upstream Effect and Alchemy + # (see scripts/lib/reference-repos.ts). Nothing imports from them. + - "!.repos/**" diff --git a/.codex/config.toml b/.codex/config.toml deleted file mode 100644 index a6369d240fc7..000000000000 --- a/.codex/config.toml +++ /dev/null @@ -1,9 +0,0 @@ -[mcp_servers.xcodebuildmcp] -enabled = true -required = false -command = "npx" -args = ["--yes", "xcodebuildmcp@2.6.2", "mcp"] -startup_timeout_sec = 20.0 - -[mcp_servers.xcodebuildmcp.env] -XCODEBUILDMCP_ENABLED_WORKFLOWS = "simulator,ui-automation,debugging,logging" diff --git a/.github/scripts/thread-transfer-report.cjs b/.github/scripts/thread-transfer-report.cjs index 94a02b7806dc..264a92b1c8c2 100644 --- a/.github/scripts/thread-transfer-report.cjs +++ b/.github/scripts/thread-transfer-report.cjs @@ -61,7 +61,7 @@ function validateResult(value) { } assertExactKeys(value.scenario, SCENARIO_KEYS, "result.scenario"); - if (value.scenario.id !== "thread-transfer-v1") { + if (!["thread-transfer-v1", "thread-transfer-v2"].includes(value.scenario.id)) { throw new Error("result.scenario.id is not supported"); } for (const key of SCENARIO_KEYS.slice(1)) { @@ -157,7 +157,7 @@ function renderComment(input) { `| ${provider === "codex" ? "Codex" : "Claude"} | ${metric.label} | ${baselineObserved === undefined ? "—" : formatValue(baselineObserved, metric.kind)} | ${formatValue(observed, metric.kind)} | ${formatImpact(observed, baselineObserved, metric.kind)} | ${formatValue(ceiling, metric.kind)} | ${pass ? "✅" : "❌"} |`, ); - if (baseline && baseline.providers[provider].ceiling[metric.key] !== ceiling) { + if (comparable && baseline.providers[provider].ceiling[metric.key] !== ceiling) { ceilingChanges.push( `- ${provider === "codex" ? "Codex" : "Claude"} ${metric.label}: ${formatValue(baseline.providers[provider].ceiling[metric.key], metric.kind)} → ${formatValue(ceiling, metric.kind)}`, ); diff --git a/.github/scripts/thread-transfer-report.test.cjs b/.github/scripts/thread-transfer-report.test.cjs index 4935864e46f0..3c8ab2578a55 100644 --- a/.github/scripts/thread-transfer-report.test.cjs +++ b/.github/scripts/thread-transfer-report.test.cjs @@ -290,3 +290,21 @@ test("preserves a successful result when a same-SHA rerun has no artifact", asyn assert.equal(published, true); assert.equal(updatedComment, false); }); + +test("accepts V2 without comparing it to the V1 scenario", () => { + const current = result(); + current.scenario.id = "thread-transfer-v2"; + current.providers.codex.ceiling = { ...current.providers.codex.ceiling, totalWireBytes: 3000000 }; + assert.equal(validateResult(current), current); + const comment = renderComment({ + current, + baseline: result(), + currentRun: { sha: "bbbbbbbb", conclusion: "success", url: "https://example.com/current" }, + baselineRun: { sha: "aaaaaaaa", matchesBase: true, url: "https://example.com/baseline" }, + }); + assert.match(comment, /fixture changed/); + assert.doesNotMatch(comment, /This PR changes transfer ceilings/); + assert.doesNotMatch(comment, /[+-]\d+\.\d+%/); + current.scenario.id = "unrecognized"; + assert.throws(() => validateResult(current), /not supported/); +}); diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index 54a770cca023..8e2e3be94ff6 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -70,9 +70,11 @@ jobs: - name: Deploy production relay stage if: steps.creds.outputs.configured == 'true' - id: deploy - run: vp run --filter t3code-relay deploy --stage prod --yes --github-output + run: vp run --filter t3code-relay deploy --stage prod --yes --no-input env: + # The PublishClientConfig action writes the client env here instead + # of the repo-root .env; nothing on the runner reads it. + T3CODE_RELAY_CLIENT_CONFIG_ENV: ${{ runner.temp }}/relay-client-config.env CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} PLANETSCALE_API_TOKEN_ID: ${{ secrets.PLANETSCALE_API_TOKEN_ID }} PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }} @@ -86,20 +88,12 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | - const result = "${{ steps.deploy.outputs.result }}"; - const changed = "${{ steps.deploy.outputs.changed }}" === "true"; - const description = changed - ? "Relay production deploy applied infrastructure changes." - : result === "noop" - ? "Relay production deploy was a no-op." - : `Relay production deploy completed with result: ${result}.`; - await github.rest.repos.createCommitStatus({ owner: context.repo.owner, repo: context.repo.repo, sha: context.sha, state: "success", context: "Relay deploy / production", - description, + description: "Relay production deploy completed.", target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, }); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f82e00be7a32..3f6a5d9b333f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -323,16 +323,29 @@ jobs: args: - --filter=t3code-relay... - - id: relay_state + # The deployed stack's outputs, read from Alchemy's state store without + # planning or applying. Redacted values are persisted as + # {"__redacted__": ""}; the token is masked before it is written. + - name: Read production relay tracing config if: steps.creds.outputs.configured == 'true' - name: Read production relay tracing config shell: bash + working-directory: infra/relay run: | - vp run --filter t3code-relay deploy \ - --stage prod \ - --read-state \ - --github-output \ - --github-env-file "$RUNNER_TEMP/relay-client-tracing.env" + set -euo pipefail + output="$(npx alchemy state read T3CodeRelay/prod/output --no-input)" + field() { + jq -er --arg key "$1" '.[$key] | if type == "object" then .__redacted__ else . end | select(. != null and . != "")' <<<"$output" \ + || { echo "Relay stack output is missing $1" >&2; exit 1; } + } + url="$(field clientTracingUrl)" + dataset="$(field clientTracingDataset)" + token="$(field clientTracingToken)" + echo "::add-mask::$token" + { + echo "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=$url" + echo "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=$dataset" + echo "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=$token" + } > "$RUNNER_TEMP/relay-client-tracing.env" - name: Upload relay client tracing config if: steps.creds.outputs.configured == 'true' diff --git a/.macroscope/ignore.md b/.macroscope/ignore.md new file mode 100644 index 000000000000..0541e7407c0c --- /dev/null +++ b/.macroscope/ignore.md @@ -0,0 +1,257 @@ +# Files Macroscope skips during code review and in Check Run Agents. +# This file REPLACES Macroscope's built-in defaults rather than extending them, +# so the defaults are reproduced below (from docs.macroscope.com, Code Review), +# followed by this repository's own entries at the end. +# One glob per line; not Markdown, and excluded from vp fmt for that reason. + +# ---- Macroscope defaults ---- +# === Vendored / dependency directories === +**/.git/** +**/__pycache__/** +**/.pytest_cache/** +**/.mypy_cache/** +**/.ruff_cache/** +**/venv/** +**/.venv/** +**/node_modules/** +**/site-packages/** +**/.pnpm-store/** +**/__Snapshots__/** +**/__snapshots__/** +**/.agents/skills/** +**/.claude/skills/** +**/.github/skills/** +**/bower_components/** +**/jspm_packages/** +**/.next/** +**/.svelte-kit/** +**/.nuxt/** +**/.output/** +**/.vercel/** +**/.angular/** +**/vendor/** +**/_vendor/** +**/third_party/** +**/Pods/** +**/.bundle/** +# === Root-anchored ambiguous directories === +build/** +out/** +env/** +ENV/** +# === Generated / build-output directories (match anywhere) === +**/target/** +**/dist/** +**/generated/** +**/intermediates/** +**/generated_sources/** +**/generated-sources/** +**/generated-src/** +**/src/main/generated/** +# === Minified build output === +**/*.min.js +**/*.min.css +**/*.bundle.js +# === Yarn PnP loader files === +**/.pnp.cjs +**/.pnp.loader.mjs +# === Generated protobuf / codegen files === +**/*_pb.d.ts +**/*_pb.js +**/*.pb.go +**/*_pb2.py +**/*_pb2_grpc.py +**/*_pb2.pyi +**/*.grpc.swift +**/*.pb.swift +**/*.sql.go +**/*.designer.cs +**/*.g.dart +**/*.pb.dart +**/*_pb.rb +**/*.d.ts +**/*.gen.ts +**/*.gen.tsx +**/*.gen.js +**/*.gen.jsx +# === Package manager files === +**/go.mod +**/package.json +**/*.pbxproj +**/*.xcstrings +**/*.strings +**/*.properties +**/pom.xml +**/Package.swift +**/bun.lock +**/.eslintrc +**/.eslintignore +# === Lock / sum files === +**/go.sum +**/package-lock.json +**/pnpm-lock.yaml +**/yarn.lock +**/Package.resolved +# === Images === +**/*.jpg +**/*.jpeg +**/*.png +**/*.gif +**/*.svg +**/*.ico +**/*.webp +**/*.bmp +**/*.tiff +# === Fonts === +**/*.woff +**/*.woff2 +**/*.ttf +**/*.eot +**/*.otf +# === Media === +**/*.mp3 +**/*.mp4 +**/*.wav +**/*.avi +**/*.mov +**/*.mkv +**/*.flac +**/*.ogg +**/*.srt +# === Archives === +**/*.zip +**/*.tar +**/*.gz +**/*.rar +**/*.7z +**/*.bz2 +# === Documents === +**/*.pdf +**/*.doc +**/*.docx +**/*.xls +**/*.xlsx +**/*.ppt +**/*.pptx +# === Data / serialized === +**/*.db +**/*.sqlite +**/*.sqlite3 +**/*.parquet +**/*.avro +**/*.arrow +**/*.npy +**/*.pkl +**/*.jsonl +# === ML models === +**/*.onnx +**/*.tflite +**/*.h5 +**/*.safetensors +# === Compiled / binary === +**/*.exe +**/*.dll +**/*.so +**/*.dylib +**/*.bin +**/*.pyc +**/*.class +**/*.o +**/*.a +**/*.wasm +# === Certificates / keys === +**/*.cer +**/*.pem +**/*.p12 +# === Platform-specific / non-reviewable === +**/*.stringsdict +**/*.snap +**/*.adoc +**/*.arb +**/*.lock +**/*.po +**/*.fbx +**/*.log +**/*.xib +**/*.meta +**/*.kml +**/*.prefab +**/*.eml +**/*.csv +**/*.grpc.reflection +**/*.js.map +# === Go === +**/*_test.go +# === TypeScript / JavaScript === +**/*.test.ts +**/*.test.tsx +**/*.test.js +**/*.test.jsx +**/*.test.mjs +**/*.test.cjs +**/*.test.mts +**/*.test.cts +**/*.spec.ts +**/*.spec.tsx +**/*.spec.js +**/*.spec.jsx +**/*.spec.mjs +**/*.spec.cjs +**/*.spec.mts +**/*.spec.cts +**/*.e2e.ts +**/*.e2e.tsx +**/*.e2e.js +**/*.e2e.jsx +**/*.e2e.mjs +**/*.e2e.cjs +**/*.integration.ts +**/*.integration.tsx +**/*.integration.js +**/*.integration.jsx +**/*.integration.mjs +**/*.integration.cjs +**/__tests__/** +# === Python === +**/test_*.py +**/*_test.py +# === Java / Kotlin === +**/*Test.java +**/*Tests.java +**/*Spec.java +**/*IT.java +**/*ITCase.java +**/*Test.kt +**/*Tests.kt +**/*Spec.kt +**/*IT.kt +**/*ITCase.kt +**/src/test/java/** +**/src/test/kotlin/** +**/src/androidTest/** +**/src/integrationTest/** +# === Swift === +**/*Tests.swift +**/*UITests.swift +**/*Tests/** +**/*UITests/** +# === Rust === +**/tests/*.rs +**/*_test.rs +**/test_*.rs +# === Ruby === +**/*_test.rb +**/*_spec.rb +**/test_*.rb +# === Generic test directories === +**/test/** +**/tests/** +**/spec/** +**/specs/** +**/e2e/** + +# ---- t3code ---- +# Vendored read-only reference checkouts of upstream Effect and Alchemy +# (see scripts/lib/reference-repos.ts). Nothing imports from them; findings +# there belong upstream. +.repos/** diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 213a64995fab..000000000000 --- a/.mcp.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "mcpServers": { - "xcodebuildmcp": { - "command": "npx", - "args": ["--yes", "xcodebuildmcp@2.6.2", "mcp"], - "env": { - "XCODEBUILDMCP_ENABLED_WORKFLOWS": "simulator,ui-automation,debugging,logging" - } - } - } -} diff --git a/.repos/alchemy-effect/.eslintignore b/.repos/alchemy-effect/.eslintignore deleted file mode 100644 index b5e86027d9f7..000000000000 --- 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 1d9523b95f21..ae67a4d31b1c 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 ab54ea70e4fb..000000000000 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 c3ad3acc7a47..000000000000 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 0f828340ae9e..000000000000 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 03fd7eab0080..000000000000 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 86ce81758763..000000000000 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 bfeec314e849..000000000000 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 e516f716ad58..000000000000 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 95d92738f145..000000000000 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 ab54ea70e4fb..000000000000 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 c3ad3acc7a47..000000000000 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 7e1a5feb5e0b..000000000000 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 09bfbaa50dd1..000000000000 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 7cc7126c50a7..000000000000 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 8d426b4c8219..000000000000 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 e516f716ad58..000000000000 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 da7a70bede9e..000000000000 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 ab54ea70e4fb..000000000000 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 c3ad3acc7a47..000000000000 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 7e1a5feb5e0b..000000000000 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 09bfbaa50dd1..000000000000 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 7cc7126c50a7..000000000000 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 07730155d6af..000000000000 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 e516f716ad58..000000000000 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 59a1b8431ee5..000000000000 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 26d13c3da72a..000000000000 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 5f875b8c90ac..000000000000 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 6b5c763d1986..000000000000 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 53f7960488c3..000000000000 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 cf847db631dd..000000000000 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 33a89ed62660..000000000000 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 cc8472a3ccf8..000000000000 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 1291225d0901..000000000000 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 ba957357ffa9..000000000000 --- 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 6a52713180dc..d8625628f522 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 c96e8f4ffaeb..713a97ca9605 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 e378929e58ad..4c3c2842b532 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 000000000000..f674c0605a90 --- /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 dc40cfb30822..000000000000 --- 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 5fb1c7af6fed..f25b7dce13a9 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 3fa72073506a..94a84b816b8a 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 2e022378275e..bf6515b84ed3 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 78497e7e153f..5631a217347b 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 5da7cb59785c..9ffc5dcfc802 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 172915addb70..fd63fee24c2c 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 6f0ec6239877..000000000000 --- 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 c387161983f7..000000000000 --- 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 5ec7dbbbb645..000000000000 --- 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 000000000000..df4fa200daa8 --- /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 c9f5e549cf02..000000000000 --- 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 a2240c43830e..000000000000 --- 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 3772bad5e971..000000000000 --- 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 55d30fb91d81..000000000000 --- 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 52abde8aa9d8..000000000000 --- 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 a22a83b7151b..000000000000 --- 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 17119f8ee6f7..000000000000 --- 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 da4b7fc8c894..000000000000 --- 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 f36b3a4cd307..000000000000 --- 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 39e868f2638c..000000000000 --- 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 99eb50ce1d34..000000000000 --- 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 5aabc40bab62..c580ed95ed63 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 614ac96f6c4c..60add368775e 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 5a63bba5ae11..b65f238cafa5 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 90c666bf65fc..49a51385b0b6 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 49ac99af8688..27cacdac3eaa 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 69db671a0fbf..ad12c471f175 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 2ab88ad7a58c..000000000000 --- 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 000000000000..331985131736 --- /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 000000000000..a73ef12dbc85 --- /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 000000000000..1426a02cb12d --- /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 000000000000..f924dde7dfe3 --- /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 000000000000..5b72b7d88dba --- /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 000000000000..1672253859f3 --- /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 000000000000..cef9599c6dab --- /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 000000000000..635297e872bd --- /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 cb11c5bf1915..000000000000 --- 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 109812650fae..4bced6c54076 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 e7fa22578f81..197e7bfa674d 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 d645777b666e..4967f062ba1f 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 4f1f9566d24c..fbdac8dec03c 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; - -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]; +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, + }, +}); -/** 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 f8fecbc2b0af..aef06ade9a8e 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 52a4461108a2..8ab0f0f540fe 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 a07ea5fcada1..17a30cdda9f1 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 ec028bf9d624..d5e438ea626f 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 2aeb55aea1a7..1bdf425fcc65 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 61dec7531504..5cf07092cc2e 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 2c182a63f405..cfdec2b72c63 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 13cd9d078b02..b07de1b885ed 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 39880fc5c77e..2b8a185c4e73 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 ed68c27416e1..48b5e2f504ac 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 11fcdee26516..a46cf4f37d7e 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 208962dabb7e..a197b0e13008 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 a11b9c67a2d1..84eb15c59782 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 d3aba64986c2..5897b008fac6 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 402be4f5ff4f..486b3ab81e67 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 16fcadf9d6a9..d10546bc413d 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 88c2abd967ad..87a9bd7dad0c 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 d5b5a67e5f9b..d48fd26f62dc 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 22a1fb220ae7..018d9e33cfb1 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 527707f59246..9b089a72cb33 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 54e658df1059..70271292e79c 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 473d763a17c4..ef6b973ecc5c 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 f18b15fdd239..4e1c51957ae4 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 557c557a0f56..94e1a1ca691f 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 e73ea31607d3..0d9ac9044078 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 e1ddf2d10bb9..3d4edef455e3 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 21f0e63d223d..2f191c71ccdf 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 0ba28b7e47bc..774e12ad7154 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 23e9643fb602..d960c493f5e7 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 d341715e78f8..232bc9d7a537 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 725fc5f1a99b..19e3693f1bcb 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 01f70e832e10..39e195b281e3 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 a981da578113..1d685edba03d 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 9f40bd6c57a4..47931f8485fb 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 a4516b4e7cbd..fba312e96114 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 dc62f02022d0..40255c6d8e0f 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 9b1d598a9b90..69d1ad271399 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 0dbfb64bf96a..388deb270083 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 bdf6c0be521e..202d70cbcd97 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 a9c41c5000c1..b8c35e447129 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 2ee326dc4ed4..3349db7ad0dc 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 3d4c118ab659..731980bd73cb 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 dcf824a2e0f8..375f933212db 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 6a0dbcc830eb..0aac1ab56944 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 b49eacf67fd9..2c944244cfac 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 de1bc81be1b5..7a2a55fd1313 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 bbc906f8a723..cfeca6806417 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 dffb6cca1c6f..934966f2750a 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 197cd1db2896..ca9ebc3cce79 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 f925b9e8d699..8063b389667e 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 8d90ee82d43c..e5ba227c4208 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 bb58b866a583..d91367ce9859 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 d1ee300a1576..01f448b28ab0 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 372b27f9eeac..98fb5a6077f1 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 e4159afff3ad..3e4c263e3b79 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 54dcc12734fc..b88c16b85c29 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 1f9b7b6f15d6..2ca1d4b140c7 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 5ea204d4c988..f260bd70bf66 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 8027d7cd2f79..0cdb339dd282 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 a0a0d79c7382..a1d547151ab6 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 f7ba0be81563..3dd6507fd773 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 000000000000..a7e5ff34cfa1 --- /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 000000000000..e74f81ac28c1 --- /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 000000000000..eda73a89ee16 --- /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 000000000000..dd7346754b5f --- /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 000000000000..e17d80400812 --- /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 000000000000..dd03caee8018 --- /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 000000000000..553ae48b6fea --- /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 000000000000..fd6072c40c46 --- /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 000000000000..9eada76e5c6a --- /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 000000000000..b9ab4c979c53 --- /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 000000000000..543696ccef3f --- /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 000000000000..dbc83229f11e --- /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 000000000000..87cd98f1a469 --- /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 000000000000..39c41c2667c1 --- /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 000000000000..e5c64971c96b --- /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 000000000000..509bda649727 --- /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 000000000000..66d92576efc9 --- /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 000000000000..4b3bdbf34b7f --- /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 000000000000..69374119b40b --- /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 000000000000..840c896ff9fd --- /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 000000000000..f04ec7bee3b1 --- /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 000000000000..60da00ff15fe --- /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 000000000000..1da67812f100 --- /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 000000000000..741939f25367 --- /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 + + )} +
    + +