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 7afa579383d9..000000000000 --- a/.agents/skills/ios-debugger-agent/SKILL.md +++ /dev/null @@ -1,64 +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 - -- 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 3fcf94334fd6..1222449cd8a6 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -1,188 +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, emulator, or dev-client prerequisite rather than claiming verification. +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. -## Choose the lightest valid launch path +## Launch T3 Code Dev -- For JavaScript, TypeScript, or asset-only changes, reuse a compatible installed development client and start Metro. Do not rebuild native code merely to load a new bundle. -- For native source, native dependencies, entitlements, config plugins, or generated project changes, rebuild the affected platform. -- Use `vp run ios:dev` or `vp run android:dev` only when an Expo clean prebuild is actually required; both commands regenerate the native project. -- If the user requested no native rebuild and no compatible app is installed, reuse an existing compatible `.app` or `.apk` artifact when available. Otherwise report the missing dev client instead of silently rebuilding. - -The development identity on both platforms is: - -- App: `T3 Code Dev` -- Bundle/package identifier: `com.t3tools.t3code.dev` -- URL scheme: `t3code-dev` - -Bundle or package presence proves the correct variant, not native compatibility. Reuse it only when the current changes did not alter its Expo SDK, native dependencies, config plugins, entitlements, generated project, or native source. - -## 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: +From the checkout being tested on the selected device host, run: ```bash -node apps/server/src/bin.ts serve \ - --host 127.0.0.1 \ - --port \ - --base-dir \ - --no-browser +node scripts/mobile-native-client.ts ensure ``` -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` - -Check the installed client with: - -```bash -xcrun simctl get_app_container com.t3tools.t3code.dev app -xcrun simctl openurl -``` +This reuses a matching native client or builds and installs one. Authorized +mobile verification includes that build step unless the user prohibits it. -Accept the iOS confirmation prompt and dismiss the developer menu when it obscures the app. +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. -### Android launch +## Pair and verify -Select one running emulator serial from `adb devices` and check the installed client: - -```bash -adb -s shell pm path com.t3tools.t3code.dev -adb -s reverse tcp: tcp: -adb -s shell am start -W \ - -a android.intent.action.VIEW \ - -d '' \ - com.t3tools.t3code.dev -``` - -Do not start, stop, erase, or reconfigure an emulator owned by another task. Track and later stop only processes owned by this test. - -## 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/VOUCHED.td b/.github/VOUCHED.td index 988e223d219f..6781356cf58d 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -18,6 +18,7 @@ github:bil0000 github:binbandit github:Brechard github:btsouth +github:cestercian github:chrisdeeming github:chuks-qua github:cursoragent @@ -25,6 +26,7 @@ github:D3OXY github:dbalders github:eggfriedrice24 github:extoci +github:f-trycua github:flamboh github:FllipEis github:gbarros-dev @@ -79,3 +81,4 @@ github:vitalyiegorov github:Yash-Singh1 github:yashranaway github:Ymit24 +github:yordis diff --git a/.github/scripts/check-nightly-release.cjs b/.github/scripts/check-nightly-release.cjs index 82ee40da1aff..377ac36a22a8 100644 --- a/.github/scripts/check-nightly-release.cjs +++ b/.github/scripts/check-nightly-release.cjs @@ -1,5 +1,43 @@ const MINIMUM_RELEASE_GAP_MS = 6 * 60 * 60 * 1000; +function repositoryDefaultBranch(context) { + const branch = context.payload.repository?.default_branch; + if (!branch) { + throw new Error("GitHub did not provide the repository default branch."); + } + return branch; +} + +async function assertCommitOnDefaultBranch({ github, context, sha }) { + const defaultBranch = repositoryDefaultBranch(context); + const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({ + ...context.repo, + basehead: `${sha}...${defaultBranch}`, + per_page: 1, + }); + if (comparison.status !== "ahead" && comparison.status !== "identical") { + throw new Error( + `Release commit ${sha} is not contained in ${defaultBranch} (${comparison.status}).`, + ); + } +} + +async function assertReleaseSource({ github, context, releaseChannel }) { + if (releaseChannel === "preview") return; + if (releaseChannel !== "stable" && releaseChannel !== "nightly") { + throw new Error(`Unsupported release channel: ${releaseChannel}`); + } + + const defaultBranch = repositoryDefaultBranch(context); + if (context.eventName === "workflow_dispatch" && context.ref !== `refs/heads/${defaultBranch}`) { + throw new Error( + `${releaseChannel} releases must be dispatched from ${defaultBranch}; selected ${context.ref}. Use the preview channel for branch builds.`, + ); + } + + await assertCommitOnDefaultBranch({ github, context, sha: context.sha }); +} + const isNightlyTag = (tag) => /^v.*-nightly\./.test(tag) || tag.startsWith("nightly-v"); // Newest published nightly by publication time, or undefined when none exists. @@ -64,4 +102,9 @@ async function resolveLatestNightlyCommit({ github, context, core }) { return { tag, sha: commit.sha, version }; } -module.exports = { shouldReleaseNightly, resolveLatestNightlyCommit }; +module.exports = { + assertCommitOnDefaultBranch, + assertReleaseSource, + shouldReleaseNightly, + resolveLatestNightlyCommit, +}; diff --git a/.github/scripts/check-nightly-release.test.cjs b/.github/scripts/check-nightly-release.test.cjs index 49ade68aeef7..64d8c8d6c0bd 100644 --- a/.github/scripts/check-nightly-release.test.cjs +++ b/.github/scripts/check-nightly-release.test.cjs @@ -1,6 +1,10 @@ const assert = require("node:assert/strict"); const test = require("node:test"); -const { shouldReleaseNightly } = require("./check-nightly-release.cjs"); +const { + assertCommitOnDefaultBranch, + assertReleaseSource, + shouldReleaseNightly, +} = require("./check-nightly-release.cjs"); const now = Date.parse("2026-09-05T12:00:00Z"); const hour = 60 * 60 * 1000; @@ -11,6 +15,77 @@ const nightly = (hoursAgo, overrides = {}) => ({ ...overrides, }); +function releaseSourceFixture({ + comparisonStatus = "ahead", + eventName = "workflow_dispatch", + ref = "refs/heads/main", + sha = "candidate", +} = {}) { + const calls = []; + return { + calls, + options: { + context: { + eventName, + payload: { repository: { default_branch: "main" } }, + ref, + repo: { owner: "example", repo: "app" }, + sha, + }, + github: { + rest: { + repos: { + async compareCommitsWithBasehead(params) { + calls.push(params); + return { data: { status: comparisonStatus } }; + }, + }, + }, + }, + }, + }; +} + +test("allows preview releases from any branch without consulting main", async () => { + const { options, calls } = releaseSourceFixture({ ref: "refs/heads/feature" }); + await assertReleaseSource({ ...options, releaseChannel: "preview" }); + assert.equal(calls.length, 0); +}); + +for (const releaseChannel of ["stable", "nightly"]) { + test(`rejects a manual ${releaseChannel} release from a feature branch`, async () => { + const { options, calls } = releaseSourceFixture({ ref: "refs/heads/feature" }); + await assert.rejects( + assertReleaseSource({ ...options, releaseChannel }), + new RegExp(`${releaseChannel} releases must be dispatched from main`), + ); + assert.equal(calls.length, 0); + }); + + test(`allows a manual ${releaseChannel} release from main`, async () => { + const { options, calls } = releaseSourceFixture(); + await assertReleaseSource({ ...options, releaseChannel }); + assert.equal(calls[0].basehead, "candidate...main"); + }); +} + +for (const comparisonStatus of ["behind", "diverged"]) { + test(`rejects a release commit that is ${comparisonStatus} from main`, async () => { + const { options } = releaseSourceFixture({ comparisonStatus, eventName: "push" }); + await assert.rejects( + assertCommitOnDefaultBranch({ ...options, sha: "release-commit" }), + new RegExp(`not contained in main \\(${comparisonStatus}\\)`), + ); + }); +} + +test("accepts a release commit already contained in main", async () => { + for (const comparisonStatus of ["ahead", "identical"]) { + const { options } = releaseSourceFixture({ comparisonStatus, eventName: "push" }); + await assertCommitOnDefaultBranch({ ...options, sha: "release-commit" }); + } +}); + function fixture({ releases = [nightly(7)], comparisonStatus = "ahead" } = {}) { const calls = []; return { diff --git a/.github/scripts/relay-state-output.test.cjs b/.github/scripts/relay-state-output.test.cjs new file mode 100644 index 000000000000..fcb829dd91e8 --- /dev/null +++ b/.github/scripts/relay-state-output.test.cjs @@ -0,0 +1,76 @@ +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const { mkdtempSync, readFileSync, rmSync, existsSync } = require("node:fs"); +const { tmpdir } = require("node:os"); +const { join } = require("node:path"); +const { test } = require("node:test"); + +const workflow = readFileSync(join(__dirname, "../workflows/release.yml"), "utf8"); +const step = workflow.match( + /- name: Read production relay tracing config\n[\s\S]*? run: \|\n((?: .*\n|\n)+)/, +); +assert.ok(step, "Could not find the relay state workflow step"); +const script = step[1].replace(/^ /gm, ""); +const config = { + clientTracingUrl: "https://example.invalid/traces", + clientTracingDataset: "fixture-dataset", + clientTracingToken: { __redacted__: "fixture-token" }, +}; +const json = JSON.stringify(config, null, 2); + +function runStep(stdout, exitCode = 0) { + const runnerTemp = mkdtempSync(join(tmpdir(), "t3-relay-state-test-")); + try { + const result = spawnSync( + "bash", + ["-c", 'npx() { printf "%s\\n" "$FIXTURE_STDOUT"; return "$FIXTURE_EXIT"; }\n' + script], + { + encoding: "utf8", + env: { + PATH: process.env.PATH, + RUNNER_TEMP: runnerTemp, + FIXTURE_STDOUT: stdout, + FIXTURE_EXIT: String(exitCode), + }, + }, + ); + assert.ifError(result.error); + const envPath = join(runnerTemp, "relay-client-tracing.env"); + return { + ...result, + envFile: existsSync(envPath) ? readFileSync(envPath, "utf8") : undefined, + }; + } finally { + rmSync(runnerTemp, { recursive: true, force: true }); + } +} + +for (const prefix of [ + "", + "• Refreshing Cloudflare State Store credentials\n✓ Refreshing Cloudflare State Store credentials\n", +]) { + test(`extracts tracing config ${prefix ? "after progress output" : "from plain JSON"}`, () => { + const result = runStep(prefix + json); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, "::add-mask::fixture-token\n"); + assert.equal( + result.envFile, + "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=https://example.invalid/traces\n" + + "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=fixture-dataset\n" + + "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=fixture-token\n", + ); + }); +} + +for (const [name, stdout, exitCode] of [ + ["failed CLI even with valid JSON", json, 1], + ["missing JSON", "Refreshing credentials...", 0], + ["malformed JSON", "{not JSON", 0], + ["missing token", JSON.stringify({ ...config, clientTracingToken: null }, null, 2), 0], +]) { + test(`rejects ${name} without writing config`, () => { + const result = runStep(stdout, exitCode); + assert.notEqual(result.status, 0); + assert.equal(result.envFile, undefined); + }); +} diff --git a/.github/scripts/stage-preview-bundle.py b/.github/scripts/stage-preview-bundle.py new file mode 100644 index 000000000000..0b3a53285916 --- /dev/null +++ b/.github/scripts/stage-preview-bundle.py @@ -0,0 +1,76 @@ +"""Stage an untrusted preview ZIP without letting it replace packaging code.""" + +import shutil +import stat +import sys +import zipfile +from pathlib import Path + +ROOTS = ("server/dist", "desktop/dist-electron") +REQUIRED_FILES = { + "server/dist/bin.mjs", + "server/dist/client/index.html", + "desktop/dist-electron/main.cjs", +} +# The current bundle is about 32 MiB compressed. Bound extraction on the +# trusted runner even when the PR replaces the uploader entirely. +MAX_ARCHIVE_BYTES = 512 * 1024 * 1024 +MAX_EXPANDED_BYTES = 2 * 1024 * 1024 * 1024 +MAX_ENTRIES = 50_000 + + +def stage_bundle(archive: Path, destination: Path): + if archive.stat().st_size > MAX_ARCHIVE_BYTES: + raise ValueError("Preview archive is too large") + with zipfile.ZipFile(archive) as bundle: + entries = bundle.infolist() + if len(entries) > MAX_ENTRIES: + raise ValueError("Preview archive has too many entries") + if sum(entry.file_size for entry in entries) > MAX_EXPANDED_BYTES: + raise ValueError("Expanded preview bundle is too large") + seen = set() + files = set() + for entry in entries: + name = entry.filename.removesuffix("/") + parts = name.split("/") + # Reject ambiguous paths before normalization, including names + # that would alias on the macOS signing runner. + if ( + entry.orig_filename != entry.filename + or any(part in ("", ".", "..") for part in parts) + or any(char in name for char in "\\:") + or not name.isascii() + or any(ord(char) < 32 or ord(char) == 127 for char in name) + ): + raise ValueError(f"Unsafe preview path: {entry.filename!r}") + allowed = any(name.startswith(root + "/") for root in ROOTS) + if entry.is_dir(): + allowed |= any(root == name or root.startswith(name + "/") for root in ROOTS) + if not allowed: + raise ValueError(f"Unexpected preview path: {name!r}") + kind = stat.S_IFMT(entry.external_attr >> 16) + if kind not in (0, stat.S_IFDIR if entry.is_dir() else stat.S_IFREG): + raise ValueError(f"Non-regular preview entry: {name!r}") + if name.casefold() in seen: + raise ValueError(f"Duplicate preview path: {name!r}") + seen.add(name.casefold()) + if not entry.is_dir(): + files.add(name) + if not REQUIRED_FILES <= files: + raise ValueError("Preview bundle is missing required entry points") + # Validate all names before writing anything. This is a fresh directory + # outside the checkout; neither pre-existing links nor trusted files + # can be followed or overwritten. ZIP permissions are never restored. + destination.mkdir(parents=True, exist_ok=False) + for entry in entries: + target = destination / entry.filename + if entry.is_dir(): + target.mkdir(parents=True, exist_ok=True) + else: + target.parent.mkdir(parents=True, exist_ok=True) + with bundle.open(entry) as source, target.open("xb") as output: + shutil.copyfileobj(source, output) + + +if __name__ == "__main__": + stage_bundle(Path(sys.argv[1]), Path(sys.argv[2])) diff --git a/.github/scripts/stage-preview-bundle.test.py b/.github/scripts/stage-preview-bundle.test.py new file mode 100644 index 000000000000..5957279c8885 --- /dev/null +++ b/.github/scripts/stage-preview-bundle.test.py @@ -0,0 +1,97 @@ +import importlib.util +import stat +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location( + "stage_preview_bundle", Path(__file__).with_name("stage-preview-bundle.py") +) +staging = importlib.util.module_from_spec(spec) +spec.loader.exec_module(staging) + + +class StagePreviewBundleTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.archive = self.root / "bundle.zip" + self.destination = self.root / "staged" + + def bundle(self, extra=(), missing=None): + with zipfile.ZipFile(self.archive, "w") as bundle: + for name in sorted(staging.REQUIRED_FILES - {missing}): + bundle.writestr(name, b"bundle data, never executed") + for name, content in extra: + bundle.writestr(name, content) + + def stage(self): + staging.stage_bundle(self.archive, self.destination) + + def test_preserves_valid_bundle_layout_and_bytes(self): + self.bundle([("server/", b""), ("server/dist/", b""), + ("desktop/dist-electron/chunks/helper.cjs", b"chunk")]) + self.stage() + for name in staging.REQUIRED_FILES: + self.assertEqual((self.destination / name).read_bytes(), b"bundle data, never executed") + self.assertEqual((self.destination / "desktop/dist-electron/chunks/helper.cjs").read_bytes(), b"chunk") + + def test_rejects_builder_overwrite_and_unsafe_paths_before_writing(self): + for name in [ + "desktop/node_modules/electron-builder/cli.js", + "desktop/package.json", + "server/dist/../../desktop/package.json", + "../package.json", + "/server/dist/absolute", + "server/dist/./alias", + "server/dist//alias", + "server/dist/back\\slash", + "server/dist/file:stream", + "server/dist/BIN.MJS", + ]: + with self.subTest(name=name): + self.bundle([(name, b"untrusted")]) + with self.assertRaises(ValueError): + self.stage() + self.assertFalse(self.destination.exists()) + + def test_rejects_links_and_special_files(self): + for mode in [stat.S_IFLNK, stat.S_IFIFO, stat.S_IFCHR]: + with self.subTest(mode=mode): + entry = zipfile.ZipInfo("server/dist/link") + entry.create_system = 3 + entry.external_attr = (mode | 0o777) << 16 + self.bundle([(entry, b"../../../desktop/node_modules")]) + with self.assertRaises(ValueError): + self.stage() + self.assertFalse(self.destination.exists()) + + def test_requires_entry_points(self): + self.bundle(missing="desktop/dist-electron/main.cjs") + with self.assertRaises(ValueError): + self.stage() + self.assertFalse(self.destination.exists()) + + def test_bounds_archive_size_expanded_size_and_entry_count(self): + for limit in ["MAX_ARCHIVE_BYTES", "MAX_EXPANDED_BYTES", "MAX_ENTRIES"]: + with self.subTest(limit=limit), patch.object(staging, limit, 1): + self.bundle() + with self.assertRaises(ValueError): + self.stage() + self.assertFalse(self.destination.exists()) + + def test_refuses_existing_destination(self): + self.bundle() + self.destination.mkdir() + sentinel = self.destination / "trusted" + sentinel.write_text("untouched") + with self.assertRaises(FileExistsError): + self.stage() + self.assertEqual(sentinel.read_text(), "untouched") + + +if __name__ == "__main__": + unittest.main() 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/ci.yml b/.github/workflows/ci.yml index 19a28f205d86..611d4cf44f75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,9 @@ jobs: sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + - name: Test preview artifact validation + run: python3 -B .github/scripts/stage-preview-bundle.test.py + - name: Test nightly release checks run: node --test .github/scripts/check-nightly-release.test.cjs diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index 25e744a42968..16e5c356219a 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -54,9 +54,11 @@ jobs: - --filter=t3code-relay... - name: Deploy production relay stage - 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 }} @@ -69,20 +71,12 @@ jobs: uses: actions/github-script@v8 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/desktop-macos-preview-publish.yml b/.github/workflows/desktop-macos-preview-publish.yml new file mode 100644 index 000000000000..4d141e327c1f --- /dev/null +++ b/.github/workflows/desktop-macos-preview-publish.yml @@ -0,0 +1,575 @@ +name: Desktop macOS Preview Publish + +# Trusted half of the macOS preview. Runs from main with secrets and a write +# token, so it must never execute PR code: the PR's JS bundle is only data that +# gets packaged into the app. Everything that runs here (packaging, signing, +# notarization, publishing) is main's code. +# +# Gate, in order: the completed build run belongs to an open PR that still +# carries the preview:mac label and whose head is the built commit, and the PR +# author is trusted by the vouch list. A maintainer applying the label alone is +# not enough, since the bundle gets signed with the Developer ID certificate. +# +# The label is consumed here once the gate passes, so it only ever covers the +# one commit a maintainer applied it to. A later push builds nothing until the +# label is applied again. + +on: + workflow_run: + workflows: [Desktop macOS Preview] + types: [completed] + # The way out: closing the PR deletes its download, and removing the label + # before it is consumed cancels the preview. pull_request_target gives this a + # write token for fork PRs; it never checks out PR code. + pull_request_target: + types: [closed, unlabeled] + +permissions: + contents: read + +jobs: + resolve: + name: Verify preview eligibility + if: >- + github.event_name == 'workflow_run' && + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' + # The build workflow completes for every PR push (its label gate is on the + # job), so this runs often and usually finds nothing. Keep it cheap. + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + actions: read + contents: read + # write only to consume the label; nothing here runs PR code. + pull-requests: write + outputs: + eligible: ${{ steps.gate.outputs.eligible }} + pr_number: ${{ steps.pr.outputs.pr_number }} + head_sha: ${{ steps.pr.outputs.head_sha }} + version: ${{ steps.version.outputs.version }} + clerk_publishable_key: ${{ steps.version.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ steps.version.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ steps.version.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ steps.version.outputs.relay_url }} + steps: + - id: pr + name: Resolve the pull request behind the build + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const run = context.payload.workflow_run; + const { owner, repo } = context.repo; + + // The build workflow also completes (with every job skipped) for + // label events that are not the preview label. Only a run that + // produced a bundle is worth resolving. + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner, + repo, + run_id: run.id, + per_page: 100, + }); + const bundles = artifacts.filter((artifact) => artifact.name === "js-bundle" && !artifact.expired); + if (bundles.length !== 1) { + core.info(`Expected one js-bundle artifact; found ${bundles.length}. Skipping.`); + core.setOutput("eligible", "false"); + return; + } + + // workflow_run.pull_requests is empty for fork PRs, so resolve the + // PR from the built commit instead and require exactly one open PR + // from the same head repository and branch. The build baked its + // PR number into the version, so two candidates would mean the + // asset name could belong to either. + const associated = await github.paginate( + github.rest.repos.listPullRequestsAssociatedWithCommit, + { owner, repo, commit_sha: run.head_sha, per_page: 100 }, + ); + const matching = associated.filter( + (candidate) => + candidate.state === "open" && + candidate.head.sha === run.head_sha && + candidate.head.ref === run.head_branch && + candidate.head.repo?.full_name === run.head_repository?.full_name, + ); + if (matching.length !== 1) { + core.info(`Expected one open PR for ${run.head_sha}; found ${matching.length}. Skipping.`); + core.setOutput("eligible", "false"); + return; + } + const { data: pull } = await github.rest.pulls.get({ + owner, + repo, + pull_number: matching[0].number, + }); + + if (pull.state !== "open") { + core.info(`PR #${pull.number} is not open. Skipping.`); + core.setOutput("eligible", "false"); + return; + } + if (pull.head.sha !== run.head_sha) { + core.info(`PR #${pull.number} moved to ${pull.head.sha} after ${run.head_sha} was built. Skipping.`); + core.setOutput("eligible", "false"); + return; + } + if (!pull.labels.some((label) => label.name === "preview:mac")) { + core.info(`PR #${pull.number} no longer carries the preview:mac label. Skipping.`); + core.setOutput("eligible", "false"); + return; + } + + core.setOutput("artifact_id", String(bundles[0].id)); + core.setOutput("eligible", "true"); + core.setOutput("pr_number", String(pull.number)); + core.setOutput("head_sha", pull.head.sha); + core.setOutput("author", pull.user.login); + + # Reads VOUCHED.td from the default branch through the API, so a PR + # cannot vouch for itself. + - id: vouch + name: Check PR author trust + if: steps.pr.outputs.eligible == 'true' + uses: mitchellh/vouch/action/check-user@d66fa29a64600490892131ad87597c30c91fcac4 # v1 + with: + user: ${{ steps.pr.outputs.author }} + allow-fail: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # The label authorized exactly this build, so take it now, before the + # long signing job. Removing it with GITHUB_TOKEN does not fire the + # unlabeled cleanup below (workflow-token events never start runs), so + # the download this run publishes survives. If a maintainer removed the + # label first, that removal wins: the 404 makes this run ineligible. + - id: consume + name: Consume the preview label + if: steps.pr.outputs.eligible == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + with: + script: | + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(process.env.PR_NUMBER), + name: "preview:mac", + }); + core.setOutput("consumed", "true"); + } catch (error) { + if (error.status !== 404) throw error; + core.info("The preview:mac label was removed before this build could consume it. Skipping."); + core.setOutput("consumed", "false"); + } + + - id: gate + name: Decide eligibility + shell: bash + env: + PR_ELIGIBLE: ${{ steps.pr.outputs.eligible }} + LABEL_CONSUMED: ${{ steps.consume.outputs.consumed }} + VOUCH_STATUS: ${{ steps.vouch.outputs.status }} + AUTHOR: ${{ steps.pr.outputs.author }} + run: | + set -euo pipefail + if [[ "$PR_ELIGIBLE" != "true" || "$LABEL_CONSUMED" != "true" ]]; then + echo "eligible=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + case "$VOUCH_STATUS" in + bot|collaborator|vouched) + echo "Author $AUTHOR is trusted ($VOUCH_STATUS)." + echo "eligible=true" >> "$GITHUB_OUTPUT" + ;; + *) + echo "Author $AUTHOR is not vouched ($VOUCH_STATUS). Add them to .github/VOUCHED.td to allow signed previews." + echo "eligible=false" >> "$GITHUB_OUTPUT" + ;; + esac + + # Same inputs as the build workflow, read from the built commit through + # the contents API as data: the desktop manifest's base version plus the + # build run's number reproduces the version baked into the bundle, and + # .env.example holds the public T3 Connect identifiers the bundle was + # compiled with, which the signed app's passkey entitlement must match. + # Both are validated before they reach a file name or an entitlement. + - id: version + name: Resolve preview version and public configuration + if: steps.gate.outputs.eligible == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + BUILD_RUN_NUMBER: ${{ github.event.workflow_run.run_number }} + run: | + set -euo pipefail + head_file() { + gh api "repos/${GITHUB_REPOSITORY}/contents/$1?ref=${HEAD_SHA}" --jq '.content' | base64 --decode + } + + base_version="$(head_file apps/desktop/package.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version")" + # The committed desktop version is always a plain X.Y.Z; every + # prerelease identifier is added by a release run. Anything else + # would also let a foreign -pr.N. marker into the asset name, which + # is what publish and cleanup key on. + if [[ ! "$base_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Unexpected desktop version '$base_version' at $HEAD_SHA; expected X.Y.Z." >&2 + exit 1 + fi + echo "version=${base_version}-pr.${PR_NUMBER}.${BUILD_RUN_NUMBER}" >> "$GITHUB_OUTPUT" + + head_file .env.example > "$RUNNER_TEMP/head.env.example" + for key in clerk_publishable_key:T3CODE_CLERK_PUBLISHABLE_KEY clerk_jwt_template:T3CODE_CLERK_JWT_TEMPLATE clerk_cli_oauth_client_id:T3CODE_CLERK_CLI_OAUTH_CLIENT_ID relay_url:T3CODE_RELAY_URL; do + output="${key%%:*}" + name="${key##*:}" + value="$(sed -n "s/^${name}=//p" "$RUNNER_TEMP/head.env.example" | head -n 1)" + if [[ ! "$value" =~ ^[A-Za-z0-9._:/-]+$ ]]; then + echo "$name is missing or malformed in .env.example at $HEAD_SHA." >&2 + exit 1 + fi + echo "${output}=${value}" >> "$GITHUB_OUTPUT" + done + + # Only the default-branch revision that owns this workflow supplies the + # validator. Never check out the PR in a workflow_run job. + - name: Checkout trusted artifact validator + if: steps.gate.outputs.eligible == 'true' + shell: bash + env: + CHECKOUT_REF: ${{ github.sha }} + GIT_TERMINAL_PROMPT: "0" + # Anonymous fetch avoids checkout's credential cleanup, which fails on + # orphaned gitlinks in .repos even when that directory is excluded. + run: | + set -euo pipefail + git init . + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git fetch --no-tags --depth=1 origin "$CHECKOUT_REF" + git sparse-checkout set .github/scripts + git checkout --detach FETCH_HEAD + + # Fetch the archive as bytes. Extracting it over the checkout, even with + # download-artifact, could replace code that runs with signing secrets. + - name: Download and validate PR JS bundle + if: steps.gate.outputs.eligible == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_ID: ${{ steps.pr.outputs.artifact_id }} + run: | + set -euo pipefail + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/js-bundle.zip" + python3 .github/scripts/stage-preview-bundle.py "$RUNNER_TEMP/js-bundle.zip" "$RUNNER_TEMP/js-bundle" + + # Only validated bundle files cross into the signing job's artifact. + - name: Stage JS bundle for packaging + if: steps.gate.outputs.eligible == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: js-bundle + path: ${{ runner.temp }}/js-bundle + if-no-files-found: error + # Re-running this workflow re-uploads under the same run. + overwrite: true + retention-days: 1 + + build: + name: Package and sign macOS arm64 preview + needs: resolve + if: needs.resolve.outputs.eligible == 'true' + concurrency: + group: desktop-macos-preview-${{ needs.resolve.outputs.pr_number }}-build + cancel-in-progress: true + uses: ./.github/workflows/release-desktop.yml + secrets: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} + with: + version: ${{ needs.resolve.outputs.version }} + ref: ${{ github.sha }} + release_channel: preview + relay_client_tracing: false + clerk_publishable_key: ${{ needs.resolve.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ needs.resolve.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ needs.resolve.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ needs.resolve.outputs.relay_url }} + label: macOS arm64 preview + runner: blacksmith-12vcpu-macos-26 + platform: mac + target: dmg + arch: arm64 + rust_target: aarch64-apple-darwin + resource_key: darwin-arm64 + cli_archive: false + + # Release assets download without a GitHub account, unlike workflow + # artifacts. All preview DMGs live on one rolling prerelease tagged + # "desktop-preview" (release.yml only matches v*.*.* tags), so publishing a + # build never notifies release watchers. + publish: + name: Publish anonymous download + needs: [resolve, build] + if: needs.resolve.outputs.eligible == 'true' && needs.build.result == 'success' + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 10 + # Its own group, so a publish never cancels a newer commit's signing job + # (they would share the build group) and is never cancelled mid-upload. + # preview_eligible's head check keeps a superseded publish from landing. + concurrency: + group: desktop-macos-preview-${{ needs.resolve.outputs.pr_number }}-publish + cancel-in-progress: false + permissions: + contents: write + pull-requests: write + steps: + - name: Download macOS artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: desktop-mac-arm64 + path: release + + - id: upload + name: Upload DMG to the rolling preview release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ needs.resolve.outputs.pr_number }} + HEAD_SHA: ${{ needs.resolve.outputs.head_sha }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + + tag="desktop-preview" + + # True while the PR is open and still points at the commit this + # build came from. The label was consumed in resolve, so it is not + # part of this check. A push does not cancel an already-running + # signing job, so this is what keeps a superseded commit's DMG off + # the release. + preview_eligible() { + [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \ + --json state,headRefOid \ + --jq '.state + " " + .headRefOid')" == "OPEN $HEAD_SHA" ]] + } + + # The build ran for many minutes. If the PR closed or moved on + # meanwhile, cleanup already ran in its own concurrency group or a + # newer build owns the asset, so publishing now would resurrect a + # deleted download or clobber a newer one. + if ! preview_eligible; then + echo "PR closed or head moved while building. Skipping publish." + exit 0 + fi + + shopt -s nullglob + dmg_files=(release/*.dmg) + if (( ${#dmg_files[@]} != 1 )); then + printf 'Expected one DMG, found %s.\n' "${#dmg_files[@]}" >&2 + exit 1 + fi + dmg_path="${dmg_files[0]}" + + # Requiring this PR's marker keeps a build from clobbering or + # deleting another PR's asset, since those names carry a different + # -pr.N. marker. + if [[ "$(basename "$dmg_path")" != *"-pr.${PR_NUMBER}."* ]]; then + echo "DMG name '$(basename "$dmg_path")' does not carry this PR's -pr.${PR_NUMBER}. marker. Refusing to publish." >&2 + exit 1 + fi + + if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + # "|| true" tolerates a concurrent publish job creating the + # release between the check and the create. + gh release create "$tag" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$DEFAULT_BRANCH" \ + --prerelease \ + --title "Desktop preview builds" \ + --notes "Rolling desktop builds from pull requests with a preview label. Each download is removed when its pull request closes or loses the label. Install stable builds from the latest release instead." \ + || true + fi + + # Keep one DMG per PR: drop this PR's older builds first. The + # trailing dot keeps -pr.12. from matching -pr.123. builds. + gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \ + | { grep -F -- "-pr.${PR_NUMBER}." || true; } \ + | while read -r asset; do + gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \ + || echo "Asset $asset was already removed by a concurrent run." + done + + gh release upload "$tag" "$dmg_path" --repo "$GITHUB_REPOSITORY" --clobber + + # Re-check after uploading. A cleanup run that started during the + # upload listed assets before ours existed, so it cannot delete it. + # Whichever writer acts last sees the final PR state; if the preview + # became ineligible, delete what we just uploaded. + if ! preview_eligible; then + gh release delete-asset "$tag" "$(basename "$dmg_path")" --repo "$GITHUB_REPOSITORY" --yes \ + || echo "Asset was already removed by a concurrent run." + echo "PR closed or head moved during upload. Removed the download." + exit 0 + fi + + echo "dmg_name=$(basename "$dmg_path")" >> "$GITHUB_OUTPUT" + echo "download_url=https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}/$(basename "$dmg_path")" >> "$GITHUB_OUTPUT" + + - name: Comment download link + if: steps.upload.outputs.download_url != '' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + PR_NUMBER: ${{ needs.resolve.outputs.pr_number }} + DOWNLOAD_URL: ${{ steps.upload.outputs.download_url }} + HEAD_SHA: ${{ needs.resolve.outputs.head_sha }} + PREVIEW_VERSION: ${{ needs.resolve.outputs.version }} + with: + script: | + const prNumber = Number(process.env.PR_NUMBER); + const { data: pullRequest } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + if (pullRequest.head.sha !== process.env.HEAD_SHA || pullRequest.state !== "open") { + core.info("Skipping the outdated macOS preview comment."); + return; + } + + const marker = ""; + const body = [ + marker, + "### macOS preview", + "", + `[Download Apple Silicon DMG](${process.env.DOWNLOAD_URL})`, + "", + `Version: ${process.env.PREVIEW_VERSION}`, + `Commit: ${process.env.HEAD_SHA.slice(0, 7)}`, + "", + "Signed and notarized, with T3 Connect enabled. The app bundle (server, web client, Electron main) is built from this PR; packaging, native helpers, and desktop dependencies come from `main`.", + "", + "No GitHub sign-in is needed. The download stays available until this PR closes. The `preview:mac` label was consumed by this build; a maintainer applies it again to build a newer commit.", + ].join("\n"); + + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + per_page: 100, + }); + const existing = comments.find( + (comment) => comment.user?.login === "github-actions[bot]" && comment.body?.includes(marker), + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + } + + cleanup: + name: Remove preview download + # A published preview no longer carries the label (resolve consumed it), + # so every close must look for assets; the -pr.N. filter below makes + # that a cheap no-op for PRs that never had one. A manual unlabel before + # the build consumed it withdraws the request and drops any older + # download too. + if: >- + github.event_name == 'pull_request_target' && + (github.event.action == 'closed' || + (github.event.action == 'unlabeled' && github.event.label.name == 'preview:mac')) + # Runs on every PR close and usually finds nothing. Keep it cheap. + runs-on: ubuntu-24.04 + timeout-minutes: 10 + # Cleanup runs must complete: a close event right after an unlabel queues + # behind the running cleanup instead of canceling it mid-delete. + concurrency: + group: desktop-macos-preview-${{ github.event.pull_request.number }}-cleanup + cancel-in-progress: false + permissions: + contents: write + pull-requests: write + steps: + - id: delete + name: Delete this PR's preview assets + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + tag="desktop-preview" + + # A stale cleanup must not delete a download that became valid + # again. If the PR is open and labeled once more, the next publish + # owns this PR's assets and replaces them itself. + if [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \ + --json state,labels \ + --jq '.state + " " + (.labels | map(.name) | contains(["preview:mac"]) | tostring)')" == "OPEN true" ]]; then + echo "PR is open and labeled again. Skipping cleanup." + echo "removed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "removed=true" >> "$GITHUB_OUTPUT" + + if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "No preview release exists. Nothing to clean up." + exit 0 + fi + + gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \ + | { grep -F -- "-pr.${PR_NUMBER}." || true; } \ + | while read -r asset; do + gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \ + || echo "Asset $asset was already removed by a concurrent run." + done + + - name: Mark the preview comment as removed + if: steps.delete.outputs.removed == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const marker = ""; + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + per_page: 100, + }); + const existing = comments.find( + (comment) => comment.user?.login === "github-actions[bot]" && comment.body?.includes(marker), + ); + if (!existing) { + return; + } + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: [ + marker, + "### macOS preview", + "", + "The preview download was removed because this PR closed or the preview label was removed.", + ].join("\n"), + }); diff --git a/.github/workflows/desktop-macos-preview.yml b/.github/workflows/desktop-macos-preview.yml index 7875aec6f36b..43b6c8220aaf 100644 --- a/.github/workflows/desktop-macos-preview.yml +++ b/.github/workflows/desktop-macos-preview.yml @@ -1,74 +1,67 @@ name: Desktop macOS Preview +# Untrusted half of the macOS preview. This runs PR code (including fork PRs) +# with a read-only token and no secrets, and only produces the JS bundle. The +# trusted half, desktop-macos-preview-publish.yml, runs on workflow_run from +# main, verifies the PR author is vouched, then packages, signs, notarizes, and +# publishes the bundle without ever executing it. +# +# The label is a one-shot request for the commit it was applied to, not a +# standing subscription: the trusted half removes it once this run completes, +# and later pushes do not build until a maintainer applies it again. Each +# signed preview is therefore an explicit per-commit decision. +# +# Closing the PR is handled by the publish workflow too, since deleting the +# download needs a write token. + on: pull_request: - types: [labeled, unlabeled, synchronize, reopened, closed] + types: [labeled] permissions: contents: read -# Build events and cleanup events use separate groups: a push must cancel a -# stale in-flight build, but must never cancel a cleanup run mid-delete. The -# publish job re-checks PR state before uploading to cover the reverse race. concurrency: - group: desktop-macos-preview-${{ github.event.pull_request.number }}-${{ contains(fromJSON('["closed", "unlabeled"]'), github.event.action) && 'cleanup' || 'build' }} - # Cleanup runs must complete (a close event right after an unlabel queues - # behind the running cleanup instead of canceling it mid-delete), and events - # that skip the build job, such as adding an unrelated label, must not - # cancel an in-flight build either. - cancel-in-progress: ${{ !contains(fromJSON('["closed", "unlabeled"]'), github.event.action) && (github.event.action != 'labeled' || github.event.label.name == 'preview:mac') }} + group: desktop-macos-preview-${{ github.event.pull_request.number }} + # Adding an unrelated label skips the job and must not cancel a build. + cancel-in-progress: ${{ github.event.label.name == 'preview:mac' }} jobs: - # Builds run PR code, so this job keeps a read-only token. Publishing to the - # release happens in the publish job below, which never checks out PR code. build: - name: Build macOS Apple Silicon preview - if: >- - github.event.action != 'closed' && - github.event.action != 'unlabeled' && - github.event.pull_request.head.repo.full_name == github.repository && - contains(github.event.pull_request.labels.*.name, 'preview:mac') && - (github.event.action != 'labeled' || github.event.label.name == 'preview:mac') - runs-on: blacksmith-12vcpu-macos-26 + name: Build preview JS bundle + if: github.event.label.name == 'preview:mac' + runs-on: blacksmith-32vcpu-ubuntu-2404 timeout-minutes: 30 - outputs: - dmg_name: ${{ steps.build.outputs.dmg_name }} - version: ${{ steps.version.outputs.version }} steps: + # This repository is public, so Git needs no credentials. checkout's + # credential cleanup runs submodule foreach even with submodules disabled, + # which fails on the orphaned gitlinks in our vendored .repos tree. - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ github.event.pull_request.head.sha }} - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false + shell: bash + env: + CHECKOUT_REF: ${{ github.event.pull_request.head.sha }} + GIT_TERMINAL_PROMPT: "0" + run: | + set -euo pipefail + git init . + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git fetch --no-tags --depth=1 origin "$CHECKOUT_REF" + git sparse-checkout set --no-cone '/*' '!/.repos/' + git checkout --detach FETCH_HEAD - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 with: node-version-file: package.json cache: true run-install: false - - name: Install desktop dependencies - run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... - - - name: Cache resource monitor - id: resource_monitor_cache - uses: actions/cache@v6 - with: - path: native/resource-monitor/target/aarch64-apple-darwin/release/t3-resource-monitor - key: resource-monitor-aarch64-apple-darwin-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} - - - name: Setup Rust - if: steps.resource_monitor_cache.outputs.cache-hit != 'true' - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-apple-darwin + - name: Install bundle dependencies + run: vp install --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... - - id: version - name: Set preview version and public configuration + # The publish workflow derives the same version from this run's number, + # so the version baked into the bundle matches the packaged app. + - name: Set preview version and public configuration shell: bash env: PR_NUMBER: ${{ github.event.pull_request.number }} @@ -76,286 +69,26 @@ jobs: set -euo pipefail base_version="$(node -p "require('./apps/desktop/package.json').version")" - preview_version="${base_version}-pr.${PR_NUMBER}.${GITHUB_RUN_NUMBER}" - node scripts/update-release-package-versions.ts "$preview_version" + node scripts/update-release-package-versions.ts "${base_version}-pr.${PR_NUMBER}.${GITHUB_RUN_NUMBER}" + # Public T3 Connect identifiers (Clerk publishable key, relay URL). cp .env.example .env - echo "version=$preview_version" >> "$GITHUB_OUTPUT" - - - id: build - name: Build unsigned macOS DMG - shell: bash - env: - T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} - PREVIEW_VERSION: ${{ steps.version.outputs.version }} - run: | - set -euo pipefail + - uses: ./.github/actions/setup-apt-mirrors - vp run dist:desktop:artifact \ - --platform mac \ - --target dmg \ - --arch arm64 \ - --build-version "$PREVIEW_VERSION" \ - --verbose + - name: Install browser secret helper build libraries + run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - shopt -s nullglob - dmg_files=(release/*.dmg) - if (( ${#dmg_files[@]} != 1 )); then - printf 'Expected one DMG, found %s.\n' "${#dmg_files[@]}" >&2 - exit 1 - fi - printf 'dmg_name=%s\n' "$(basename "${dmg_files[0]}")" >> "$GITHUB_OUTPUT" + - name: Build JS bundle + run: vp run build:desktop - # archive: false uploads the file as its own artifact named after the - # file, so the publish job downloads by *.dmg pattern, not by name. - - name: Upload macOS DMG - uses: actions/upload-artifact@v7 + # Same layout as release.yml's js-bundle so release-desktop.yml can + # package it unchanged. + - name: Upload JS bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - path: release/*.dmg + name: js-bundle + path: | + apps/server/dist + apps/desktop/dist-electron if-no-files-found: error - archive: false - overwrite: true - retention-days: 7 - - # Release assets download without a GitHub account, unlike workflow - # artifacts. All preview DMGs live on one rolling prerelease tagged - # "desktop-preview" (release.yml only matches v*.*.* tags), so publishing a - # build never notifies release watchers. This job holds the write token and - # only handles the artifact the build job produced; it never runs PR code. - publish: - name: Publish anonymous download - needs: build - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - permissions: - contents: write - pull-requests: write - steps: - - name: Download macOS DMG - uses: actions/download-artifact@v8 - with: - pattern: "*.dmg" - merge-multiple: true - path: release - - - id: upload - name: Upload DMG to the rolling preview release - shell: bash - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: | - set -euo pipefail - - tag="desktop-preview" - - # True while the PR is open and still carries the preview label. - preview_eligible() { - [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \ - --json state,labels \ - --jq '.state + " " + (.labels | map(.name) | contains(["preview:mac"]) | tostring)')" == "OPEN true" ]] - } - - # The build ran for many minutes. If the PR closed or lost the label - # meanwhile, cleanup already ran in its own concurrency group, so - # publishing now would resurrect a deleted download. - if ! preview_eligible; then - echo "PR closed or preview label removed while building. Skipping publish." - exit 0 - fi - - dmg_path="$(find release -type f -name '*.dmg' -print -quit)" - if [[ -z "$dmg_path" ]]; then - echo "No DMG found in the downloaded artifact." >&2 - exit 1 - fi - - # The filename comes out of the build, which runs PR code. Requiring - # this PR's marker keeps a build from clobbering or deleting another - # PR's asset, since those names carry a different -pr.N. marker. - if [[ "$(basename "$dmg_path")" != *"-pr.${PR_NUMBER}."* ]]; then - echo "DMG name '$(basename "$dmg_path")' does not carry this PR's -pr.${PR_NUMBER}. marker. Refusing to publish." >&2 - exit 1 - fi - - if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - # "|| true" tolerates a concurrent publish job creating the - # release between the check and the create. - gh release create "$tag" \ - --repo "$GITHUB_REPOSITORY" \ - --target "$DEFAULT_BRANCH" \ - --prerelease \ - --title "Desktop preview builds" \ - --notes "Rolling unsigned desktop builds from pull requests with a preview label. Each download is removed when its pull request closes or loses the label. Install stable builds from the latest release instead." \ - || true - fi - - # Keep one DMG per PR: drop this PR's older builds first. The - # trailing dot keeps -pr.12. from matching -pr.123. builds. - gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \ - | { grep -F -- "-pr.${PR_NUMBER}." || true; } \ - | while read -r asset; do - gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \ - || echo "Asset $asset was already removed by a concurrent run." - done - - gh release upload "$tag" "$dmg_path" --repo "$GITHUB_REPOSITORY" --clobber - - # Re-check after uploading. A cleanup run that started during the - # upload listed assets before ours existed, so it cannot delete it. - # Whichever writer acts last sees the final PR state; if the preview - # became ineligible, delete what we just uploaded. - if ! preview_eligible; then - gh release delete-asset "$tag" "$(basename "$dmg_path")" --repo "$GITHUB_REPOSITORY" --yes \ - || echo "Asset was already removed by a concurrent run." - echo "PR closed or preview label removed during upload. Removed the download." - exit 0 - fi - - echo "download_url=https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}/$(basename "$dmg_path")" >> "$GITHUB_OUTPUT" - - - name: Comment download link - if: steps.upload.outputs.download_url != '' - uses: actions/github-script@v8 - env: - DOWNLOAD_URL: ${{ steps.upload.outputs.download_url }} - DMG_NAME: ${{ needs.build.outputs.dmg_name }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - PREVIEW_VERSION: ${{ needs.build.outputs.version }} - with: - script: | - const { data: pullRequest } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.payload.pull_request.number, - }); - if ( - pullRequest.head.sha !== process.env.HEAD_SHA || - pullRequest.state !== "open" || - !pullRequest.labels.some((label) => label.name === "preview:mac") - ) { - core.info("Skipping the outdated macOS preview comment."); - return; - } - - const marker = ""; - const body = [ - marker, - "### macOS preview", - "", - `[Download Apple Silicon DMG](${process.env.DOWNLOAD_URL})`, - "", - `Version: ${process.env.PREVIEW_VERSION}`, - `Commit: ${process.env.HEAD_SHA.slice(0, 7)}`, - "", - "Unsigned build. Clear quarantine before opening:", - "```sh", - `xattr -d com.apple.quarantine ~/Downloads/${process.env.DMG_NAME}`, - "```", - "", - "No GitHub sign-in is needed. The download stays available until this PR closes or the preview label is removed.", - ].join("\n"); - - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - per_page: 100, - }); - const existing = comments.find((comment) => comment.body?.includes(marker)); - - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - body, - }); - } - - # The way out: closing the PR or removing the label deletes its DMG from the - # rolling release and updates the PR comment to say so. - cleanup: - name: Remove preview download - if: >- - github.event.pull_request.head.repo.full_name == github.repository && - ((github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'preview:mac')) || - (github.event.action == 'unlabeled' && github.event.label.name == 'preview:mac')) - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - permissions: - contents: write - pull-requests: write - steps: - - id: delete - name: Delete this PR's preview assets - shell: bash - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - - tag="desktop-preview" - - # A stale cleanup must not delete a download that became valid - # again. If the PR is open and labeled once more, the next publish - # owns this PR's assets and replaces them itself. - if [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \ - --json state,labels \ - --jq '.state + " " + (.labels | map(.name) | contains(["preview:mac"]) | tostring)')" == "OPEN true" ]]; then - echo "PR is open and labeled again. Skipping cleanup." - echo "removed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "removed=true" >> "$GITHUB_OUTPUT" - - if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "No preview release exists. Nothing to clean up." - exit 0 - fi - - gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \ - | { grep -F -- "-pr.${PR_NUMBER}." || true; } \ - | while read -r asset; do - gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \ - || echo "Asset $asset was already removed by a concurrent run." - done - - - name: Mark the preview comment as removed - if: steps.delete.outputs.removed == 'true' - uses: actions/github-script@v8 - with: - script: | - const marker = ""; - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - per_page: 100, - }); - const existing = comments.find((comment) => comment.body?.includes(marker)); - if (!existing) { - return; - } - - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body: [ - marker, - "### macOS preview", - "", - "The preview download was removed because this PR closed or the preview label was removed.", - ].join("\n"), - }); + retention-days: 1 diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 4ad9f4f7672b..227004b4882e 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -24,6 +24,8 @@ name: Mobile EAS Production # into the void. # workflow_dispatch remains as a manual override for both modes (e.g. to # retry an errored build or force an OTA). +# Manual v2-preview builds keep the production app identity and disable OTA. +# Select the v2 branch when dispatching a preview build. on: workflow_dispatch: inputs: @@ -35,6 +37,14 @@ on: options: - build - update + profile: + description: "Store build profile (v2-preview supports build mode only)" + required: true + type: choice + default: production + options: + - production + - v2-preview platform: description: "Target platform" required: true @@ -75,7 +85,7 @@ concurrency: jobs: production: - name: EAS Production ${{ github.event_name == 'push' && 'auto' || inputs.mode }} + name: EAS ${{ inputs.profile || 'production' }} ${{ github.event_name == 'push' && 'auto' || inputs.mode }} runs-on: blacksmith-8vcpu-ubuntu-2404 permissions: contents: read @@ -83,6 +93,12 @@ jobs: APP_VARIANT: production NODE_OPTIONS: --max-old-space-size=8192 steps: + - name: Reject preview OTA updates + if: github.event_name == 'workflow_dispatch' && inputs.profile == 'v2-preview' && inputs.mode == 'update' + run: | + echo "::error::V2 previews use store builds only. Select mode=build." + exit 1 + - id: expo-token name: Check for EXPO_TOKEN env: @@ -125,6 +141,16 @@ jobs: args: - --filter=@t3tools/mobile... + - name: Verify v2 preview source + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.profile == 'v2-preview' + run: | + node --input-type=module -e ' + import * as environment from "./packages/contracts/src/environment.ts"; + if (environment.ORCHESTRATION_PROTOCOL_VERSION !== 2) { + throw new Error("V2 previews require a v2 source branch. Select the v2 branch when running this workflow."); + } + ' + - name: Expose pnpm if: steps.expo-token.outputs.present == 'true' run: | @@ -199,10 +225,12 @@ jobs: - name: Summarize manual build version if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' working-directory: apps/mobile + env: + MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }} run: | version="$(npx expo config --json --type public | jq -r '.version')" { - echo "## Manual production build" + echo "## Manual $MOBILE_BUILD_PROFILE build" echo echo "- App version: \`$version\`" echo "- Platform: \`${{ inputs.platform }}\`" @@ -215,7 +243,8 @@ jobs: working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - run: eas build --platform ${{ inputs.platform }} --profile production --auto-submit --non-interactive --no-wait + MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }} + run: eas build --platform ${{ inputs.platform }} --profile "$MOBILE_BUILD_PROFILE" --auto-submit --non-interactive --no-wait - name: Publish OTA update (manual) if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'update' diff --git a/.github/workflows/mobile-fingerprint-check.yml b/.github/workflows/mobile-fingerprint-check.yml index fd98817cd105..b8b872f019f6 100644 --- a/.github/workflows/mobile-fingerprint-check.yml +++ b/.github/workflows/mobile-fingerprint-check.yml @@ -13,17 +13,9 @@ name: Mobile Fingerprint Check # pnpm), so the comparison is self-consistent; no EXPO_TOKEN needed. on: pull_request: - paths: - - apps/mobile/** - - packages/client-runtime/** - - packages/contracts/** - - packages/shared/** - - assets/** - - scripts/** - - patches/** - - pnpm-lock.yaml - - pnpm-workspace.yaml - - .github/workflows/mobile-fingerprint-check.yml + # Run even when a rebase or base change removes every native input from the + # diff, so the label can be cleared without installing Expo dependencies. + types: [opened, synchronize, reopened, edited] concurrency: group: mobile-fingerprint-check-${{ github.event.pull_request.number }} @@ -44,12 +36,29 @@ jobs: - name: Checkout uses: actions/checkout@v6 with: - # Default pull_request checkout is the merge commit (PR applied on - # top of base), so the "head" fingerprint is the state main would - # actually be in after merging — stale branches compare cleanly. - fetch-depth: 0 + # The merge ref can advance after the event is queued. Pin its commit + # and compare against its first parent, rather than the event's base. + ref: ${{ github.sha }} + fetch-depth: 2 + + - id: changes + name: Detect native fingerprint inputs + run: | + base_sha=$(git rev-parse HEAD^1) + echo "base_sha=$base_sha" >> "$GITHUB_OUTPUT" + paths=$(git diff --no-renames --name-only "$base_sha" HEAD -- \ + apps/mobile/ packages/client-runtime/ packages/contracts/ packages/shared/ \ + assets/ scripts/ patches/ package.json pnpm-lock.yaml pnpm-workspace.yaml \ + .github/workflows/mobile-fingerprint-check.yml) + if [[ -n "$paths" ]]; then + echo "relevant=true" >> "$GITHUB_OUTPUT" + else + echo "relevant=false" >> "$GITHUB_OUTPUT" + echo "No native fingerprint inputs changed; clearing any stale native change label." >> "$GITHUB_STEP_SUMMARY" + fi - name: Setup Vite+ + if: steps.changes.outputs.relevant == 'true' uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json @@ -59,6 +68,7 @@ jobs: - --filter=@t3tools/mobile... - name: Expose pnpm + if: steps.changes.outputs.relevant == 'true' run: | pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" @@ -66,6 +76,7 @@ jobs: "$vp_pnpm_bin/pnpm" --version - name: Fingerprint merge result + if: steps.changes.outputs.relevant == 'true' working-directory: apps/mobile run: | mkdir -p "$RUNNER_TEMP/fp/head" "$RUNNER_TEMP/fp/base" @@ -74,8 +85,11 @@ jobs: done - name: Fingerprint base + if: steps.changes.outputs.relevant == 'true' + env: + BASE_SHA: ${{ steps.changes.outputs.base_sha }} run: | - git checkout --quiet "${{ github.event.pull_request.base.sha }}" + git checkout --quiet "$BASE_SHA" # Re-sync node_modules to the base commit's lockfile before # fingerprinting — a dep-changing PR must not fingerprint the base # against head's installed packages. @@ -87,6 +101,7 @@ jobs: - id: compare name: Compare fingerprints + if: steps.changes.outputs.relevant == 'true' run: | changed="" { diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml new file mode 100644 index 000000000000..c374a8692db6 --- /dev/null +++ b/.github/workflows/release-desktop.yml @@ -0,0 +1,571 @@ +name: Release desktop build + +# One desktop platform/arch build, called once per target from release.yml so +# each target is its own job with its own `needs`. The JS bundle (server, web +# client, Electron main) comes from the `js-bundle` artifact that build_bundle +# produced; this job only packages it, builds the native helpers, and, where +# `cli_archive` is set, the self-contained CLI archive for its platform. + +on: + workflow_call: + secrets: + CSC_LINK: + required: false + CSC_KEY_PASSWORD: + required: false + APPLE_API_KEY: + required: false + APPLE_API_KEY_ID: + required: false + APPLE_API_ISSUER: + required: false + MACOS_PROVISIONING_PROFILE: + required: false + AZURE_TENANT_ID: + required: false + AZURE_CLIENT_ID: + required: false + AZURE_CLIENT_SECRET: + required: false + AZURE_TRUSTED_SIGNING_ENDPOINT: + required: false + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: + required: false + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: + required: false + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: + required: false + inputs: + label: + required: true + type: string + runner: + required: true + type: string + platform: + required: true + type: string + target: + required: true + type: string + arch: + required: true + type: string + rust_target: + required: true + type: string + resource_key: + required: true + type: string + # Whether the job also builds the self-contained CLI archive for its own + # platform/arch, on this runner, and smoke-tests it here. Every archive + # is built on hardware of its own architecture. + cli_archive: + required: false + default: false + type: boolean + version: + required: true + type: string + ref: + required: true + type: string + release_channel: + required: true + type: string + # Whether a `relay-client-tracing-config` artifact from the production + # relay state is expected. PR previews carry no tracing config. + relay_client_tracing: + required: false + default: true + type: boolean + clerk_publishable_key: + required: true + type: string + clerk_jwt_template: + required: true + type: string + clerk_cli_oauth_client_id: + required: true + type: string + relay_url: + required: true + type: string + +permissions: + contents: read + +jobs: + build: + name: Build ${{ inputs.label }} + runs-on: ${{ inputs.runner }} + timeout-minutes: 30 + env: + T3CODE_CLERK_PUBLISHABLE_KEY: ${{ inputs.clerk_publishable_key }} + T3CODE_CLERK_JWT_TEMPLATE: ${{ inputs.clerk_jwt_template }} + T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ inputs.clerk_cli_oauth_client_id }} + T3CODE_RELAY_URL: ${{ inputs.relay_url }} + steps: + # This repository is public, so Git needs no credentials. checkout's + # credential cleanup runs submodule foreach even with submodules disabled, + # which fails on the orphaned gitlinks in our vendored .repos tree. + - name: Checkout + shell: bash + env: + CHECKOUT_REF: ${{ inputs.ref }} + GIT_TERMINAL_PROMPT: "0" + run: | + set -euo pipefail + git init . + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git fetch --no-tags --depth=1 origin "$CHECKOUT_REF" + git sparse-checkout set --no-cone '/*' '!/.repos/' + git checkout --detach FETCH_HEAD + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + with: + node-version-file: package.json + cache: ${{ inputs.platform != 'win' }} + run-install: false + + - name: Resolve Windows package cache path + if: inputs.platform == 'win' + id: package_cache_path + shell: pwsh + run: '"path=$(vp pm cache dir)" >> $env:GITHUB_OUTPUT' + + - name: Cache Windows packages + if: inputs.platform == 'win' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.package_cache_path.outputs.path }} + key: windows-release-packages-v1-${{ inputs.arch }}-${{ hashFiles('pnpm-lock.yaml') }} + + # pnpm checks the lockfile and policy before reusing this result. A missing + # artifact leaves the cache empty, so installation runs the checks again. + - name: Download dependency verification + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: release-dependency-verification + path: ${{ runner.temp }}/pnpm-metadata + + - name: Install desktop dependencies + env: + pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata + run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... + + - name: Cache resource monitor + id: resource_monitor_cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: native/resource-monitor/target/${{ inputs.rust_target }}/release/t3-resource-monitor${{ inputs.platform == 'win' && '.exe' || '' }} + key: resource-monitor-${{ inputs.rust_target }}-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} + + - name: Cache Linux capture helpers + if: inputs.platform == 'linux' + id: capture_helper_cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: | + native/kde-snap-shot/target/${{ inputs.rust_target }}/release/t3-kde-snap-shot + native/hyprland-snap-shot/target/${{ inputs.rust_target }}/release/t3-hyprland-snap-shot + key: linux-capture-helpers-${{ inputs.rust_target }}-${{ hashFiles('native/kde-snap-shot/Cargo.lock', 'native/kde-snap-shot/Cargo.toml', 'native/kde-snap-shot/src/**', 'native/hyprland-snap-shot/Cargo.lock', 'native/hyprland-snap-shot/Cargo.toml', 'native/hyprland-snap-shot/src/**', 'native/hyprland-snap-shot/protocols/**') }} + + - name: Setup Rust + if: steps.resource_monitor_cache.outputs.cache-hit != 'true' || (inputs.platform == 'linux' && steps.capture_helper_cache.outputs.cache-hit != 'true') + uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + with: + toolchain: stable + targets: ${{ inputs.rust_target }} + + - name: Download relay client tracing config + if: inputs.relay_client_tracing + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: relay-client-tracing-config + path: ${{ runner.temp }}/relay-client-tracing + + - name: Load relay client tracing config + if: inputs.relay_client_tracing + shell: bash + run: | + config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" + tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" + echo "::add-mask::$tracing_token" + cat "$config_path" >> "$GITHUB_ENV" + + - name: Align package versions to release version + run: node scripts/update-release-package-versions.ts "${{ inputs.version }}" + + # The artifact root is `apps/` (upload-artifact keeps the least common + # ancestor of its paths), so extracting into `apps` restores + # apps/server/dist and apps/desktop/dist-electron at their build paths. + - name: Download JS bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: js-bundle + path: apps + + # The WSL backend runs the Linux CLI archive inside the distro, so the + # Windows desktop embeds the same-arch archive the release attaches. + - name: Download Linux CLI archive for WSL + if: inputs.platform == 'win' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: cli-linux-${{ inputs.arch }} + path: wsl-runtime + + - name: Install Spectre-mitigated MSVC libs + if: inputs.platform == 'win' + shell: pwsh + run: | + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $installPath = & $vswhere -products * -latest -property installationPath + $setupExe = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\setup.exe" + $proc = Start-Process -FilePath $setupExe ` + -ArgumentList "modify", "--installPath", "`"$installPath`"", "--add", ` + "Microsoft.VisualStudio.Component.VC.Runtimes.${{ inputs.arch == 'arm64' && 'ARM64' || 'x86.x64' }}.Spectre", "--quiet", "--norestart" ` + -Wait -PassThru -NoNewWindow + if ($null -eq $proc -or $proc.ExitCode -ne 0) { + $code = if ($null -ne $proc) { $proc.ExitCode } else { 1 } + Write-Error "Visual Studio Installer failed with exit code $code" + exit $code + } + + - uses: ./.github/actions/setup-apt-mirrors + if: inputs.platform == 'linux' + + - name: Install Linux desktop build libraries + if: inputs.platform == 'linux' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y libsecret-1-dev pkg-config + if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then + sudo apt-get install -y imagemagick + fi + + if command -v magick >/dev/null 2>&1; then + magick -version + else + convert -version + fi + + - name: Prepare Azure Trusted Signing + if: inputs.platform == 'win' + shell: pwsh + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} + run: | + $ErrorActionPreference = "Stop" + + $requiredSecrets = @( + $env:AZURE_TENANT_ID, + $env:AZURE_CLIENT_ID, + $env:AZURE_CLIENT_SECRET, + $env:AZURE_TRUSTED_SIGNING_ENDPOINT, + $env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME, + $env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME, + $env:AZURE_TRUSTED_SIGNING_PUBLISHER_NAME + ) + if ($requiredSecrets | Where-Object { [string]::IsNullOrWhiteSpace($_) }) { + Write-Host "Azure Trusted Signing disabled; skipping TrustedSigning module preparation." + exit 0 + } + + try { + Install-PackageProvider ` + -Name NuGet ` + -MinimumVersion 2.8.5.201 ` + -Force ` + -Scope CurrentUser ` + -ErrorAction Stop + } catch { + Write-Warning "Could not bootstrap NuGet package provider. Continuing because the runner may already have a usable provider. $($_.Exception.Message)" + } + + Install-Module ` + -Name TrustedSigning ` + -MinimumVersion 0.5.0 ` + -Force ` + -AllowClobber ` + -Repository PSGallery ` + -Scope CurrentUser ` + -ErrorAction Stop + + Import-Module TrustedSigning -MinimumVersion 0.5.0 -Force + Get-Command Invoke-TrustedSigning -ErrorAction Stop + + $moduleRoots = @( + [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "PowerShell", "Modules"), + [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "WindowsPowerShell", "Modules"), + [System.IO.Path]::Combine($env:ProgramFiles, "PowerShell", "Modules"), + [System.IO.Path]::Combine($env:ProgramFiles, "WindowsPowerShell", "Modules") + ) + $modulePathEntries = @($moduleRoots + ($env:PSModulePath -split ";")) | + Where-Object { $_ -and (Test-Path $_) } | + Select-Object -Unique + "PSModulePath=$($modulePathEntries -join ';')" >> $env:GITHUB_ENV + + - name: Build desktop artifact + shell: bash + env: + pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata + T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} + T3CODE_DESKTOP_REUSE_LINUX_CAPTURE_HELPERS: ${{ steps.capture_helper_cache.outputs.cache-hit == 'true' }} + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} + MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} + T3CODE_CLERK_PASSKEY_RP_DOMAINS: ${{ vars.CLERK_PASSKEY_RP_DOMAINS }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} + run: | + args=( + --platform "${{ inputs.platform }}" + --target "${{ inputs.target }}" + --arch "${{ inputs.arch }}" + --build-version "${{ inputs.version }}" + --skip-build + --verbose + ) + + has_all() { + for value in "$@"; do + if [[ -z "$value" ]]; then + return 1 + fi + done + return 0 + } + + if [[ "${{ inputs.platform }}" == "mac" ]]; then + if has_all "$CSC_LINK" "$CSC_KEY_PASSWORD" "$APPLE_API_KEY" "$APPLE_API_KEY_ID" "$APPLE_API_ISSUER"; then + if ! has_all "$APPLE_TEAM_ID" "$MACOS_PROVISIONING_PROFILE"; then + echo "macOS signing is configured, but APPLE_TEAM_ID or MACOS_PROVISIONING_PROFILE is missing." >&2 + exit 1 + fi + + key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" + printf '%s' "$APPLE_API_KEY" > "$key_path" + export APPLE_API_KEY="$key_path" + + profile_path="$RUNNER_TEMP/t3code.provisionprofile" + printf '%s' "$MACOS_PROVISIONING_PROFILE" | base64 -D > "$profile_path" + security cms -D -i "$profile_path" >/dev/null + export T3CODE_APPLE_TEAM_ID="$APPLE_TEAM_ID" + export T3CODE_MACOS_PROVISIONING_PROFILE="$profile_path" + + echo "macOS signing enabled." + args+=(--signed) + else + echo "macOS signing disabled (missing one or more Apple signing secrets)." + fi + elif [[ "${{ inputs.platform }}" == "win" ]]; then + # Embed the Linux CLI archive built by the same-arch Linux job as + # the WSL runtime. Required for a working WSL backend on Windows. + args+=(--wsl-runtime "$GITHUB_WORKSPACE"/wsl-runtime/t3-*-linux-${{ inputs.arch }}.tar.gz) + if has_all \ + "$AZURE_TENANT_ID" \ + "$AZURE_CLIENT_ID" \ + "$AZURE_CLIENT_SECRET" \ + "$AZURE_TRUSTED_SIGNING_ENDPOINT" \ + "$AZURE_TRUSTED_SIGNING_ACCOUNT_NAME" \ + "$AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME" \ + "$AZURE_TRUSTED_SIGNING_PUBLISHER_NAME"; then + echo "Windows signing enabled (Azure Trusted Signing)." + args+=(--signed) + else + echo "Windows signing disabled (missing one or more Azure Trusted Signing secrets)." + fi + else + echo "Signing disabled for ${{ inputs.platform }}." + fi + + vp run dist:desktop:artifact "${args[@]}" + + # The single-executable is built with a Node that supports --build-sea + # (25.7+); the repo itself stays on the engines.node version. It always + # injects into the runner's own Node: tsdown's cross-target download path + # runs `tar` on a drive-letter path on Windows, which GNU tar reads as a + # remote host, and a cross-built macOS binary cannot be smoke-tested. + - name: Build CLI single-executable + if: inputs.cli_archive + shell: bash + env: + # The exact version, not a major: vp downloads it from nodejs.org/dist on + # the runner, and only exact versions have a dist directory. Keep in + # step with SEA_NODE_VERSION in apps/server/vite.config.ts. + VP_NODE_VERSION: "26.8.2" + run: node apps/server/scripts/cli.ts build-exe --verbose + + - name: Import macOS signing certificate for the CLI archive + if: inputs.cli_archive && inputs.platform == 'mac' + shell: bash + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + run: | + set -euo pipefail + if [[ -z "$CSC_LINK" || -z "$CSC_KEY_PASSWORD" ]]; then + echo "macOS CLI signing disabled (missing CSC_LINK); the archive is signed ad hoc." + exit 0 + fi + keychain="$RUNNER_TEMP/t3-cli-signing.keychain-db" + keychain_password="$(openssl rand -hex 16)" + cert_path="$RUNNER_TEMP/t3-cli-signing.p12" + printf '%s' "$CSC_LINK" | base64 --decode > "$cert_path" + security create-keychain -p "$keychain_password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + security import "$cert_path" -k "$keychain" -P "$CSC_KEY_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain" >/dev/null + security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') + identity="$(security find-identity -v -p codesigning "$keychain" | sed -n 's/.*"\(Developer ID Application: [^"]*\)".*/\1/p' | head -n 1)" + if [[ -z "$identity" ]]; then + echo "No Developer ID Application identity found in CSC_LINK." >&2 + exit 1 + fi + echo "::add-mask::$keychain_password" + echo "T3CODE_CLI_MAC_SIGN_IDENTITY=$identity" >> "$GITHUB_ENV" + echo "macOS CLI signing enabled." + + - name: Stage resource monitor for the CLI archive + if: inputs.cli_archive + shell: bash + run: | + set -euo pipefail + binary_name="t3-resource-monitor" + if [[ "${{ inputs.platform }}" == "win" ]]; then + binary_name="${binary_name}.exe" + fi + target_dir="$RUNNER_TEMP/cli-resource-monitor/${{ inputs.resource_key }}" + mkdir -p "$target_dir" + cp "native/resource-monitor/target/${{ inputs.rust_target }}/release/${binary_name}" "$target_dir/$binary_name" + + - name: Build CLI archive + if: inputs.cli_archive + shell: bash + env: + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + run: | + set -euo pipefail + if [[ "${{ inputs.platform }}" == "mac" && -n "${APPLE_API_KEY:-}" ]]; then + key_path="$RUNNER_TEMP/AuthKey_cli_${APPLE_API_KEY_ID}.p8" + printf '%s' "$APPLE_API_KEY" > "$key_path" + export APPLE_API_KEY="$key_path" + fi + node scripts/build-cli-archive.ts \ + --platform "${{ inputs.platform }}" \ + --arch "${{ inputs.arch }}" \ + --version "${{ inputs.version }}" \ + --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ + --output-dir release-cli + + - name: Smoke-test CLI archive + if: inputs.cli_archive + shell: bash + run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ inputs.version }}" + + - name: Upload CLI archive + if: inputs.cli_archive + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: cli-${{ inputs.platform }}-${{ inputs.arch }} + path: release-cli/* + if-no-files-found: error + + - name: Collect release assets + shell: bash + run: | + set -euo pipefail + mkdir -p release-publish + + shopt -s nullglob + patterns=( + "release/*.dmg" + "release/*.zip" + "release/*.AppImage" + "release/*.exe" + ) + # Preview builds have no publish config, so electron-builder writes + # no feed manifest for them, but it still emits blockmaps beside the + # installers. Neither belongs on a release no updater may follow. + if [[ "${{ inputs.release_channel }}" != "preview" ]]; then + patterns+=("release/*.blockmap" "release/*.yml") + fi + for pattern in "${patterns[@]}"; do + for file in $pattern; do + cp "$file" release-publish/ + done + done + + if [[ "${{ inputs.platform }}" == "mac" && "${{ inputs.arch }}" != "arm64" ]]; then + shopt -s nullglob + for manifest in release-publish/*-mac.yml; do + mv "$manifest" "${manifest%.yml}-${{ inputs.arch }}.yml" + done + fi + + # Windows updater metadata is channel-specific (for example + # "latest.yml" or "nightly.yml") and carries no arch, so the x64 and + # arm64 jobs would upload the same name. Suffix each per-arch copy; + # the release job merges them back into one manifest per channel. + # builder-debug.yml is electron-builder's config dump, not a feed. + if [[ "${{ inputs.platform }}" == "win" ]]; then + for manifest in release-publish/*.yml; do + [[ "$manifest" == */builder-debug.yml ]] && continue + mv "$manifest" "${manifest%.yml}-win-${{ inputs.arch }}.yml" + done + fi + + - name: Collect resource monitor + shell: bash + run: | + set -euo pipefail + binary_name="t3-resource-monitor" + if [[ "${{ inputs.platform }}" == "win" ]]; then + binary_name="${binary_name}.exe" + fi + source_path="native/resource-monitor/target/${{ inputs.rust_target }}/release/${binary_name}" + target_dir="resource-monitor-publish/${{ inputs.resource_key }}" + mkdir -p "$target_dir" + cp "$source_path" "$target_dir/$binary_name" + + - name: Upload build artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-${{ inputs.platform }}-${{ inputs.arch }} + path: release-publish/* + if-no-files-found: error + + - name: Upload resource monitor + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: resource-monitor-${{ inputs.resource_key }} + path: resource-monitor-publish/${{ inputs.resource_key }}/* + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 39e485d42a98..8890de34e721 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,7 @@ on: tags: - "v*.*.*" - "!v*-nightly.*" + - "!v*-preview.*" schedule: # Avoid minute zero, when GitHub scheduled jobs are busiest. - cron: "8,38 * * * *" @@ -13,9 +14,10 @@ on: channel: description: "Release channel" required: false - default: stable + default: preview type: choice options: + - preview - stable - nightly version: @@ -30,7 +32,7 @@ on: # newest-wins single slot, so a queued stable tag can never be silently # dropped. Automatic nightlies recheck the release gap after leaving the queue. concurrency: - group: release-${{ (github.event_name == 'schedule' || inputs.channel == 'nightly') && 'nightly' || 'stable' }} + group: release-${{ (github.event_name == 'schedule' || inputs.channel == 'nightly' || inputs.channel == 'preview') && 'nightly' || 'stable' }} cancel-in-progress: false queue: max @@ -65,15 +67,27 @@ jobs: with: script: | const { + assertCommitOnDefaultBranch, + assertReleaseSource, shouldReleaseNightly, resolveLatestNightlyCommit, } = require('./.github/scripts/check-nightly-release.cjs'); + const dispatchChannel = process.env.DISPATCH_CHANNEL || 'preview'; + const releaseChannel = context.eventName === 'schedule' + ? 'nightly' + : context.eventName === 'workflow_dispatch' + ? dispatchChannel + : 'stable'; + + await assertReleaseSource({ github, context, releaseChannel }); + if (context.eventName === 'schedule') { core.setOutput('has_changes', await shouldReleaseNightly({ github, context, core })); core.setOutput('ref', context.sha); - } else if (context.eventName === 'workflow_dispatch' && process.env.DISPATCH_CHANNEL !== 'nightly') { + } else if (context.eventName === 'workflow_dispatch' && dispatchChannel === 'stable') { const { tag, sha, version } = await resolveLatestNightlyCommit({ github, context, core }); + await assertCommitOnDefaultBranch({ github, context, sha }); core.notice(`Stable release builds ${sha}, the commit shipped by ${tag}.`); core.setOutput('ref', sha); core.setOutput('nightly_version', version); @@ -131,7 +145,7 @@ jobs: NIGHTLY_SHA: ${{ needs.resolve_commit.outputs.ref }} NIGHTLY_RUN_NUMBER: ${{ github.run_number }} run: | - if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then + if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-preview}" == "nightly" ) ]]; then nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" node scripts/resolve-nightly-release.ts \ @@ -144,6 +158,28 @@ jobs: echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT" echo "is_prerelease=true" >> "$GITHUB_OUTPUT" echo "make_latest=false" >> "$GITHUB_OUTPUT" + elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-preview}" == "preview" ]]; then + # Manual-only test train: exercises the whole release flow for a + # commit end users must never receive. Never scheduled. + # Same versioning as nightly under its own prerelease identifier. + # A preview release is reachable only by asking for it: npm gets it + # under the `preview` dist-tag, which nothing resolves by default, + # its desktop builds carry no update feed, and no updater manifest + # is attached to the release, so neither stable nor nightly + # installs can ever be offered one. + nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" + + node scripts/resolve-nightly-release.ts \ + --channel preview \ + --date "$nightly_date" \ + --run-number "$NIGHTLY_RUN_NUMBER" \ + --sha "$NIGHTLY_SHA" \ + --github-output + + echo "release_channel=preview" >> "$GITHUB_OUTPUT" + echo "cli_dist_tag=preview" >> "$GITHUB_OUTPUT" + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + echo "make_latest=false" >> "$GITHUB_OUTPUT" else if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then raw="${DISPATCH_VERSION:-$NIGHTLY_VERSION}" @@ -275,15 +311,31 @@ jobs: args: - --filter=t3code-relay... - - id: relay_state - name: Read production relay tracing config + # The deployed stack's outputs, read from Alchemy's state store without + # planning or applying. Select the backend explicitly to avoid loading + # the stack's unrelated provider credentials; suppress informational logs + # and strip the credential-refresh progress prefix before parsing JSON. + # Redacted values are persisted as + # {"__redacted__": ""}; the token is masked before it is written. + - 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 --backend cloudflare --log-level error --no-input | sed -n '/^{/,$p')" + 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 uses: actions/upload-artifact@v7 @@ -325,117 +377,23 @@ jobs: echo "clerk_cli_oauth_client_id=$CLERK_CLI_OAUTH_CLIENT_ID" >> "$GITHUB_OUTPUT" echo "relay_url=https://$relay_domain" >> "$GITHUB_OUTPUT" - # node-pty publishes no Linux prebuilt and the WSL backend runs under the - # distro's own (Linux) Node, which can't load the Windows/Electron binary. We - # build the Linux pty.node here, on Linux, and hand it to the Windows packaging - # job — the Windows artifact then ships a ready WSL backend binary with no - # cross-compiling and no first-launch compiler/node-gyp/network on the user's - # machine. node-pty is N-API, so one binary works across all WSL Node versions. - build_wsl_node_pty: - name: Build WSL node-pty (linux-x64) + # The platform-independent JS (server bundle, web client, Electron main) is + # built exactly once here and handed to every platform job as `js-bundle`. + # The relay/Clerk values are baked into the bundle, so they belong to this + # job rather than to the packaging jobs. + build_bundle: + name: Build JS bundle # Same gating as relay_public_config: only the release commit is needed, so # this runs alongside preflight. See the condition comment there. - needs: [resolve_commit] - if: | - needs.resolve_commit.result == 'success' && - (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true') - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.resolve_commit.outputs.ref }} - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=t3... - - - name: Build node-pty linux-x64 prebuild - shell: bash - run: | - set -euo pipefail - # Resolve node-pty from apps/server (where it's a dependency) and build - # its native binary from source for Linux. node-addon-api resolves from - # node-pty's own dependency tree, so node-gyp has everything it needs. - pty_pkg="$(node -e "console.log(require.resolve('node-pty/package.json', { paths: ['$GITHUB_WORKSPACE/apps/server'] }))")" - pty_dir="$(dirname "$pty_pkg")" - ( cd "$pty_dir" && npx --yes node-gyp rebuild ) - mkdir -p wsl-prebuild - cp "$pty_dir/build/Release/pty.node" wsl-prebuild/pty.node - file wsl-prebuild/pty.node - - - name: Upload node-pty linux-x64 prebuild - uses: actions/upload-artifact@v7 - with: - name: wsl-node-pty-x64 - path: wsl-prebuild/pty.node - if-no-files-found: error - - build: - name: Build ${{ matrix.label }} - # build_wsl_node_pty stays in `needs` so it runs first and its artifact is - # available to download, but only the Windows matrix entry consumes it. We - # therefore gate the job on preflight + relay (must succeed) WITHOUT requiring - # build_wsl_node_pty, so a failed Linux prebuild doesn't skip the macOS/Linux - # builds. `!cancelled()` (not `!failure()`) lets the job run even when - # build_wsl_node_pty failed; the Windows-only download step below then fails - # that single platform if the prebuild is missing. - needs: [preflight, relay_public_config, build_wsl_node_pty] + needs: [preflight, relay_public_config] if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} - runs-on: ${{ matrix.runner }} + runs-on: blacksmith-32vcpu-ubuntu-2404 timeout-minutes: 30 env: T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} - strategy: - fail-fast: false - matrix: - include: - - label: macOS arm64 - runner: blacksmith-12vcpu-macos-26 - platform: mac - target: dmg - arch: arm64 - rust_target: aarch64-apple-darwin - resource_key: darwin-arm64 - - label: macOS x64 - runner: blacksmith-12vcpu-macos-26 - platform: mac - target: dmg - arch: x64 - rust_target: x86_64-apple-darwin - resource_key: darwin-x64 - - label: Linux x64 - runner: blacksmith-32vcpu-ubuntu-2404 - platform: linux - target: AppImage - arch: x64 - rust_target: x86_64-unknown-linux-gnu - resource_key: linux-x64 - - label: Windows x64 - runner: blacksmith-32vcpu-windows-2025 - platform: win - target: nsis - arch: x64 - rust_target: x86_64-pc-windows-msvc - resource_key: win32-x64 - # - label: Windows arm64 - # runner: windows-11-arm - # platform: win - # target: nsis - # arch: arm64 steps: - name: Checkout uses: actions/checkout@v6 @@ -450,22 +408,9 @@ jobs: uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json - cache: ${{ matrix.platform != 'win' }} + cache: true run-install: false - - name: Resolve Windows package cache path - if: matrix.platform == 'win' - id: package_cache_path - shell: pwsh - run: '"path=$(vp pm cache dir)" >> $env:GITHUB_OUTPUT' - - - name: Cache Windows packages - if: matrix.platform == 'win' - uses: actions/cache@v6 - with: - path: ${{ steps.package_cache_path.outputs.path }} - key: windows-release-packages-v1-${{ matrix.arch }}-${{ hashFiles('pnpm-lock.yaml') }} - # pnpm checks the lockfile and policy before reusing this result. A missing # artifact leaves the cache empty, so installation runs the checks again. - name: Download dependency verification @@ -475,33 +420,10 @@ jobs: name: release-dependency-verification path: ${{ runner.temp }}/pnpm-metadata - - name: Install desktop dependencies + - name: Install bundle dependencies env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... - - - name: Cache resource monitor - id: resource_monitor_cache - uses: actions/cache@v6 - with: - path: native/resource-monitor/target/${{ matrix.rust_target }}/release/t3-resource-monitor${{ matrix.platform == 'win' && '.exe' || '' }} - key: resource-monitor-${{ matrix.rust_target }}-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} - - - name: Cache Linux capture helpers - if: matrix.platform == 'linux' - id: capture_helper_cache - uses: actions/cache@v6 - with: - path: | - native/kde-snap-shot/target/${{ matrix.rust_target }}/release/t3-kde-snap-shot - native/hyprland-snap-shot/target/${{ matrix.rust_target }}/release/t3-hyprland-snap-shot - key: linux-capture-helpers-${{ matrix.rust_target }}-${{ hashFiles('native/kde-snap-shot/Cargo.lock', 'native/kde-snap-shot/Cargo.toml', 'native/kde-snap-shot/src/**', 'native/hyprland-snap-shot/Cargo.lock', 'native/hyprland-snap-shot/Cargo.toml', 'native/hyprland-snap-shot/src/**', 'native/hyprland-snap-shot/protocols/**') }} - - - name: Setup Rust - if: steps.resource_monitor_cache.outputs.cache-hit != 'true' || (matrix.platform == 'linux' && steps.capture_helper_cache.outputs.cache-hit != 'true') - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.rust_target }} + run: vp install --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... - name: Download relay client tracing config uses: actions/download-artifact@v8 @@ -520,275 +442,206 @@ jobs: - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - - name: Download WSL node-pty prebuild - if: matrix.platform == 'win' - uses: actions/download-artifact@v7 - with: - name: wsl-node-pty-x64 - path: wsl-prebuild - - - name: Install Spectre-mitigated MSVC libs - if: matrix.platform == 'win' - shell: pwsh - run: | - $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" - $installPath = & $vswhere -products * -latest -property installationPath - $setupExe = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\setup.exe" - $proc = Start-Process -FilePath $setupExe ` - -ArgumentList "modify", "--installPath", "`"$installPath`"", "--add", ` - "Microsoft.VisualStudio.Component.VC.Runtimes.x86.x64.Spectre", "--quiet", "--norestart" ` - -Wait -PassThru -NoNewWindow - if ($null -eq $proc -or $proc.ExitCode -ne 0) { - $code = if ($null -ne $proc) { $proc.ExitCode } else { 1 } - Write-Error "Visual Studio Installer failed with exit code $code" - exit $code - } - - uses: ./.github/actions/setup-apt-mirrors - if: matrix.platform == 'linux' - - - name: Install Linux desktop build libraries - if: matrix.platform == 'linux' - shell: bash - run: | - sudo apt-get update - sudo apt-get install -y libsecret-1-dev pkg-config - if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then - sudo apt-get install -y imagemagick - fi - if command -v magick >/dev/null 2>&1; then - magick -version - else - convert -version - fi - - - name: Prepare Azure Trusted Signing - if: matrix.platform == 'win' - shell: pwsh - env: - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} - AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} - run: | - $ErrorActionPreference = "Stop" - - $requiredSecrets = @( - $env:AZURE_TENANT_ID, - $env:AZURE_CLIENT_ID, - $env:AZURE_CLIENT_SECRET, - $env:AZURE_TRUSTED_SIGNING_ENDPOINT, - $env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME, - $env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME, - $env:AZURE_TRUSTED_SIGNING_PUBLISHER_NAME - ) - if ($requiredSecrets | Where-Object { [string]::IsNullOrWhiteSpace($_) }) { - Write-Host "Azure Trusted Signing disabled; skipping TrustedSigning module preparation." - exit 0 - } - - try { - Install-PackageProvider ` - -Name NuGet ` - -MinimumVersion 2.8.5.201 ` - -Force ` - -Scope CurrentUser ` - -ErrorAction Stop - } catch { - Write-Warning "Could not bootstrap NuGet package provider. Continuing because the runner may already have a usable provider. $($_.Exception.Message)" - } - - Install-Module ` - -Name TrustedSigning ` - -MinimumVersion 0.5.0 ` - -Force ` - -AllowClobber ` - -Repository PSGallery ` - -Scope CurrentUser ` - -ErrorAction Stop - - Import-Module TrustedSigning -MinimumVersion 0.5.0 -Force - Get-Command Invoke-TrustedSigning -ErrorAction Stop - - $moduleRoots = @( - [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "PowerShell", "Modules"), - [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "WindowsPowerShell", "Modules"), - [System.IO.Path]::Combine($env:ProgramFiles, "PowerShell", "Modules"), - [System.IO.Path]::Combine($env:ProgramFiles, "WindowsPowerShell", "Modules") - ) - $modulePathEntries = @($moduleRoots + ($env:PSModulePath -split ";")) | - Where-Object { $_ -and (Test-Path $_) } | - Select-Object -Unique - "PSModulePath=$($modulePathEntries -join ';')" >> $env:GITHUB_ENV - - - name: Build desktop artifact - shell: bash - env: - pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} - T3CODE_DESKTOP_REUSE_LINUX_CAPTURE_HELPERS: ${{ steps.capture_helper_cache.outputs.cache-hit == 'true' }} - CSC_LINK: ${{ secrets.CSC_LINK }} - CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} - MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} - T3CODE_CLERK_PASSKEY_RP_DOMAINS: ${{ vars.CLERK_PASSKEY_RP_DOMAINS }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} - AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} - run: | - args=( - --platform "${{ matrix.platform }}" - --target "${{ matrix.target }}" - --arch "${{ matrix.arch }}" - --build-version "${{ needs.preflight.outputs.version }}" - --verbose - ) - - has_all() { - for value in "$@"; do - if [[ -z "$value" ]]; then - return 1 - fi - done - return 0 - } - - if [[ "${{ matrix.platform }}" == "mac" ]]; then - if has_all "$CSC_LINK" "$CSC_KEY_PASSWORD" "$APPLE_API_KEY" "$APPLE_API_KEY_ID" "$APPLE_API_ISSUER"; then - if ! has_all "$APPLE_TEAM_ID" "$MACOS_PROVISIONING_PROFILE"; then - echo "macOS signing is configured, but APPLE_TEAM_ID or MACOS_PROVISIONING_PROFILE is missing." >&2 - exit 1 - fi - - key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" - printf '%s' "$APPLE_API_KEY" > "$key_path" - export APPLE_API_KEY="$key_path" - - profile_path="$RUNNER_TEMP/t3code.provisionprofile" - printf '%s' "$MACOS_PROVISIONING_PROFILE" | base64 -D > "$profile_path" - security cms -D -i "$profile_path" >/dev/null - export T3CODE_APPLE_TEAM_ID="$APPLE_TEAM_ID" - export T3CODE_MACOS_PROVISIONING_PROFILE="$profile_path" - - echo "macOS signing enabled." - args+=(--signed) - else - echo "macOS signing disabled (missing one or more Apple signing secrets)." - fi - elif [[ "${{ matrix.platform }}" == "win" ]]; then - # Bundle the Linux node-pty binary built by the build_wsl_node_pty job - # so the packaged WSL backend ships a ready binary (no first-launch - # compile). Required for a working WSL backend on Windows. - args+=(--wsl-prebuild "$GITHUB_WORKSPACE/wsl-prebuild/pty.node") - if has_all \ - "$AZURE_TENANT_ID" \ - "$AZURE_CLIENT_ID" \ - "$AZURE_CLIENT_SECRET" \ - "$AZURE_TRUSTED_SIGNING_ENDPOINT" \ - "$AZURE_TRUSTED_SIGNING_ACCOUNT_NAME" \ - "$AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME" \ - "$AZURE_TRUSTED_SIGNING_PUBLISHER_NAME"; then - echo "Windows signing enabled (Azure Trusted Signing)." - args+=(--signed) - else - echo "Windows signing disabled (missing one or more Azure Trusted Signing secrets)." - fi - else - echo "Signing disabled for ${{ matrix.platform }}." - fi - - vp run dist:desktop:artifact "${args[@]}" - - - name: Collect release assets - shell: bash - run: | - set -euo pipefail - mkdir -p release-publish - - shopt -s nullglob - for pattern in \ - "release/*.dmg" \ - "release/*.zip" \ - "release/*.AppImage" \ - "release/*.exe" \ - "release/*.blockmap" \ - "release/*.yml"; do - for file in $pattern; do - cp "$file" release-publish/ - done - done - - if [[ "${{ matrix.platform }}" == "mac" && "${{ matrix.arch }}" != "arm64" ]]; then - shopt -s nullglob - for manifest in release-publish/*-mac.yml; do - mv "$manifest" "${manifest%.yml}-${{ matrix.arch }}.yml" - done - fi - - # Enable if Windows arm64 builds are enabled. - # Windows updater metadata is channel-specific (for example - # "latest.yml" or "nightly.yml"). Suffix each per-arch copy so the - # release job can merge matching arm64/x64 manifests back into one - # canonical manifest per channel. - # if [[ "${{ matrix.platform }}" == "win" ]]; then - # shopt -s nullglob - # for manifest in release-publish/*.yml; do - # mv "$manifest" "${manifest%.yml}-win-${{ matrix.arch }}.yml" - # done - # fi - - - name: Collect resource monitor - shell: bash - run: | - set -euo pipefail - binary_name="t3-resource-monitor" - if [[ "${{ matrix.platform }}" == "win" ]]; then - binary_name="${binary_name}.exe" - fi - source_path="native/resource-monitor/target/${{ matrix.rust_target }}/release/${binary_name}" - target_dir="resource-monitor-publish/${{ matrix.resource_key }}" - mkdir -p "$target_dir" - cp "$source_path" "$target_dir/$binary_name" + # @t3tools/desktop#build compiles the Linux browser secret helper on a + # Linux host before packing, and that needs libsecret headers. + - name: Install browser secret helper build libraries + run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - - name: Upload build artifacts - uses: actions/upload-artifact@v7 - with: - name: desktop-${{ matrix.platform }}-${{ matrix.arch }} - path: release-publish/* - if-no-files-found: error + # Runs t3#build (which depends on @t3tools/web#build) and + # @t3tools/desktop#build, so apps/server/dist holds the server bundle + # plus the web client and apps/desktop/dist-electron the Electron main. + - name: Build JS bundle + run: vp run build:desktop - - name: Upload resource monitor + # Two paths under apps/ so the artifact root is apps/; consumers download + # into `apps` to restore both at their original locations. + - name: Upload JS bundle uses: actions/upload-artifact@v7 with: - name: resource-monitor-${{ matrix.resource_key }} - path: resource-monitor-publish/${{ matrix.resource_key }}/* + name: js-bundle + path: | + apps/server/dist + apps/desktop/dist-electron if-no-files-found: error + retention-days: 1 + # One job per platform and architecture (see release-desktop.yml), each on + # hardware of its own architecture, and each gated only on what it consumes: + # every platform needs the JS bundle, and the Windows jobs also need the + # same-arch Linux job, whose CLI archive they embed as the WSL runtime. Every + # job builds the desktop app; all but macOS x64 also build the CLI archive + # for their platform, so a target either ships fully or not at all. + desktop_mac_arm64: + name: Desktop macOS arm64 + needs: [preflight, relay_public_config, build_bundle] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} + uses: ./.github/workflows/release-desktop.yml + secrets: inherit + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.ref }} + release_channel: ${{ needs.preflight.outputs.release_channel }} + clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ needs.relay_public_config.outputs.relay_url }} + label: macOS arm64 + runner: blacksmith-12vcpu-macos-26 + platform: mac + target: dmg + arch: arm64 + rust_target: aarch64-apple-darwin + resource_key: darwin-arm64 + cli_archive: true + + desktop_mac_x64: + name: Desktop macOS x64 + needs: [preflight, relay_public_config, build_bundle] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} + uses: ./.github/workflows/release-desktop.yml + secrets: inherit + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.ref }} + release_channel: ${{ needs.preflight.outputs.release_channel }} + clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ needs.relay_public_config.outputs.relay_url }} + label: macOS x64 + runner: blacksmith-12vcpu-macos-26 + platform: mac + target: dmg + arch: x64 + rust_target: x86_64-apple-darwin + resource_key: darwin-x64 + # No CLI archive: Node single-executables are unsupported on x64 macOS + # (the SEA docs list macOS as arm64 only) and the built binary segfaults + # on start. The x64 desktop app is Electron and unaffected. + cli_archive: false + + desktop_linux_x64: + name: Desktop Linux x64 + needs: [preflight, relay_public_config, build_bundle] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} + uses: ./.github/workflows/release-desktop.yml + secrets: inherit + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.ref }} + release_channel: ${{ needs.preflight.outputs.release_channel }} + clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ needs.relay_public_config.outputs.relay_url }} + label: Linux x64 + runner: blacksmith-32vcpu-ubuntu-2404 + platform: linux + target: AppImage + arch: x64 + rust_target: x86_64-unknown-linux-gnu + resource_key: linux-x64 + cli_archive: true + + # node-pty has no Linux prebuild and compiles from source, so the arm64 app + # and archive are built on arm64 hardware rather than cross-built. + desktop_linux_arm64: + name: Desktop Linux arm64 + needs: [preflight, relay_public_config, build_bundle] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} + uses: ./.github/workflows/release-desktop.yml + secrets: inherit + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.ref }} + release_channel: ${{ needs.preflight.outputs.release_channel }} + clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ needs.relay_public_config.outputs.relay_url }} + label: Linux arm64 + runner: ubuntu-24.04-arm + platform: linux + target: AppImage + arch: arm64 + rust_target: aarch64-unknown-linux-gnu + resource_key: linux-arm64 + cli_archive: true + + # The Windows jobs embed the same-arch Linux CLI archive as the WSL runtime. + # `!cancelled()` (not `!failure()`) still lets them start when that Linux job + # failed; the download step inside then fails this single platform if the + # archive is missing. + desktop_win_x64: + name: Desktop Windows x64 + needs: [preflight, relay_public_config, build_bundle, desktop_linux_x64] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} + uses: ./.github/workflows/release-desktop.yml + secrets: inherit + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.ref }} + release_channel: ${{ needs.preflight.outputs.release_channel }} + clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ needs.relay_public_config.outputs.relay_url }} + label: Windows x64 + runner: blacksmith-32vcpu-windows-2025 + platform: win + target: nsis + arch: x64 + rust_target: x86_64-pc-windows-msvc + resource_key: win32-x64 + cli_archive: true + + desktop_win_arm64: + name: Desktop Windows arm64 + needs: [preflight, relay_public_config, build_bundle, desktop_linux_arm64] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} + uses: ./.github/workflows/release-desktop.yml + secrets: inherit + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.ref }} + release_channel: ${{ needs.preflight.outputs.release_channel }} + clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ needs.relay_public_config.outputs.relay_url }} + label: Windows arm64 + runner: windows-11-arm + platform: win + target: nsis + arch: arm64 + rust_target: aarch64-pc-windows-msvc + resource_key: win32-arm64 + cli_archive: true + + # npm gets the same bytes as the GitHub Release: the launcher plus one + # package per CLI archive. Preview publishes too, under the `preview` + # dist-tag, which nothing resolves unless asked for by name. publish_cli: name: Publish CLI to npm - needs: [preflight, relay_public_config, quality, build] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.build.result == 'success' }} + needs: + [ + preflight, + relay_public_config, + quality, + desktop_mac_arm64, + desktop_linux_x64, + desktop_linux_arm64, + desktop_win_x64, + desktop_win_arm64, + ] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }} runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 + timeout-minutes: 15 permissions: contents: read id-token: write - env: - T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} - T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} - T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} - T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} steps: - name: Checkout uses: actions/checkout@v6 @@ -807,56 +660,45 @@ jobs: run-install: | args: - --filter=t3... - - --filter=@t3tools/web... - --filter=@t3tools/scripts... - - name: Download relay client tracing config + - name: Download all CLI archives uses: actions/download-artifact@v8 with: - name: relay-client-tracing-config - path: ${{ runner.temp }}/relay-client-tracing - - - name: Load relay client tracing config - shell: bash - run: | - config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" - tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" - echo "::add-mask::$tracing_token" - cat "$config_path" >> "$GITHUB_ENV" - - - name: Align package versions to release version - run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - - # The t3 build task depends on @t3tools/web#build, so the web client is - # built (once) as part of this step. - - name: Build CLI package - run: vp run --filter t3 build + pattern: cli-* + merge-multiple: true + path: release-cli - - name: Download resource monitors - uses: actions/download-artifact@v8 - with: - pattern: resource-monitor-* - path: ${{ runner.temp }}/resource-monitors + - name: Build npm packages from CLI archives + run: node scripts/build-npm-platform-packages.ts --archives-dir release-cli --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages - - name: Bundle resource monitors into CLI package - shell: bash + # A dry run of every package first: an auth or scope error here (the + # @t3code org missing, a package without a trusted publisher) fails + # before anything is live, instead of after some platforms already are. + - name: Check npm publish access (dry run) run: | - set -euo pipefail - for artifact_dir in "$RUNNER_TEMP"/resource-monitors/resource-monitor-*; do - resource_key="${artifact_dir##*/resource-monitor-}" - target_dir="apps/server/dist/resource-monitor/${resource_key}" - mkdir -p "$target_dir" - cp "$artifact_dir"/t3-resource-monitor* "$target_dir/" - chmod +x "$target_dir"/t3-resource-monitor 2>/dev/null || true - done + if ! node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --dry-run --verbose; then + echo "::error::npm publish --dry-run failed. Make sure the @t3code npm org exists and that t3 and every @t3code/t3- package has a trusted publisher registered for .github/workflows/release.yml (see docs/operations/release.md)." >&2 + exit 1 + fi - - name: Publish CLI package - run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose + - name: Publish CLI packages + run: node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --verbose release: name: Publish GitHub Release - needs: [preflight, build, publish_cli] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' && needs.publish_cli.result == 'success' }} + needs: + [ + preflight, + desktop_mac_arm64, + desktop_mac_x64, + desktop_linux_x64, + desktop_linux_arm64, + desktop_win_x64, + desktop_win_arm64, + publish_cli, + ] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' && needs.publish_cli.result == 'success' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 30 permissions: @@ -887,7 +729,51 @@ jobs: merge-multiple: true path: release-assets + - name: Download all CLI archives + uses: actions/download-artifact@v8 + with: + pattern: cli-* + merge-multiple: true + path: release-assets + + # Installers verify archives against this file, so it is written from the + # signed bytes that get uploaded, never from an earlier stage. + - name: Write CLI archive checksums + shell: bash + run: | + set -euo pipefail + cd release-assets + shopt -s nullglob + archives=(t3-*.tar.gz t3-*.zip) + if [[ ${#archives[@]} -eq 0 ]]; then + echo "No CLI archives were produced." >&2 + exit 1 + fi + sha256sum "${archives[@]}" > SHA256SUMS + cat SHA256SUMS + + # The desktop build omits the publish config for preview versions, so + # electron-builder emits no updater manifests for them. Refuse to publish + # if one shows up anyway: a `latest*.yml` or `nightly*.yml` on a preview + # release is what would let a stable or nightly install update onto it. + - name: Refuse updater metadata on preview releases + if: needs.preflight.outputs.release_channel == 'preview' + shell: bash + run: | + set -euo pipefail + shopt -s nullglob extglob + # builder-debug.yml is electron-builder's config dump, not a feed. + updater_files=(release-assets/!(builder-debug).yml release-assets/*.blockmap) + if [[ ${#updater_files[@]} -ne 0 ]]; then + printf 'Preview releases must not carry updater metadata, found: %s\n' "${updater_files[*]}" >&2 + exit 1 + fi + + # electron-updater reads one manifest per platform and channel and picks + # the file entry whose name carries the running arch, so the per-arch + # manifests the build jobs wrote are merged back into that one file. - name: Merge macOS updater manifests + if: needs.preflight.outputs.release_channel != 'preview' run: | shopt -s nullglob for x64_manifest in release-assets/*-mac-x64.yml; do @@ -898,6 +784,60 @@ jobs: fi done + - name: Merge Windows updater manifests + if: needs.preflight.outputs.release_channel != 'preview' + run: | + shopt -s nullglob + for x64_manifest in release-assets/*-win-x64.yml; do + arm64_manifest="${x64_manifest%-x64.yml}-arm64.yml" + merged_manifest="${x64_manifest%-win-x64.yml}.yml" + if [[ -f "$arm64_manifest" ]]; then + node scripts/merge-update-manifests.ts --platform win "$x64_manifest" "$arm64_manifest" "$merged_manifest" + rm -f "$x64_manifest" "$arm64_manifest" + else + mv "$x64_manifest" "$merged_manifest" + fi + done + + # Updater manifests and blockmaps are what electron-updater consumes. + # They are only listed for channels an updater is meant to follow. + - id: release_files + name: Resolve release asset list + shell: bash + run: | + { + echo 'files<> "$GITHUB_OUTPUT" + + # A preview release gets a warning instead of generated notes. Generated + # notes would list every commit since the previous preview, which is + # unmerged branch history no one should read as a changelog, and would + # make the release look like any other build to someone browsing the + # releases page. + - name: Write preview release notes + if: needs.preflight.outputs.release_channel == 'preview' + shell: bash + run: | + cat > release-notes.md <<'EOF' + > [!WARNING] + > **This is a preview build. Do not install it unless you know exactly why you are here.** + > + > Preview builds are cut by maintainers from unreleased branches to exercise the release pipeline. They can be broken, receive no fixes, are never offered as updates, and are not supported. If you want T3 Code, install the [latest release](https://github.com/pingdotgg/t3code/releases/latest) or a nightly instead. + + Built from `${{ needs.preflight.outputs.ref }}`. + EOF + - name: Publish release if: needs.preflight.outputs.previous_tag != '' uses: softprops/action-gh-release@v3 @@ -905,17 +845,12 @@ jobs: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} name: ${{ needs.preflight.outputs.release_name }} - generate_release_notes: true + generate_release_notes: ${{ needs.preflight.outputs.release_channel != 'preview' }} + body_path: ${{ needs.preflight.outputs.release_channel == 'preview' && 'release-notes.md' || '' }} previous_tag: ${{ needs.preflight.outputs.previous_tag }} prerelease: ${{ needs.preflight.outputs.is_prerelease }} make_latest: ${{ needs.preflight.outputs.make_latest }} - files: | - release-assets/*.dmg - release-assets/*.zip - release-assets/*.AppImage - release-assets/*.exe - release-assets/*.blockmap - release-assets/*.yml + files: ${{ steps.release_files.outputs.files }} fail_on_unmatched_files: true token: ${{ github.token }} @@ -926,23 +861,18 @@ jobs: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} name: ${{ needs.preflight.outputs.release_name }} - generate_release_notes: true + generate_release_notes: ${{ needs.preflight.outputs.release_channel != 'preview' }} + body_path: ${{ needs.preflight.outputs.release_channel == 'preview' && 'release-notes.md' || '' }} prerelease: ${{ needs.preflight.outputs.is_prerelease }} make_latest: ${{ needs.preflight.outputs.make_latest }} - files: | - release-assets/*.dmg - release-assets/*.zip - release-assets/*.AppImage - release-assets/*.exe - release-assets/*.blockmap - release-assets/*.yml + files: ${{ steps.release_files.outputs.files }} fail_on_unmatched_files: true token: ${{ github.token }} publish_aur: name: Publish AUR package needs: [preflight, release] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }} + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} uses: ./.github/workflows/publish-aur.yml with: release_tag: ${{ needs.preflight.outputs.tag }} @@ -952,7 +882,7 @@ jobs: deploy_web: name: Deploy hosted web app needs: [preflight, relay_public_config, release] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.release.result == 'success' }} + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 env: @@ -1206,6 +1136,7 @@ jobs: if: | always() && !cancelled() && needs.preflight.result == 'success' && + needs.preflight.outputs.release_channel != 'preview' && needs.relay_public_config.result == 'success' && needs.release.result == 'success' && needs.deploy_web.result == 'success' && diff --git a/.gitignore b/.gitignore index 57262578a786..8482c5a290e1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules *.log *.tsbuildinfo apps/*/dist +apps/*/dist-exe infra/*/dist .astro packages/*/dist @@ -30,6 +31,8 @@ dist-electron/ .electron-runtime/ .showcase/ apps/mobile/.showcase/ +apps/mobile/.generated/ +/.generated/ artifacts/app-store/screenshots/ .github/pr-assets/ native/**/target/ @@ -38,3 +41,6 @@ node_modules/ *.log .env* !.env.example + +# pnpm content-addressable store; never belongs in the repo. +.pnpm-store/ diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index 99d7f3cd22ba..e38c0b040af0 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -2,7 +2,7 @@ title: Effect Service Conventions model: gpt-5-6-sol effort: medium -input: full_diff +input: incremental tools: - browse_code - modify_pr @@ -12,14 +12,11 @@ include: - "infra/**/*.ts" exclude: - "**/*.test.ts" -labels: - - vouch:trusted - - macroscope-review requires: - Check maxBudgetPerRun: 5 maxBudgetPerPR: 25 -conclusion: failure +conclusion: neutral showToolCalls: true --- diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index d2e450235baa..b90c81ab0a49 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -1,8 +1,8 @@ --- title: UI Consistency -model: gpt-5-6-terra +model: gpt-5-6-sol effort: medium -input: full_diff +input: incremental tools: - browse_code - modify_pr @@ -11,14 +11,11 @@ include: - "apps/web/src/**/*.css" exclude: - "apps/web/src/**/*.test.tsx" -labels: - - vouch:trusted - - macroscope-review requires: - Check maxBudgetPerRun: 2 maxBudgetPerPR: 10 -conclusion: failure +conclusion: neutral --- # UI consistency review 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 + + )} +
    + +