diff --git a/.agents/skills/ios-debugger-agent/LICENSE b/.agents/skills/ios-debugger-agent/LICENSE new file mode 100644 index 000000000000..0d193abe04f2 --- /dev/null +++ b/.agents/skills/ios-debugger-agent/LICENSE @@ -0,0 +1,21 @@ +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 new file mode 100644 index 000000000000..7afa579383d9 --- /dev/null +++ b/.agents/skills/ios-debugger-agent/SKILL.md @@ -0,0 +1,64 @@ +--- +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 new file mode 100644 index 000000000000..094bae6620be --- /dev/null +++ b/.agents/skills/ios-debugger-agent/agents/openai.yaml @@ -0,0 +1,9 @@ +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 new file mode 100644 index 000000000000..0d193abe04f2 --- /dev/null +++ b/.agents/skills/ios-simulator-browser/LICENSE @@ -0,0 +1,21 @@ +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 new file mode 100644 index 000000000000..74b97530799c --- /dev/null +++ b/.agents/skills/ios-simulator-browser/SKILL.md @@ -0,0 +1,51 @@ +--- +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 new file mode 100644 index 000000000000..9e8cdf7a16b0 --- /dev/null +++ b/.agents/skills/ios-simulator-browser/agents/openai.yaml @@ -0,0 +1,4 @@ +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 new file mode 100644 index 000000000000..c3dc1c103d4d --- /dev/null +++ b/.agents/skills/test-t3-app/SKILL.md @@ -0,0 +1,71 @@ +--- +name: test-t3-app +description: Launch 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, dev server lifecycle, and direct SQLite inspection or fixture seeding. Use when an agent needs to run T3 locally, test UI behavior in a browser, recover from an expired or consumed pairing token, isolate dev state, or prepare test data in state.sqlite. +--- + +# Test T3 App + +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. + +## Start an isolated web environment + +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 --home-dir `. +4. Keep the terminal session alive and read the selected server port, web port, base directory, and pairing URL from its output. + +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. + +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. + +## 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. + +Treat pairing URLs as secrets. Do not copy them into final responses, screenshots, committed files, or durable logs. A pairing token is short-lived and single-use; opening the URL in another browser or opening it twice can consume it. + +## Recover a consumed or expired pairing token + +Create another token against the same database and web URL as the running dev server: + +```bash +T3CODE_PORT= node apps/server/src/bin.ts auth pairing create \ + --base-dir \ + --dev-url \ + --base-url \ + --ttl 15m \ + --label agent-ui-test +``` + +Use the `Pair URL` from this command once. Derive `` and `` from the current dev-runner output, including any automatically selected port offset. Setting `T3CODE_PORT` keeps the administrative CLI from probing for an unrelated free port. + +Always pass `--dev-url` for a dev-runner environment so the generated pairing URL uses the current web origin. An explicit base directory stores runtime state in `/userdata`; the `/dev` fallback is only used by an implicit dev home. Use `auth pairing list` to inspect active token metadata; it intentionally cannot reveal token secrets. + +## 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. + +## Finish the test + +Stop the dev process with its terminal interrupt. Preserve the isolated base directory when it contains useful reproduction evidence; otherwise remove only a path that was created for this test after resolving and verifying the exact target. A fresh isolated base directory is 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`. diff --git a/.agents/skills/test-t3-app/agents/openai.yaml b/.agents/skills/test-t3-app/agents/openai.yaml new file mode 100644 index 000000000000..a3b89c95e608 --- /dev/null +++ b/.agents/skills/test-t3-app/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "T3 App Testing" + short_description: "Launch and seed isolated T3 test environments" + default_prompt: "Use $test-t3-app to launch an isolated T3 development environment and test it in the browser." diff --git a/.agents/skills/test-t3-app/references/sqlite-fixtures.md b/.agents/skills/test-t3-app/references/sqlite-fixtures.md new file mode 100644 index 000000000000..8eca543cf979 --- /dev/null +++ b/.agents/skills/test-t3-app/references/sqlite-fixtures.md @@ -0,0 +1,58 @@ +# SQLite fixtures + +Load this reference only when inspecting or seeding local T3 state directly. + +## Select the correct database + +When `--base-dir` or `--home-dir` is explicit, runtime state lives under `/userdata` and the database path is `/userdata/state.sqlite`. The `/dev` state directory is only the fallback for an implicit development home, preventing an ordinary `vp run dev` from touching production state. + +Start the target runtime once before seeding so all migrations have run. Use an isolated base directory. Stop the server before writes to avoid racing application state or an active projection. + +## Use the helper + +List tables: + +```bash +node apps/server/scripts/t3-sqlite-state.ts query \ + --base-dir \ + --sql "SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY name" +``` + +Inspect current columns before writing a fixture: + +```bash +node apps/server/scripts/t3-sqlite-state.ts query \ + --base-dir \ + --sql "PRAGMA table_info(projection_threads)" +``` + +Apply a SQL fixture from a file: + +```bash +node apps/server/scripts/t3-sqlite-state.ts exec \ + --base-dir \ + --file /tmp/t3-seed.sql +``` + +Use one statement per invocation for both `query` and `exec`; the helper wraps writes in a transaction and prints the backup path after a successful mutation. Use a single insert with multiple value rows when a fixture needs several records. + +## Seed projection data carefully + +The web UI primarily reads these projection tables: + +- `projection_projects` +- `projection_threads` +- `projection_thread_messages` +- `projection_thread_activities` +- `projection_thread_sessions` +- `projection_turns` +- `projection_pending_approvals` +- `projection_thread_proposed_plans` + +Inspect `PRAGMA table_info()` and the current migrations under `apps/server/src/persistence/Migrations/` before constructing inserts. Keep identifiers unique, timestamps as ISO strings, JSON columns valid, and related project/thread/turn IDs consistent. + +For a substantial current example, inspect `seedDatabase` in `scripts/mobile-showcase-environment.ts`. Adapt its column set to the target database instead of assuming copied SQL remains current. + +Direct projection writes are appropriate for ephemeral visual states, edge-case counts, long titles, activity lists, and similar UI fixtures. They do not create a coherent orchestration event history. Do not modify `orchestration_events` unless the test specifically exercises projector internals, and do not use direct projection writes to claim backend business behavior works. + +Use the app's commands or APIs for behavior tests. Use `node apps/server/src/bin.ts auth ...` for auth state rather than editing `auth_pairing_links` or `auth_sessions`. diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md new file mode 100644 index 000000000000..f3e3dcfd8ce5 --- /dev/null +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -0,0 +1,188 @@ +--- +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. +--- + +# 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. + +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`. + +## Select a viable platform + +Inspect the host and the affected code before launching processes: + +- 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. + +Do not treat unavailable iOS tooling as a blocker when Android is a valid representative target. + +## Choose the lightest valid launch path + +- 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: + +```bash +node apps/server/src/bin.ts serve \ + --host 127.0.0.1 \ + --port \ + --base-dir \ + --no-browser +``` + +Use these client origins: + +- iOS Simulator: `http://127.0.0.1:` +- Android Emulator: `http://10.0.2.2:` +- Physical device: bind the backend to `0.0.0.0` and use the host's reachable LAN origin + +Always enter the complete `http://` origin; the mobile host field otherwise assumes 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 \ + --clear \ + --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 +``` + +Accept the iOS confirmation prompt and dismiss the developer menu when it obscures the app. + +### Android launch + +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 + +Issue a fresh credential against the running backend's exact base directory: + +```bash +T3CODE_PORT= node apps/server/src/bin.ts auth pairing create \ + --base-dir \ + --base-url \ + --ttl 15m \ + --label agent-mobile- +``` + +In PowerShell, set `$env:T3CODE_PORT = ""` first and run the `node ... auth pairing create` command without the leading assignment. + +If the visible Add Environment action is not exposed as a semantic target, open the app's registered route instead of guessing coordinates: + +```bash +xcrun simctl openurl 't3code-dev://connections/new' +adb -s shell am start -W \ + -a android.intent.action.VIEW \ + -d 't3code-dev://connections/new' \ + com.t3tools.t3code.dev +``` + +Run only the command for the selected platform. + +In T3 Code Dev, open Add Environment and enter the complete `` and newly printed `Token`. 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 + +- **Old UI or an old error appears:** verify Metro's worktree, variant, URL, and port before diagnosing the app. +- **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. +- **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. diff --git a/.agents/skills/test-t3-mobile/agents/openai.yaml b/.agents/skills/test-t3-mobile/agents/openai.yaml new file mode 100644 index 000000000000..e9518ce9e04a --- /dev/null +++ b/.agents/skills/test-t3-mobile/agents/openai.yaml @@ -0,0 +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." diff --git a/.claude/skills/test-t3-app b/.claude/skills/test-t3-app new file mode 120000 index 000000000000..b24222ec66d4 --- /dev/null +++ b/.claude/skills/test-t3-app @@ -0,0 +1 @@ +../../.agents/skills/test-t3-app \ No newline at end of file diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 000000000000..a6369d240fc7 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,9 @@ +[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/.env.example b/.env.example index 79b2adaf0c8b..61cdd66d246a 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,10 @@ # Get this from your relay deployment. `infra/relay` deploys update it automatically. # T3CODE_RELAY_URL=https://relay.example.com +# Optional: hosted app origin used by the CLI's out-of-band OAuth flow. +# Defaults to https://app.t3.codes; override to test against a staging deployment. +# T3CODE_HOSTED_APP_URL=https://nightly.app.t3.codes + # Public, ingest-only mobile OpenTelemetry configuration. # T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces # T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000000..213a64995fab --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "xcodebuildmcp": { + "command": "npx", + "args": ["--yes", "xcodebuildmcp@2.6.2", "mcp"], + "env": { + "XCODEBUILDMCP_ENABLED_WORKFLOWS": "simulator,ui-automation,debugging,logging" + } + } + } +} diff --git a/.plans/t3-connect-remote-setup.html b/.plans/t3-connect-remote-setup.html new file mode 100644 index 000000000000..101c293bee1f --- /dev/null +++ b/.plans/t3-connect-remote-setup.html @@ -0,0 +1,257 @@ + + + + + +Plan: seamless `npx t3 connect` for remote boxes + + + +
+ +

Seamless npx t3 connect for remote boxes

+

Design principle: the smallest diff that ships the UX. No relay/infra changes, no new backend surface, no new auth primitives — every step reuses code that already exists. One PR, built as four phases with clear commit boundaries — each phase compiles, passes tests, and leaves the product working, so the PR reviews commit-by-commit. (Phase 4, web-triggered update, is an optional follow-up PR.)

+ +
+$ npx t3 connect

+To set up T3 Connect, open this URL and sign in:
+  https://app.t3.codes/connect#B64URL_STATE_AND_CHALLENGE

+Enter your authentication code: [code]

+Connected as theo@t3.gg!

+Run T3 Code in the background whenever this machine boots? (y/n): y

+T3 Code is set up and ready to go. +
+ +

Why this is a small change

+

The entire t3 connect data plane already works: Clerk PKCE token exchange, encrypted secret store, cloudflared relay-client install, relay environment linking, DPoP tokens. The only broken piece on an SSH box is the redirect: CliTokenManager.login() hardcodes a loopback callback (http://127.0.0.1:34338/callback) that requires a browser on the same machine.

+

We swap that one leg for a hosted out-of-band authorization page and keep everything else. Because PKCE's code_verifier never leaves the box, the displayed one-time code is useless to anyone who sees it — no new token-minting or storage is needed anywhere.

+ +
+

Reused as-is (zero changes)

+
    +
  • exchangeToken() PKCE exchange — apps/server/src/cloud/CliTokenManager.ts:147
  • +
  • Token persistence in ServerSecretStore (cloud-cli-oauth-token)
  • +
  • acquireRelayClientForLink() cloudflared install + progress — cli/connect.ts:146
  • +
  • CliState.setCliDesiredCloudLink() + server-side provisioning on start
  • +
  • All relay endpoints (infra/relay) and contracts — untouched
  • +
  • Existing subcommands login/link/status/unlink/logout — semantics unchanged
  • +
  • Web app Clerk session + hosted-page precedent (routes/pair.tsx, hostedPairing.ts)
  • +
+
+ +

Auth flow (hosted out-of-band OAuth, Clerk PKCE)

+ +
+ + + + + + Remote box — t3 CLI + Laptop — app.t3.codes + Clerk + + + + + + + 1. gen verifier + challenge + state + + + + + 2. user opens /connect#{state,challenge} + + + + + 3. sign in → /oauth/authorize (PKCE) + + + + + 4. redirect /connect/callback?code&state + + + + 5. shows account + authorization code + + + + + 6. user enters code in terminal + + + + + 7. POST /oauth/token {code + verifier} → access/refresh tokens + + + + 8. store token, set desired link, + install relay client → Connected! + +
The verifier never leaves the box (steps 1→7), so the authorization code is worthless if observed. state/challenge ride the URL fragment — they are not secrets.
+
+ +
+

Details that keep it simple

+
    +
  • Stateless URL, no short-link service. The /connect page reads state + code_challenge from the URL fragment and builds the Clerk authorize URL client-side. ~100-char URL — fine to transfer into an SSH session.
  • +
  • State check without a backend: the callback page displays one authorization blob of code.state; the CLI splits it and verifies state matches what it generated. One line on each side, preserves the loopback flow's CSRF check.
  • +
  • Phishing is addressed with copy, not code: the callback page shows which account is being connected ("Connecting as theo@…") and warns: "Only enter this code in a terminal session you started yourself." No mechanism needed.
  • +
  • Code expiry is a non-issue: Clerk auth codes live 10 minutes — the same timeout the existing loopback flow already uses. Wrong/expired code → friendly retry that reprints the URL.
  • +
  • One external config step: register https://app.t3.codes/connect/callback as an allowed redirect URI on the existing Clerk CLI OAuth client. No new client, no new scopes.
  • +
+
+ +

The phases (one PR, one commit each)

+

Ordering is dependency order: each phase is independently revertable and the tree is green at every boundary. Phases 1–3 are the PR; phase 4 ships separately later.

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
PhaseScopeFiles~LOC
1Hosted code page (web-only, purely additive, zero risk). Two static routes modeled on pair.tsx: /connect (ensure Clerk session, then client-side redirect to authorize — it's a static SPA, no server 302) and /connect/callback (validate params, show account + copyable code + safety warning). Both routes guard against non-hosted deployments — redirect to / unless isHostedStaticApp(), same pattern as pair.tsx, since this bundle also ships in local instances. Plus the Clerk dashboard redirect-URI entry.apps/web/src/routes/connect.tsx
apps/web/src/routes/connect.callback.tsx
~200
2CLI out-of-band OAuth flow + single command. Add an out-of-band OAuth login path to CliTokenManager (print URL, Prompt.text for the code, reuse exchangeToken). Make bare t3 connect a handler = login + link (subcommands untouched). Auto-pick headless mode inside SSH sessions (SSH_CONNECTION/SSH_TTY — nothing else); --headless flag as manual override. Loopback stays the default on desktop — no regression.cloud/CliTokenManager.ts (+60)
cloud/publicConfig.ts (+10)
cli/connect.ts (+60)
~150
3Background on boot — Linux first (the SSH case). One new module: pinned runtime install to ~/.t3/runtime/versions/<v> + current symlink, systemd user unit with absolute node/t3 paths, enable-linger. y/n prompt at the end of connect; teardown in logout. Install and service-start failures must land in a log file (under ~/.t3/userdata/logs/) whose path is printed at connect time — systemd user units fail invisibly otherwise. Unit-file generation is pure string-building → trivially testable. macOS launchd / Windows follow as 3b/3c only if wanted.cloud/bootService.ts (new)
cli/connect.ts (+prompt/teardown)
~250
4Web-triggered update (optional follow-up PR; not part of this one, not needed for the core UX). Web detects daemon version < latest-on-channel using the existing version-skew surface + hosted manifest; one authenticated "update" command — client says update, daemon resolves/verifies the version itself (never client-specified — that would be RCE). Stage install → verify → atomic symlink swap → systemctl --user restart. Progress streams reuse the RelayClientInstallProgressEvent pattern.web banner + one control command + daemon update routinelater
+ +

Runtime layout (phase 3)

+
~/.t3/runtime/
+├── versions/0.0.27/        ← npm install --prefix (gets native deps right: node-pty etc.)
+└── current -> versions/0.0.27
+
+~/.config/systemd/user/t3code.service   ← ExecStart=/abs/path/node .../current/.../t3 serve
+loginctl enable-linger $USER            ← survives SSH logout / reboot
+

Why a real npm install and not "reuse the npx binary": the npx cache is ephemeral and t3 ships native deps (node-pty, @ff-labs/fff-node) that need per-platform prebuilds. Why pinned and not npx t3@latest in the unit: a boot-time registry fetch means the box may simply not come up (network down, PATH-less systemd env, nvm). Deterministic boot; updates happen out-of-band (phase 4 follow-up) or by re-running npx t3 connect.

+ +

Explicitly not doing

+
    +
  • Relay / infra / contracts changes — none, in any phase
  • +
  • Short-link service (app.t3.codes/c/AB7K) — only matters for hand-typing; revisit if ever needed
  • +
  • RFC 8628 device grant — wrong UX direction, unverified Clerk support
  • +
  • Auto-update loop in the daemon — web-triggered only (phase 4 follow-up), user stays in control
  • +
  • Project auto-registration — workspace assumed set up; the web UI handles the rest
  • +
  • Changing existing loopback flow, subcommands, or desktop behavior
  • +
+ +

Risks & checks

+
    +
  • Clerk redirect URI: confirm the CLI OAuth client accepts the hosted redirect and that the token endpoint honors PKCE exchange for codes issued to it. Verify in staging before the phase 2 commit. (Only external dependency in the plan.)
  • +
  • systemd user env is minimal: always write absolute paths for node + t3 into the unit; never rely on PATH. Service failures are invisible by default — hence the phase 3 requirement to log to a printed file path.
  • +
  • Linger prompt honesty: the y/n prompt should say the machine becomes reachable via T3 Connect whenever powered on — that's the feature, but say it.
  • +
  • Re-running connect when linked → idempotent: refresh token, re-confirm service, done.
  • +
+ +

Decision log

+
    +
  • Auth: hosted out-of-band OAuth redirect on Clerk PKCE (not relay-brokered pairing, not device grant) — chosen for minimal new surface.
  • +
  • URL: stateless static page, no backend short-link.
  • +
  • Service: real per-user login service (systemd user + linger first); detect + offer install, never silent.
  • +
  • Binary: pinned managed runtime under ~/.t3; interactive npx usage untouched.
  • +
  • Updates: not always-latest; web UI surfaces available updates with one-click trigger (phase 4 follow-up).
  • +
  • Delivery: one PR with a commit per phase (green tree at every boundary), not separate PRs.
  • +
  • Workspace: assumed already set up; no auto-registration.
  • +
+ + + + diff --git a/AGENTS.md b/AGENTS.md index 380a9202683b..ef69591a3404 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,27 +2,16 @@ ## Task Completion Requirements -- `vp check` and `vp run typecheck` must pass before considering tasks completed. - - If changing native mobile code, `vp run lint:mobile` must also pass. -- Use `vp test` for the built-in Vite+ test command and `vp run test` when you specifically need the `test` package script. - -## Project Snapshot - -T3 Code is a minimal web GUI for using coding agents like Codex and Claude. - -This repository is a VERY EARLY WIP. Proposing sweeping changes that improve long-term maintainability is encouraged. - -## Core Priorities - -1. Performance first. -2. Reliability first. -3. Keep behavior predictable under load and during failures (session restarts, reconnects, partial streams). - -If a tradeoff is required, choose correctness and robustness over short-term convenience. - -## Maintainability - -Long term maintainability is a core priority. If you add new functionality, first check if there is shared logic that can be extracted to a separate module. Duplicate logic across multiple files is a code smell and should be avoided. Don't be afraid to change existing code. Don't take shortcuts by just adding local logic to solve a problem. +- Keep local verification focused on the files and packages changed. Run the smallest relevant test set; do not run the full workspace test suite as a routine completion step. + - Use `vp test run ` for focused built-in Vite+ tests. Use `vp run test` only when the affected package specifically requires its `test` script. + - Backend changes must include and run focused tests for the changed behavior. + - Run targeted formatting, lint, and type checks for the affected scope when available. +- Do not run repo-wide `vp check`, `vp run typecheck`, `vp run test`, or equivalent full-suite commands locally unless the user explicitly requests them. CI is responsible for the full verification suite. +- After frontend feature development or any user-visible frontend behavior change, the primary agent must run one integrated verification pass for each affected client surface after integrating the work: + - Web: use the `test-t3-app` skill. Launch one isolated environment, authenticate through the printed pairing URL, and verify the affected flow in the controlled browser. + - Mobile: use the `test-t3-mobile` skill. Connect one representative iOS Simulator or Android Emulator available on the host to one isolated environment and verify the affected flow. On compatible macOS hosts, prefer iOS for cross-platform changes and stream it through serve-sim in the T3 Code in-app browser or another available agent browser; use Android when it is the affected or viable platform. + - Subagents must not independently launch dev servers or repeat integrated client verification unless their delegated task explicitly requires it. + - Stop dev servers, watchers, and other long-running verification processes when the focused verification is complete. ## Package Roles @@ -35,7 +24,6 @@ Long term maintainability is a core priority. If you add new functionality, firs ## Reference Repos - Open-source Codex repo: https://github.com/openai/codex -- Codex-Monitor (Tauri, feature-complete, strong reference implementation): https://github.com/Dimillian/CodexMonitor Use these as implementation references when designing protocol handling, UX flows, and operational safeguards. @@ -47,8 +35,7 @@ agents. - Prefer examples and patterns from the vendored source code over generated guesses or web search results. - Do not edit files under `.repos/` unless explicitly asked. - Do not import from `.repos/`; application code must continue importing from normal package dependencies. -- Manage vendored subtrees with `bun run sync:repos`; use `bun run sync:repos --repo ` to sync one - configured repository. +- Manage vendored subtrees with `vpr sync:repos`; use `vpr sync:repos --repo ` to sync one configured repository. - When updating a dependency with a configured vendored subtree, sync that subtree in the same change so `.repos/` matches the installed dependency version. - When writing Effect code, read `.repos/effect-smol/LLMS.md` first and inspect `.repos/effect-smol/` for diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 92da3f887ac3..15d23f8e1522 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -53,13 +53,16 @@ describe("DesktopEnvironment", () => { assert.equal(environment.isDevelopment, true); assert.equal(environment.appDataDirectory, "/Users/alice/Library/Application Support"); assert.equal(environment.baseDir, "/tmp/t3"); - assert.equal(environment.stateDir, "/tmp/t3/dev"); - assert.equal(environment.desktopSettingsPath, "/tmp/t3/dev/desktop-settings.json"); - assert.equal(environment.clientSettingsPath, "/tmp/t3/dev/client-settings.json"); - assert.equal(environment.savedEnvironmentRegistryPath, "/tmp/t3/dev/saved-environments.json"); - assert.equal(environment.serverSettingsPath, "/tmp/t3/dev/settings.json"); - assert.equal(environment.logDir, "/tmp/t3/dev/logs"); - assert.equal(environment.browserArtifactsDir, "/tmp/t3/dev/browser-artifacts"); + assert.equal(environment.stateDir, "/tmp/t3/userdata"); + assert.equal(environment.desktopSettingsPath, "/tmp/t3/userdata/desktop-settings.json"); + assert.equal(environment.clientSettingsPath, "/tmp/t3/userdata/client-settings.json"); + assert.equal( + environment.savedEnvironmentRegistryPath, + "/tmp/t3/userdata/saved-environments.json", + ); + assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json"); + assert.equal(environment.logDir, "/tmp/t3/userdata/logs"); + assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts"); assert.equal(environment.rootDir, "/repo"); assert.equal(environment.appRoot, "/repo"); assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); @@ -78,7 +81,7 @@ describe("DesktopEnvironment", () => { }), ); - it.effect("derives production state paths under userdata", () => + it.effect("stores production state under userdata in an explicit home", () => Effect.gen(function* () { const environment = yield* makeEnvironment( {}, @@ -95,6 +98,19 @@ describe("DesktopEnvironment", () => { }), ); + it.effect("keeps implicit development state separate from production state", () => + Effect.gen(function* () { + const development = yield* makeEnvironment( + {}, + { VITE_DEV_SERVER_URL: "http://localhost:5173" }, + ); + const production = yield* makeEnvironment(); + + assert.equal(development.stateDir, "/Users/alice/.t3/dev"); + assert.equal(production.stateDir, "/Users/alice/.t3/userdata"); + }), + ); + it.effect("uses a configured app user model id override", () => Effect.gen(function* () { const environment = yield* makeEnvironment( diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 992314ef4ff6..b54bfb496962 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -148,7 +148,8 @@ const make = Effect.fn("desktop.environment.make")(function* ( : input.platform === "darwin" ? path.join(homeDirectory, "Library", "Application Support") : Option.getOrElse(config.xdgConfigHome, () => path.join(homeDirectory, ".config")); - const baseDir = Option.getOrElse(config.t3Home, () => path.join(homeDirectory, ".t3")); + const configuredBaseDir = config.t3Home; + const baseDir = Option.getOrElse(configuredBaseDir, () => path.join(homeDirectory, ".t3")); const rootDir = path.resolve(input.dirname, "../../.."); const appRoot = input.isPackaged ? input.appPath : rootDir; const branding = resolveDesktopAppBranding({ @@ -156,7 +157,10 @@ const make = Effect.fn("desktop.environment.make")(function* ( appVersion: input.appVersion, }); const displayName = branding.displayName; - const stateDir = path.join(baseDir, isDevelopment ? "dev" : "userdata"); + const stateDir = path.join( + baseDir, + isDevelopment && Option.isNone(configuredBaseDir) ? "dev" : "userdata", + ); const userDataDirName = isDevelopment ? "t3code-dev" : "t3code"; const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; const resourcesPath = input.resourcesPath; diff --git a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx index d58ee338973c..f88e32874451 100644 --- a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx +++ b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx @@ -58,6 +58,9 @@ export function ConnectionSheetButton(props: { return ( ()): string { + if (value === null) return "null"; + switch (typeof value) { + case "boolean": + case "number": + case "string": + return JSON.stringify(value); + case "undefined": + return "undefined"; + case "function": + // Header factories are frequently recreated inline. Their source is + // stable across equivalent renders, while a reference comparison would + // make navigation.setOptions re-enter the navigator indefinitely. + return `function:${Function.prototype.toString.call(value)}`; + case "symbol": + return `symbol:${String(value)}`; + case "bigint": + return `bigint:${String(value)}`; + case "object": { + const object = value as object; + if (seen.has(object)) return "[circular]"; + seen.add(object); + if (Array.isArray(value)) { + return `[${value.map((entry) => optionsSignature(entry, seen)).join(",")}]`; + } + // React refs carry mutable native instances that must not make static + // screen options appear different after every render. + if ("current" in object) return "[ref]"; + return `{${Object.keys(value as Record) + .sort() + .map( + (key) => + `${JSON.stringify(key)}:${optionsSignature((value as Record)[key], seen)}`, + ) + .join(",")}}`; + } + } + return String(value); +} + +function stabilizeOptionFunctions( + value: unknown, + path: string, + latestFunctions: Map unknown>, + wrappers: Map unknown>, + seen = new WeakSet(), +): unknown { + if (typeof value === "function") { + latestFunctions.set(path, value as (...args: unknown[]) => unknown); + let wrapper = wrappers.get(path); + if (!wrapper) { + wrapper = (...args: unknown[]) => { + return latestFunctions.get(path)?.(...args); + }; + wrappers.set(path, wrapper); + } + return wrapper; + } + if (Array.isArray(value)) { + if (seen.has(value)) return value; + seen.add(value); + return value.map((entry, index) => + stabilizeOptionFunctions(entry, `${path}[${index}]`, latestFunctions, wrappers, seen), + ); + } + if (value !== null && typeof value === "object") { + if (seen.has(value) || "current" in value) return value; + seen.add(value); + return Object.fromEntries( + Object.entries(value as Record).map(([key, entry]) => [ + key, + stabilizeOptionFunctions(entry, `${path}.${key}`, latestFunctions, wrappers, seen), + ]), + ); + } + return value; +} + export function NativeStackScreenOptions(props: { readonly options?: AppNativeStackNavigationOptions; readonly listeners?: Record void>; readonly name?: string; }) { const navigation = useNativeStackNavigation(); + const lastAppliedOptionsSignatureRef = useRef(undefined); + const latestOptionFunctionsRef = useRef(new Map unknown>()); + const optionFunctionWrappersRef = useRef(new Map unknown>()); const normalizedOptions = useMemo(() => normalizeScreenOptions(props.options), [props.options]); + const stableOptions = normalizedOptions + ? (stabilizeOptionFunctions( + normalizedOptions, + "options", + latestOptionFunctionsRef.current, + optionFunctionWrappersRef.current, + ) as NativeStackNavigationOptions) + : undefined; useLayoutEffect(() => { - if (!navigation || !normalizedOptions) { + if (!navigation || !stableOptions) { + return; + } + const signature = optionsSignature(stableOptions); + // Avoid re-entering navigation state when semantically equal options are + // reapplied every layout (common when callers pass unstable object literals). + if (lastAppliedOptionsSignatureRef.current === signature) { return; } - navigation.setOptions(normalizedOptions); - }, [navigation, normalizedOptions]); + lastAppliedOptionsSignatureRef.current = signature; + navigation.setOptions(stableOptions); + }, [navigation, stableOptions]); useEffect(() => { if (!navigation || !props.listeners) { diff --git a/apps/server/scripts/t3-sqlite-state.test.ts b/apps/server/scripts/t3-sqlite-state.test.ts new file mode 100644 index 000000000000..d1ef1368918b --- /dev/null +++ b/apps/server/scripts/t3-sqlite-state.test.ts @@ -0,0 +1,119 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts"; +import { runSqliteState } from "./t3-sqlite-state.ts"; + +const createFixtureDatabase = Effect.fn("createSqliteStateFixtureDatabase")(function* ( + baseDir: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateDir = path.join(baseDir, "userdata"); + const databasePath = path.join(stateDir, "state.sqlite"); + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`CREATE TABLE fixtures (id INTEGER PRIMARY KEY, label TEXT NOT NULL)`; + yield* sql`INSERT INTO fixtures (id, label) VALUES (1, 'existing')`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: databasePath }))); +}); + +it.layer(NodeServices.layer)("t3-sqlite-state", (it) => { + it.effect("reports each invalid SQL source with a specific error", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sqlite-state-input-" }); + + const multipleSources = yield* runSqliteState({ + operation: "query", + baseDir, + sql: "SELECT 1", + file: "fixture.sql", + }).pipe(Effect.flip); + assert.equal(multipleSources._tag, "SqliteStateMultipleSqlSourcesError"); + assert.equal(multipleSources.message, "Provide only one of --sql or --file."); + + const missingSource = yield* runSqliteState({ operation: "query", baseDir }).pipe( + Effect.flip, + ); + assert.equal(missingSource._tag, "SqliteStateMissingSqlSourceError"); + assert.equal(missingSource.message, "Provide one of --sql or --file."); + + const emptySql = yield* runSqliteState({ + operation: "query", + baseDir, + sql: " ", + }).pipe(Effect.flip); + assert.equal(emptySql._tag, "SqliteStateEmptySqlError"); + assert.equal(emptySql.message, "SQL input is empty."); + }), + ); + + it.effect("queries an isolated database through Effect SQL", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sqlite-state-query-" }); + yield* createFixtureDatabase(baseDir); + + const result = yield* runSqliteState({ + operation: "query", + baseDir, + sql: "SELECT id, label FROM fixtures", + }); + + assert.equal(result.operation, "query"); + if (result.operation === "query") { + assert.deepStrictEqual(result.rows, [{ id: 1, label: "existing" }]); + } + }), + ); + + it.effect("backs up isolated state before writes and refuses the shared home", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sqlite-state-exec-" }); + yield* createFixtureDatabase(baseDir); + + const mutation = yield* runSqliteState({ + operation: "exec", + baseDir, + sql: "INSERT INTO fixtures (id, label) VALUES (2, 'seeded')", + }); + assert.equal(mutation.operation, "exec"); + if (mutation.operation === "exec") { + assert.equal((yield* fs.stat(mutation.backup)).mode & 0o777, 0o600); + } + + const error = yield* runSqliteState( + { + operation: "exec", + baseDir, + sql: "DELETE FROM fixtures", + }, + { sharedHome: baseDir }, + ).pipe(Effect.flip); + assert.equal(error._tag, "SqliteStateSharedHomeMutationError"); + + const aliasParent = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-sqlite-state-alias-", + }); + const aliasBaseDir = path.join(aliasParent, "shared-home-alias"); + yield* fs.symlink(baseDir, aliasBaseDir); + const aliasError = yield* runSqliteState( + { + operation: "exec", + baseDir: aliasBaseDir, + sql: "DELETE FROM fixtures", + }, + { sharedHome: baseDir }, + ).pipe(Effect.flip); + assert.equal(aliasError._tag, "SqliteStateSharedHomeMutationError"); + }), + ); +}); diff --git a/apps/server/scripts/t3-sqlite-state.ts b/apps/server/scripts/t3-sqlite-state.ts new file mode 100644 index 000000000000..de0402b36472 --- /dev/null +++ b/apps/server/scripts/t3-sqlite-state.ts @@ -0,0 +1,285 @@ +#!/usr/bin/env node + +// @effect-diagnostics nodeBuiltinImport:off - node:os resolves the shared T3 home guard. +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeOS from "node:os"; +import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; +import * as Console from "effect/Console"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { Argument, Command, Flag } from "effect/unstable/cli"; + +import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts"; + +export const SqliteStateOperation = Schema.Literals(["query", "exec"]); +export type SqliteStateOperation = typeof SqliteStateOperation.Type; + +export class SqliteStateMultipleSqlSourcesError extends Schema.TaggedErrorClass()( + "SqliteStateMultipleSqlSourcesError", + {}, +) { + override get message(): string { + return "Provide only one of --sql or --file."; + } +} + +export class SqliteStateMissingSqlSourceError extends Schema.TaggedErrorClass()( + "SqliteStateMissingSqlSourceError", + {}, +) { + override get message(): string { + return "Provide one of --sql or --file."; + } +} + +export class SqliteStateEmptySqlError extends Schema.TaggedErrorClass()( + "SqliteStateEmptySqlError", + {}, +) { + override get message(): string { + return "SQL input is empty."; + } +} + +export class SqliteStateDatabaseMissingError extends Schema.TaggedErrorClass()( + "SqliteStateDatabaseMissingError", + { + databasePath: Schema.String, + }, +) { + override get message(): string { + return `Database does not exist at '${this.databasePath}'. Start T3 once to run migrations.`; + } +} + +export class SqliteStateSharedHomeMutationError extends Schema.TaggedErrorClass()( + "SqliteStateSharedHomeMutationError", + {}, +) { + override get message(): string { + return "Refusing to mutate the shared ~/.t3 database. Use an isolated --base-dir."; + } +} + +export class SqliteStateSqlFileError extends Schema.TaggedErrorClass()( + "SqliteStateSqlFileError", + { + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read SQL from '${this.filePath}'.`; + } +} + +export class SqliteStateDatabaseError extends Schema.TaggedErrorClass()( + "SqliteStateDatabaseError", + { + operation: SqliteStateOperation, + databasePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} SQLite database at '${this.databasePath}'.`; + } +} + +const SqliteStateValue = Schema.Union([ + Schema.Null, + Schema.String, + Schema.Number, + Schema.Array(Schema.Number), +]); +const SqliteStateRow = Schema.Record(Schema.String, SqliteStateValue); +const SqliteStateQueryResult = Schema.Struct({ + operation: Schema.Literal("query"), + database: Schema.String, + rows: Schema.Array(SqliteStateRow), +}); +const SqliteStateExecResult = Schema.Struct({ + operation: Schema.Literal("exec"), + database: Schema.String, + backup: Schema.String, +}); +const SqliteStateResult = Schema.Union([SqliteStateQueryResult, SqliteStateExecResult]); +const encodeSqliteStateResult = Schema.encodeEffect(fromJsonStringPretty(SqliteStateResult)); + +export type SqliteStateResult = typeof SqliteStateResult.Type; + +type RawSqliteValue = null | string | number | bigint | Uint8Array; +type RawSqliteRow = Readonly>; + +export interface RunSqliteStateInput { + readonly operation: SqliteStateOperation; + readonly baseDir: string; + readonly sql?: string | undefined; + readonly file?: string | undefined; +} + +export interface RunSqliteStateOptions { + readonly sharedHome?: string | undefined; +} + +const resolveSqlSource = Effect.fn("resolveSqliteStateSqlSource")(function* ( + sql: string | undefined, + file: string | undefined, +) { + if (sql !== undefined && file !== undefined) { + return yield* new SqliteStateMultipleSqlSourcesError(); + } + if (sql === undefined && file === undefined) { + return yield* new SqliteStateMissingSqlSourceError(); + } + + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + let source: string; + if (sql !== undefined) { + source = sql; + } else { + const filePath = path.resolve(file as string); + source = yield* fs + .readFileString(filePath) + .pipe(Effect.mapError((cause) => new SqliteStateSqlFileError({ filePath, cause }))); + } + + const trimmed = source.trim(); + if (trimmed.length === 0) { + return yield* new SqliteStateEmptySqlError(); + } + return trimmed; +}); + +function normalizeSqliteValue(value: RawSqliteValue): typeof SqliteStateValue.Type { + if (typeof value === "bigint") { + const numericValue = Number(value); + return Number.isSafeInteger(numericValue) ? numericValue : value.toString(); + } + if (value instanceof Uint8Array) { + return Array.from(value); + } + return value; +} + +function normalizeSqliteRow(row: RawSqliteRow): typeof SqliteStateRow.Type { + return Object.fromEntries( + Object.entries(row).map(([key, value]) => [key, normalizeSqliteValue(value)]), + ); +} + +export const runSqliteState = Effect.fn("runSqliteState")(function* ( + input: RunSqliteStateInput, + options: RunSqliteStateOptions = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = path.resolve(input.baseDir); + const sharedHome = path.resolve(options.sharedHome ?? path.join(NodeOS.homedir(), ".t3")); + const databasePath = path.join(baseDir, "userdata", "state.sqlite"); + const source = yield* resolveSqlSource(input.sql, input.file); + + if (!(yield* fs.exists(databasePath))) { + return yield* new SqliteStateDatabaseMissingError({ databasePath }); + } + if (input.operation === "exec") { + const [canonicalBaseDir, canonicalSharedHome] = yield* Effect.all([ + fs.realPath(baseDir), + fs.realPath(sharedHome).pipe(Effect.orElseSucceed(() => sharedHome)), + ]); + if (canonicalBaseDir === canonicalSharedHome) { + return yield* new SqliteStateSharedHomeMutationError(); + } + } + + const program = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql.unsafe("PRAGMA busy_timeout = 5000").unprepared; + + if (input.operation === "query") { + const rows = yield* sql.unsafe(source).unprepared.pipe( + Effect.provideService(SqlClient.SafeIntegers, true), + Effect.map((rows) => rows.map(normalizeSqliteRow)), + ); + return { + operation: "query", + database: databasePath, + rows, + } as const; + } + + const timestamp = DateTime.formatIso(yield* DateTime.now).replaceAll(":", "-"); + const backupPath = `${databasePath}.backup-${timestamp}`; + yield* sql`VACUUM INTO ${backupPath}`; + yield* fs.chmod(backupPath, 0o600); + yield* sql.withTransaction(sql.unsafe(source).unprepared); + + return { + operation: "exec", + database: databasePath, + backup: backupPath, + } as const; + }); + + return yield* program.pipe( + Effect.provide( + NodeSqliteClient.layer({ + filename: databasePath, + readonly: input.operation === "query", + }), + ), + Effect.mapError( + (cause) => + new SqliteStateDatabaseError({ + operation: input.operation, + databasePath, + cause, + }), + ), + ); +}); + +export const t3SqliteStateCommand = Command.make( + "t3-sqlite-state", + { + operation: Argument.choice("operation", SqliteStateOperation.literals).pipe( + Argument.withDescription("Run a read-only query or a backed-up fixture mutation."), + ), + baseDir: Flag.string("base-dir").pipe( + Flag.withDescription("Explicit T3 base directory containing userdata/state.sqlite."), + ), + sql: Flag.string("sql").pipe( + Flag.optional, + Flag.withDescription("SQL source supplied directly on the command line."), + ), + file: Flag.string("file").pipe( + Flag.optional, + Flag.withDescription("Path to a SQL source file."), + ), + }, + ({ operation, baseDir, sql, file }) => + runSqliteState({ + operation, + baseDir, + sql: Option.getOrUndefined(sql), + file: Option.getOrUndefined(file), + }).pipe(Effect.flatMap(encodeSqliteStateResult), Effect.flatMap(Console.log)), +).pipe( + Command.withDescription( + "Inspect or seed an isolated T3 SQLite database with automatic backups for writes.", + ), +); + +if (import.meta.main) { + Command.run(t3SqliteStateCommand, { version: "0.0.0" }).pipe( + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, + ); +} diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 5e5f4bd4d933..c6b770de4c15 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -269,7 +269,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { }), ); - it.effect("logs in to headless connect without enabling access", () => + it.effect("accepts the --headless login override without enabling access", () => Effect.gen(function* () { const baseDir = NodeFS.mkdtempSync( NodePath.join(NodeOS.tmpdir(), "t3-cli-cloud-login-test-"), @@ -287,7 +287,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { ); const login = yield* captureStdout( - runConnectCli(["connect", "login", "--base-dir", baseDir]), + runConnectCli(["connect", "login", "--base-dir", baseDir, "--headless"]), ); const status = yield* captureStdout( runConnectCli(["connect", "status", "--base-dir", baseDir, "--json"]), @@ -298,7 +298,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { readonly authenticated: boolean; }; - assert.equal(login.output, "Signed in to T3 Connect."); + assert.equal(login.output, "✓ Signed in"); assert.isFalse(decoded.desired); assert.isTrue(decoded.authenticated); }), diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 2346dcda2ee5..617230650672 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -18,6 +18,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { deriveServerPaths } from "../config.ts"; import { resolveServerConfig } from "./config.ts"; +const deriveExplicitServerPaths = (baseDir: string, devUrl: URL | undefined) => + deriveServerPaths(baseDir, devUrl, { baseDirIsExplicit: true }); + const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); const makeDesktopBootstrap = ( @@ -60,7 +63,10 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { Effect.gen(function* () { const { join } = yield* Path.Path; const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-env-base"); - const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173")); + const derivedPaths = yield* deriveExplicitServerPaths( + baseDir, + new URL("http://127.0.0.1:5173"), + ); const resolved = yield* resolveServerConfig( { mode: Option.none(), @@ -121,6 +127,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { tailscaleServeEnabled: false, tailscaleServePort: 443, }); + assert.equal(resolved.stateDir, join(baseDir, "userdata")); }), ); @@ -128,7 +135,10 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { Effect.gen(function* () { const { join } = yield* Path.Path; const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-flags-base"); - const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); + const derivedPaths = yield* deriveExplicitServerPaths( + baseDir, + new URL("http://127.0.0.1:4173"), + ); const resolved = yield* resolveServerConfig( { mode: Option.some("web"), @@ -188,6 +198,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { tailscaleServeEnabled: true, tailscaleServePort: 8443, }); + assert.equal(resolved.dbPath, join(baseDir, "userdata", "state.sqlite")); }), ); @@ -202,7 +213,10 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { tailscaleServePort: 443, }), ); - const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); + const derivedPaths = yield* deriveExplicitServerPaths( + baseDir, + new URL("http://127.0.0.1:4173"), + ); const resolved = yield* resolveServerConfig( { @@ -399,7 +413,10 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { tailscaleServePort: 443, }), ); - const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); + const derivedPaths = yield* deriveExplicitServerPaths( + baseDir, + new URL("http://127.0.0.1:4173"), + ); const resolved = yield* resolveServerConfig( { @@ -464,7 +481,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-settings-" }); - const derivedPaths = yield* deriveServerPaths(baseDir, undefined); + const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined); yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); yield* fs.writeFileString( derivedPaths.settingsPath, @@ -532,7 +549,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { Effect.gen(function* () { const { join } = yield* Path.Path; const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-headless-base"); - const derivedPaths = yield* deriveServerPaths(baseDir, undefined); + const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined); const resolved = yield* resolveServerConfig( { diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 5cce1b3d9942..389e0e73deb3 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -31,7 +31,9 @@ export const hostFlag = Flag.string("host").pipe( Flag.optional, ); export const baseDirFlag = Flag.string("base-dir").pipe( - Flag.withDescription("Base directory path (equivalent to T3CODE_HOME)."), + Flag.withDescription( + "Explicit T3 Code data directory; runtime state is stored under userdata (equivalent to T3CODE_HOME).", + ), Flag.optional, ); export const devUrlFlag = Flag.string("dev-url").pipe( @@ -263,19 +265,21 @@ export const resolveServerConfig = ( resolveOptionPrecedence(normalizedFlags.devUrl, Option.fromUndefinedOr(env.devUrl)), () => undefined, ); + const explicitBaseDir = resolveOptionPrecedence( + normalizedFlags.baseDir, + Option.fromUndefinedOr(env.t3Home), + ).pipe(Option.filter((value) => value.trim().length > 0)); const baseDir = yield* resolveBaseDir( Option.getOrUndefined( - resolveOptionPrecedence( - normalizedFlags.baseDir, - Option.fromUndefinedOr(env.t3Home), - Option.fromUndefinedOr(bootstrap?.t3Home), - ), + resolveOptionPrecedence(explicitBaseDir, Option.fromUndefinedOr(bootstrap?.t3Home)), ), ); const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd()); const cwd = path.resolve(yield* expandHomePath(rawCwd.trim())); yield* fs.makeDirectory(cwd, { recursive: true }); - const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, devUrl); + const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, devUrl, { + baseDirIsExplicit: Option.isSome(explicitBaseDir), + }); yield* ServerConfig.ensureServerDirectories(derivedPaths); const persistedObservabilitySettings = yield* loadPersistedObservabilitySettings( derivedPaths.settingsPath, diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index f01c93f05384..e63cbf331b58 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -1,19 +1,45 @@ import * as RelayClient from "@t3tools/shared/relayClient"; import { assert, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as References from "effect/References"; +import * as Terminal from "effect/Terminal"; import { acquireRelayClientForLink, + formatHeadlessAuthorizationPrompt, + formatRelayClientReady, + headlessSessionConfig, isPublishAgentActivityEnabledValue, + recoverBootServiceOffer, reportCloudDisconnectResults, } from "./connect.ts"; +it("explains how to complete headless authorization", () => { + assert.equal( + formatHeadlessAuthorizationPrompt("https://example.test/connect"), + [ + "Headless authorization", + "Open this URL on a device with a browser:", + " https://example.test/connect", + "", + "After signing in, return here and enter the code shown in your browser.", + ].join("\n"), + ); +}); + +it("formats relay readiness without printing its installation path", () => { + assert.equal(formatRelayClientReady("2026.5.2"), "✓ Relay client ready · cloudflared 2026.5.2"); +}); + +const readHeadlessSessionConfig = (env: Record) => + headlessSessionConfig.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))); + const managedExecutable = { status: "available", executablePath: "/tmp/cloudflared", @@ -21,6 +47,22 @@ const managedExecutable = { version: RelayClient.CLOUDFLARED_VERSION, } as const; +it.effect("detects headless operation from individual SSH config values", () => + Effect.gen(function* () { + assert.isFalse(yield* readHeadlessSessionConfig({})); + assert.isFalse(yield* readHeadlessSessionConfig({ CI: "true" })); + assert.isTrue(yield* readHeadlessSessionConfig({ SSH_CONNECTION: "client server" })); + assert.isTrue(yield* readHeadlessSessionConfig({ SSH_TTY: "/dev/pts/1" })); + }), +); + +it.effect("treats cancelling optional background setup as a successful skip", () => + Effect.gen(function* () { + const result = yield* recoverBootServiceOffer(Effect.fail(new Terminal.QuitError({}))); + assert.isFalse(result); + }), +); + it.effect("does not install the relay client when the user declines the managed download", () => Effect.gen(function* () { let installCalls = 0; diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index bd1a3692ef85..ea42aa440df3 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -6,9 +6,12 @@ import { } from "@t3tools/contracts"; import { RelayOkResponse } from "@t3tools/contracts/relay"; import * as RelayClient from "@t3tools/shared/relayClient"; +import * as Terminal from "effect/Terminal"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Cause from "effect/Cause"; +import * as Config from "effect/Config"; import * as Console from "effect/Console"; +import * as Crypto from "effect/Crypto"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -16,6 +19,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as References from "effect/References"; +import * as Schema from "effect/Schema"; import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; import { FetchHttpClient, @@ -25,8 +29,10 @@ import { } from "effect/unstable/http"; import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; +import packageJson from "../../package.json" with { type: "json" }; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as BootService from "../cloud/bootService.ts"; import * as CliState from "../cloud/CliState.ts"; import * as CliTokenManager from "../cloud/CliTokenManager.ts"; import { @@ -38,6 +44,8 @@ import { relayUrlConfig } from "../cloud/publicConfig.ts"; import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ExternalLauncher from "../process/externalLauncher.ts"; +import * as ProcessRunner from "../processRunner.ts"; import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; @@ -46,6 +54,81 @@ const jsonFlag = Flag.boolean("json").pipe( Flag.withDefault(false), ); +const isCloudCliTokenManagerError = Schema.is(CliTokenManager.CloudCliTokenManagerError); + +const headlessFlag = Flag.boolean("headless").pipe( + Flag.withDescription("Authorize without a local browser using out-of-band OAuth."), + Flag.withDefault(false), +); + +/** + * Inside an SSH session there is no local browser to complete the loopback + * OAuth callback, so out-of-band OAuth is the only flow that can work. + */ +export const headlessSessionConfig = Config.all({ + sshConnection: Config.string("SSH_CONNECTION").pipe(Config.option), + sshTty: Config.string("SSH_TTY").pipe(Config.option), +}).pipe( + Config.map(({ sshConnection, sshTty }) => Option.isSome(sshConnection) || Option.isSome(sshTty)), +); + +const promptForOutOfBandOAuthCode = Effect.fn("cloud.cli.prompt_for_out_of_band_oauth_code")( + function* ({ authorizeUrl, validate }: CliTokenManager.OutOfBandOAuthPromptInput) { + yield* Console.log(formatHeadlessAuthorizationPrompt(authorizeUrl)); + return yield* Prompt.run(Prompt.text({ message: "Authorization code", validate })); + }, +); + +export function formatHeadlessAuthorizationPrompt(authorizeUrl: string): string { + return [ + "Headless authorization", + "Open this URL on a device with a browser:", + ` ${authorizeUrl}`, + "", + "After signing in, return here and enter the code shown in your browser.", + ].join("\n"); +} + +/** Returns the connected account identity, if the flow could determine one. */ +const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: { + readonly headless: boolean; +}) { + const tokens = yield* CliTokenManager.CloudCliTokenManager; + const useOutOfBandOAuth = options.headless || (yield* headlessSessionConfig); + if (!useOutOfBandOAuth) { + const authorization = yield* tokens.get; + if (authorization._tag === "Authorized") { + return authorization.token.identity ?? null; + } + yield* Console.log("\nHeadless mode enabled. A new authorization link is ready below."); + } + // A stored credential whose refresh fails (revoked, expired grant) must + // fall through to a fresh out-of-band authorization, not dead-end the command. + const existing = yield* tokens.getExisting.pipe( + Effect.catchTag("CloudCliCredentialRefreshError", () => + Console.log( + "The stored T3 Connect credential could not be refreshed; signing in again.", + ).pipe(Effect.as(Option.none())), + ), + ); + if (Option.isSome(existing)) { + return existing.value.identity ?? null; + } + const { token, identity } = yield* CliTokenManager.outOfBandOAuthLogin( + promptForOutOfBandOAuthCode, + ).pipe( + Effect.mapError((cause) => + // Ctrl-C / EOF at the prompt is a QuitError; let it propagate so the CLI + // cancels quietly instead of dumping an authorization error. + Terminal.isQuitError(cause) || isCloudCliTokenManagerError(cause) + ? cause + : new CliTokenManager.CloudCliAuthorizationError({ cause }), + ), + ); + yield* tokens.store(token); + return identity; +}); + function bytesToString(value: Uint8Array): string { return new TextDecoder().decode(value); } @@ -314,6 +397,21 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { if (options.clearAuthorization) { const tokens = yield* CliTokenManager.CloudCliTokenManager; yield* tokens.clear; + + // uninstall itself no-ops when nothing is installed (and on non-Linux), + // so no status pre-check that could mask a real removal failure. + const bootService = yield* BootService.BootService; + yield* bootService.uninstall.pipe( + Effect.tap((removed) => + removed ? Console.log("Removed the T3 Code background service.") : Effect.void, + ), + Effect.catchTag("BootServiceUnsupportedError", () => Effect.succeed(false)), + Effect.catch((error) => + Console.warn(`Could not remove the background service: ${error.message}`).pipe( + Effect.as(false), + ), + ), + ); } yield* reportCloudDisconnectResults({ @@ -327,7 +425,7 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { } }); -const runCloudCommand = ( +const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* ( flags: { readonly baseDir: Option.Option }, run: Effect.Effect< A, @@ -336,6 +434,8 @@ const runCloudCommand = ( | CliTokenManager.CloudCliTokenManager | RelayClient.RelayClient | EnvironmentAuth.EnvironmentAuth + | BootService.BootService + | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient | Prompt.Environment @@ -345,37 +445,79 @@ const runCloudCommand = ( options?: { readonly quietLogs?: boolean; }, -) => - Effect.gen(function* () { - const logLevel = yield* GlobalFlag.LogLevel; - const config = yield* resolveCliAuthConfig(flags, logLevel); - const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; - const runtimeLayer = Layer.mergeAll( - ServerSecretStore.layer, - CliTokenManager.layer.pipe(Layer.provide(ServerSecretStore.layer)), - RelayClient.layerCloudflared({ baseDir: config.baseDir }), - EnvironmentAuth.runtimeLayer, - ServerEnvironment.layer, - headlessRelayClientTracingLayer, - ).pipe( - Layer.provideMerge(FetchHttpClient.layer), - Layer.provideMerge(ServerConfig.layer(config)), - Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), +) { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; + const runtimeLayer = Layer.mergeAll( + ServerSecretStore.layer, + CliTokenManager.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provide(ExternalLauncher.layer), + ), + RelayClient.layerCloudflared({ baseDir: config.baseDir }), + EnvironmentAuth.runtimeLayer, + ServerEnvironment.layer, + BootService.layer({ + baseDir: config.baseDir, + logsDir: config.logsDir, + cliVersion: packageJson.version, + }).pipe(Layer.provide(ProcessRunner.layer)), + headlessRelayClientTracingLayer, + ).pipe( + Layer.provideMerge(FetchHttpClient.layer), + Layer.provideMerge(ServerConfig.layer(config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), + ); + return yield* run.pipe(Effect.provide(runtimeLayer)); +}); + +const connectedAs = (identity: string | null): string => (identity ? ` as ${identity}` : ""); + +export function formatRelayClientReady(version: string): string { + return `✓ Relay client ready · cloudflared ${version}`; +} + +const linkEnvironmentForConnect = Effect.fn("cloud.cli.link_environment")(function* (options: { + readonly headless: boolean; + readonly publishOnly?: boolean; +}) { + const publishOnly = options.publishOnly ?? false; + if (!publishOnly) { + const relayClient = yield* RelayClient.RelayClient; + const installed = yield* acquireRelayClientForLink( + relayClient, + confirmRelayClientInstall, + reportRelayClientInstallProgress, ); - return yield* run.pipe(Effect.provide(runtimeLayer)); - }); + if (Option.isNone(installed)) { + yield* Console.log("T3 Connect setup cancelled. The relay client was not installed."); + return null; + } + yield* Console.log(formatRelayClientReady(installed.value.version)); + } + + const identity = yield* authorizeCli(options); + yield* CliState.setCliDesiredCloudLink(true, publishOnly ? "publish_only" : "managed"); + if (publishOnly) { + const secrets = yield* ServerSecretStore.ServerSecretStore; + yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes("true")); + } + return { identity } as const; +}); const connectLoginCommand = Command.make("login", { ...projectLocationFlags, + headless: headlessFlag, }).pipe( Command.withDescription("Authorize the T3 Connect CLI without enabling remote access."), Command.withHandler((flags) => runCloudCommand( flags, Effect.gen(function* () { - const tokens = yield* CliTokenManager.CloudCliTokenManager; - yield* tokens.get; - yield* Console.log("Signed in to T3 Connect."); + yield* Console.log("T3 Connect\n"); + const identity = yield* authorizeCli(flags); + yield* Console.log(`✓ Signed in${connectedAs(identity)}`); }), ), ), @@ -383,6 +525,7 @@ const connectLoginCommand = Command.make("login", { const connectLinkCommand = Command.make("link", { ...projectLocationFlags, + headless: headlessFlag, publishOnly: Flag.boolean("publish-only").pipe( Flag.withDescription( "Link to publish agent activity only — no managed tunnel. Reach this environment out of band (e.g. Tailscale).", @@ -395,41 +538,15 @@ const connectLinkCommand = Command.make("link", { runCloudCommand( flags, Effect.gen(function* () { - // A publish-only link needs no Cloudflare tunnel, so skip installing the - // relay client entirely. - if (!flags.publishOnly) { - const relayClient = yield* RelayClient.RelayClient; - const installed = yield* acquireRelayClientForLink( - relayClient, - confirmRelayClientInstall, - reportRelayClientInstallProgress, - ); - if (Option.isNone(installed)) { - yield* Console.log("T3 Connect setup cancelled. The relay client was not installed."); - return; - } + yield* Console.log("T3 Connect\n"); + const linked = yield* linkEnvironmentForConnect(flags); + if (linked) { yield* Console.log( - `Using relay client ${installed.value.version} from ${installed.value.executablePath}.`, + flags.publishOnly + ? `✓ Authorized${connectedAs(linked.identity)}\n\nNext\n Start T3 to publish agent activity (no managed tunnel).` + : `✓ Authorized${connectedAs(linked.identity)}\n\nNext\n Start the server with \`t3 serve\` to make this machine reachable.`, ); } - - const tokens = yield* CliTokenManager.CloudCliTokenManager; - yield* tokens.get; - yield* CliState.setCliDesiredCloudLink( - true, - flags.publishOnly ? "publish_only" : "managed", - ); - if (flags.publishOnly) { - // A publish-only link exists solely to publish; without the publish - // flag the link would be inert and the success message a lie. - const secrets = yield* ServerSecretStore.ServerSecretStore; - yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes("true")); - } - yield* Console.log( - flags.publishOnly - ? "This environment will publish agent activity to your mobile clients the next time T3 starts (no managed tunnel)." - : "This T3 environment will be available through T3 Connect the next time T3 starts.", - ); }), ), ), @@ -567,8 +684,80 @@ const connectLogoutCommand = Command.make("logout", { ), ); -export const connectCommand = Command.make("connect").pipe( - Command.withDescription("Manage headless T3 Connect access."), +const offerBootService = Effect.gen(function* () { + const bootService = yield* BootService.BootService; + const { supported, installed, current } = yield* bootService.status; + if (!supported) { + // Don't prompt for something that can only fail; background setup is + // Linux/systemd-only for now. + return false; + } + if (installed && current) { + yield* Console.log("T3 Code is already set up to run in the background on this machine."); + return true; + } + const wanted = yield* Prompt.run( + Prompt.confirm({ + message: installed + ? "The installed T3 Code background service is from an older setup. Update it now?" + : "Run T3 Code in the background whenever this machine boots? " + + "It stays reachable through T3 Connect even after you log out.", + initial: true, + }), + ); + if (!wanted) { + return false; + } + const plan = yield* bootService.install; + yield* Console.log(`Background service installed. Logs: ${plan.logPath}`); + return true; +}); + +export const recoverBootServiceOffer = ( + offer: Effect.Effect, +) => + offer.pipe( + Effect.catchTags({ + QuitError: () => Effect.succeed(false), + BootServiceUnsupportedError: (error) => + Console.log(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)), + BootServiceCommandError: (error) => + Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), + BootServiceInstallError: (error) => + Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), + }), + ); + +export const connectCommand = Command.make("connect", { + ...projectLocationFlags, + headless: headlessFlag, +}).pipe( + Command.withDescription("Set up T3 Connect for this machine."), + Command.withHandler((flags) => + runCloudCommand( + flags, + Effect.gen(function* () { + yield* Console.log("T3 Connect\n"); + const linked = yield* linkEnvironmentForConnect(flags); + if (!linked) { + return; + } + // Show which account was linked so an unexpected identity (an + // authorization code for a different account) is visible before the + // machine is brought online. + yield* Console.log(`✓ Connected${connectedAs(linked.identity)}`); + + // Connect itself already succeeded; a boot-service failure must not + // fail the command, just tell the user what happened and move on. + const background = yield* recoverBootServiceOffer(offerBootService); + yield* Console.log( + background + ? "\n✓ Background service ready\n\nT3 Code will stay reachable after you log out." + : "\nNext\n Start the server with `t3 serve` to make this machine reachable.", + ); + }), + ), + ), Command.withSubcommands([ connectLoginCommand, connectLinkCommand, diff --git a/apps/server/src/cloud/CliTokenManager.test.ts b/apps/server/src/cloud/CliTokenManager.test.ts new file mode 100644 index 000000000000..e6eb6b7cd6fd --- /dev/null +++ b/apps/server/src/cloud/CliTokenManager.test.ts @@ -0,0 +1,264 @@ +import { readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Terminal from "effect/Terminal"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +import * as CliTokenManager from "./CliTokenManager.ts"; +import type { OutOfBandOAuthPromptInput } from "./CliTokenManager.ts"; + +// pk_test_ +const TEST_ENV = { + T3CODE_CLERK_PUBLISHABLE_KEY: "pk_test_Y2xlcmsuZXhhbXBsZS50ZXN0JA==", + T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_client_test", + T3CODE_HOSTED_APP_URL: "https://hosted.example.test", +}; + +interface RecordedTokenRequest { + readonly url: string; + readonly params: URLSearchParams; +} + +// A JWT whose payload claims { email: "theo@example.test" } (signature is not +// verified — the CLI only reads the claim to display the connected account). +const TestIdTokenHeaderJson = Schema.fromJsonString(Schema.Struct({ alg: Schema.Literal("none") })); +const TestIdTokenPayloadJson = Schema.fromJsonString(Schema.Struct({ email: Schema.String })); +const encodeTestIdTokenHeader = Schema.encodeSync(TestIdTokenHeaderJson); +const encodeTestIdTokenPayload = Schema.encodeSync(TestIdTokenPayloadJson); +const idTokenWithEmail = (() => { + const header = Encoding.encodeBase64Url(encodeTestIdTokenHeader({ alg: "none" })); + const payload = Encoding.encodeBase64Url( + encodeTestIdTokenPayload({ email: "theo@example.test" }), + ); + return `${header}.${payload}.`; +})(); + +const TestTokenResponseJson = Schema.fromJsonString( + Schema.Struct({ + access_token: Schema.String, + refresh_token: Schema.String, + id_token: Schema.String, + expires_in: Schema.Number, + token_type: Schema.String, + }), +); +const encodeTestTokenResponse = Schema.encodeSync(TestTokenResponseJson); + +const makeTokenEndpointLayer = ( + requests: Array, + options?: { readonly idToken?: string }, +) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + const body = + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : ""; + requests.push({ url: request.url, params: new URLSearchParams(body) }); + return HttpClientResponse.fromWeb( + request, + new Response( + encodeTestTokenResponse({ + access_token: "access-token-1", + refresh_token: "refresh-token-1", + id_token: options?.idToken ?? idTokenWithEmail, + expires_in: 3600, + token_type: "bearer", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + }), + ), + ); + +const provideTestEnv = Effect.provide( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: TEST_ENV })), +); + +const isAuthorizationError = Schema.is(CliTokenManager.CloudCliAuthorizationError); + +class PromptRejectedError extends Schema.TaggedErrorClass()( + "PromptRejectedError", + { message: Schema.String }, +) {} + +it("formats loopback authorization with a headless-host fallback", () => { + assert.equal( + CliTokenManager.formatLoopbackAuthorizationPrompt("https://clerk.example.test/authorize"), + [ + "Open this URL to authorize T3 Connect:", + " https://clerk.example.test/authorize", + "", + "Press \u001b[1mEnter\u001b[22m to open it in your browser.", + "No browser on this device? Press \u001b[1mH\u001b[22m to switch to headless mode.", + ].join("\n"), + ); +}); + +const makeTestTerminal = (queue: Queue.Queue) => + Terminal.make({ + columns: Effect.succeed(80), + rows: Effect.succeed(24), + readInput: Effect.succeed(Queue.asDequeue(queue)), + readLine: Effect.never, + display: () => Effect.void, + }); + +const userInput = (name: string): Terminal.UserInput => ({ + input: Option.some(name), + key: { name, ctrl: false, meta: false, shift: name !== name.toLowerCase() }, +}); + +it.effect("opens the browser on Enter and switches the active flow on H", () => + Effect.gen(function* () { + const queue = yield* Queue.make(); + yield* Queue.offerAll(queue, [userInput("enter"), userInput("H")]); + const opened: Array = []; + + const result = yield* CliTokenManager.waitForLoopbackAuthorization({ + authorizationUrl: "https://clerk.example.test/authorize", + callback: Effect.never, + terminal: makeTestTerminal(queue), + launchBrowser: (url) => + Effect.sync(() => { + opened.push(url); + }), + }); + + assert.deepEqual(opened, ["https://clerk.example.test/authorize"]); + assert.deepEqual(result, { _tag: "HeadlessRequested" }); + }), +); + +it.effect("finishes normally when the browser callback wins", () => + Effect.gen(function* () { + const queue = yield* Queue.make(); + const callback = yield* Deferred.make(); + yield* Deferred.succeed(callback, "clerk-code-123"); + + const result = yield* CliTokenManager.waitForLoopbackAuthorization({ + authorizationUrl: "https://clerk.example.test/authorize", + callback: Deferred.await(callback), + terminal: makeTestTerminal(queue), + launchBrowser: () => Effect.die("browser launch should not run"), + }); + + assert.deepEqual(result, { _tag: "AuthorizationCode", code: "clerk-code-123" }); + }), +); + +it.layer(NodeServices.layer)("CliTokenManager.outOfBandOAuthLogin", (it) => { + it.effect("prints a hosted authorize URL and exchanges the out-of-band code with PKCE", () => + Effect.gen(function* () { + const requests: Array = []; + let seenAuthorizeUrl = ""; + + const { token, identity } = yield* CliTokenManager.outOfBandOAuthLogin( + ({ authorizeUrl, validate }: OutOfBandOAuthPromptInput) => + Effect.gen(function* () { + seenAuthorizeUrl = authorizeUrl; + const request = readConnectAuthorizeRequest(new URL(authorizeUrl)); + assert.isNotNull(request); + return yield* validate(`clerk-code-123.${request!.state}`).pipe( + Effect.mapError((message) => new PromptRejectedError({ message })), + ); + }), + ).pipe(Effect.provide(makeTokenEndpointLayer(requests)), provideTestEnv); + + const authorizeUrl = new URL(seenAuthorizeUrl); + assert.equal(authorizeUrl.origin, "https://hosted.example.test"); + assert.equal(authorizeUrl.pathname, "/connect"); + const request = readConnectAuthorizeRequest(authorizeUrl); + assert.isNotNull(request); + assert.match(request!.state, /^[A-Za-z0-9_-]{22}$/); + + assert.equal(token.accessToken, "access-token-1"); + assert.equal(token.refreshToken, "refresh-token-1"); + assert.equal(token.identity, "theo@example.test"); + // The id_token's email claim is surfaced so connect can show the account. + assert.equal(identity, "theo@example.test"); + + assert.lengthOf(requests, 1); + const exchange = requests[0]!; + assert.equal(exchange.url, "https://clerk.example.test/oauth/token"); + assert.equal(exchange.params.get("grant_type"), "authorization_code"); + assert.equal(exchange.params.get("code"), "clerk-code-123"); + assert.equal( + exchange.params.get("redirect_uri"), + "https://hosted.example.test/connect/callback", + ); + assert.equal(exchange.params.get("client_id"), "oauth_client_test"); + // The verifier must hash to the challenge advertised in the authorize URL. + const verifier = exchange.params.get("code_verifier"); + assert.isNotNull(verifier); + const crypto = yield* Crypto.Crypto; + const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier!)); + assert.equal(Encoding.encodeBase64Url(digest), request!.challenge); + }), + ); + + it.effect("rejects out-of-band codes whose state does not match the request", () => + Effect.gen(function* () { + const requests: Array = []; + + const validationErrors: Array = []; + const result = yield* CliTokenManager.outOfBandOAuthLogin( + ({ validate }: OutOfBandOAuthPromptInput) => + validate("clerk-code-123.wrong-state").pipe( + Effect.tapError((message) => Effect.sync(() => validationErrors.push(message))), + Effect.mapError((message) => new PromptRejectedError({ message })), + ), + ).pipe(Effect.provide(makeTokenEndpointLayer(requests)), provideTestEnv, Effect.flip); + + assert.lengthOf(requests, 0); + assert.lengthOf(validationErrors, 1); + assert.include(validationErrors[0], "different connect request"); + assert.instanceOf(result, PromptRejectedError); + }), + ); + + it.effect("ignores an id_token whose claims are not valid JSON", () => + Effect.gen(function* () { + const requests: Array = []; + const malformedIdToken = `header.${Encoding.encodeBase64Url("not-json")}.signature`; + + const { identity } = yield* CliTokenManager.outOfBandOAuthLogin( + ({ authorizeUrl }: OutOfBandOAuthPromptInput) => { + const request = readConnectAuthorizeRequest(new URL(authorizeUrl)); + assert.isNotNull(request); + return Effect.succeed(`clerk-code-123.${request!.state}`); + }, + ).pipe( + Effect.provide(makeTokenEndpointLayer(requests, { idToken: malformedIdToken })), + provideTestEnv, + ); + + assert.isNull(identity); + assert.lengthOf(requests, 1); + }), + ); + + it.effect("fails without touching the token endpoint when the prompt returns garbage", () => + Effect.gen(function* () { + const requests: Array = []; + + const result = yield* CliTokenManager.outOfBandOAuthLogin(() => + Effect.succeed("not-a-connect-code"), + ).pipe(Effect.provide(makeTokenEndpointLayer(requests)), provideTestEnv, Effect.flip); + + assert.lengthOf(requests, 0); + assert.isTrue(isAuthorizationError(result)); + }), + ); +}); diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index 00709370b269..f01599bb96fa 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -3,6 +3,7 @@ import * as NodeHttp from "node:http"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as Clock from "effect/Clock"; +import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -12,8 +13,10 @@ import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; +import * as Terminal from "effect/Terminal"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -21,19 +24,105 @@ import * as HttpRouter from "effect/unstable/http/HttpRouter"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { + buildConnectAuthorizeRequestUrl, + buildConnectClerkAuthorizeUrl, + checkConnectAuthCode, + connectCallbackUrl, +} from "@t3tools/shared/connectAuth"; + import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { cloudCliOAuthConfig, type CloudCliOAuthConfig } from "./publicConfig.ts"; +import * as ExternalLauncher from "../process/externalLauncher.ts"; +import { + cloudCliOAuthConfig, + hostedAppUrlConfig, + type CloudCliOAuthConfig, +} from "./publicConfig.ts"; +import { renderLoopbackAuthorizationCompleteHtml } from "./cliAuthHtml.ts"; const CLOUD_CLI_OAUTH_TOKEN_SECRET = "cloud-cli-oauth-token"; const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10); const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5)); +const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`; + +export function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string { + return [ + "Open this URL to authorize T3 Connect:", + ` ${authorizationUrl}`, + "", + `Press ${boldTerminalText("Enter")} to open it in your browser.`, + `No browser on this device? Press ${boldTerminalText("H")} to switch to headless mode.`, + ].join("\n"); +} + +export type LoopbackAuthorizationResult = + | { readonly _tag: "AuthorizationCode"; readonly code: string } + | { readonly _tag: "HeadlessRequested" }; + +const readLoopbackAuthorizationAction = Effect.fn( + "cloud.cli_token.read_loopback_authorization_action", +)(function* (input: Queue.Dequeue) { + while (true) { + const event = yield* Queue.take(input).pipe(Effect.mapError(() => new Terminal.QuitError({}))); + const keyName = event.key.name.toLowerCase(); + if (!event.key.ctrl && !event.key.meta && keyName === "h") { + return "headless" as const; + } + if (keyName === "enter" || keyName === "return") { + return "open-browser" as const; + } + } +}); + +export const waitForLoopbackAuthorization = Effect.fn( + "cloud.cli_token.wait_for_loopback_authorization", +)(function* (input: { + readonly authorizationUrl: string; + readonly callback: Effect.Effect; + readonly terminal: Terminal.Terminal; + readonly launchBrowser: ( + url: string, + ) => Effect.Effect; +}) { + return yield* Effect.scoped( + Effect.gen(function* () { + const terminalInput = yield* input.terminal.readInput; + while (true) { + const result = yield* Effect.raceFirst( + input.callback.pipe( + Effect.map( + (code): LoopbackAuthorizationResult => ({ _tag: "AuthorizationCode", code }), + ), + ), + readLoopbackAuthorizationAction(terminalInput), + ); + if (typeof result !== "string") { + return result; + } + if (result === "headless") { + return { _tag: "HeadlessRequested" } as const; + } + yield* input + .launchBrowser(input.authorizationUrl) + .pipe( + Effect.catch(() => + Console.warn( + `Could not open a browser on this device. Open the URL above manually, or press ${boldTerminalText("H")} to switch to headless mode.`, + ), + ), + ); + } + }), + ); +}); const PersistedToken = Schema.Struct({ accessToken: Schema.String, refreshToken: Schema.String, expiresAtEpochMs: Schema.Number, + identity: Schema.optional(Schema.String), }); -type PersistedToken = typeof PersistedToken.Type; +export type PersistedToken = typeof PersistedToken.Type; const PersistedTokenJson = Schema.fromJsonString(PersistedToken); const decodePersistedToken = Schema.decodeUnknownEffect(PersistedTokenJson); @@ -42,10 +131,39 @@ const encodePersistedToken = Schema.encodeEffect(PersistedTokenJson); const OAuthTokenResponse = Schema.Struct({ access_token: Schema.String, refresh_token: Schema.optional(Schema.String), + id_token: Schema.optional(Schema.String), expires_in: Schema.Number, token_type: Schema.String, }); +const OidcIdentityClaimsJson = Schema.fromJsonString( + Schema.Struct({ + email: Schema.optional(Schema.String), + preferred_username: Schema.optional(Schema.String), + sub: Schema.optional(Schema.String), + }), +); +const decodeOidcIdentityClaimsJson = Schema.decodeUnknownOption(OidcIdentityClaimsJson); + +/** + * Best-effort read of the `email` (or fallback) claim from an OIDC id_token. + * Only used to show the operator which account they linked, so a malformed + * token degrades to "no identity" rather than an error. + */ +function idTokenIdentity(idToken: string | undefined): string | null { + if (!idToken) return null; + const payload = idToken.split(".")[1]; + if (!payload) return null; + const decoded = Encoding.decodeBase64UrlString(payload); + if (decoded._tag !== "Success") return null; + const claims = decodeOidcIdentityClaimsJson(decoded.success); + if (Option.isNone(claims)) return null; + for (const value of [claims.value.email, claims.value.preferred_username, claims.value.sub]) { + if (typeof value === "string" && value.length > 0) return value; + } + return null; +} + export class CloudCliCredentialRemovalError extends Schema.TaggedErrorClass()( "CloudCliCredentialRemovalError", { cause: Schema.Defect() }, @@ -103,18 +221,18 @@ export type CloudCliTokenManagerError = typeof CloudCliTokenManagerError.Type; export class CloudCliTokenManager extends Context.Service< CloudCliTokenManager, { - readonly get: Effect.Effect; + readonly get: Effect.Effect< + | { readonly _tag: "Authorized"; readonly token: PersistedToken } + | { readonly _tag: "HeadlessRequested" }, + CloudCliTokenManagerError | Terminal.QuitError + >; readonly getExisting: Effect.Effect, CloudCliTokenManagerError>; readonly hasCredential: Effect.Effect; + readonly store: (token: PersistedToken) => Effect.Effect; readonly clear: Effect.Effect; } >()("t3/cloud/CliTokenManager/CloudCliTokenManager") {} -const wrapError = - (makeError: (cause: unknown) => WrappedError) => - (effect: Effect.Effect): Effect.Effect => - effect.pipe(Effect.mapError(makeError)); - function stringToBytes(value: string): Uint8Array { return new TextEncoder().encode(value); } @@ -123,10 +241,101 @@ function bytesToString(value: Uint8Array): string { return new TextDecoder().decode(value); } +const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* ( + metadata: Pick, + params: Record, +) { + const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); + const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe( + HttpClientRequest.bodyUrlParams(params), + httpClient.execute, + Effect.flatMap(HttpClientResponse.schemaBodyJson(OAuthTokenResponse)), + ); + const now = yield* Clock.currentTimeMillis; + const identity = idTokenIdentity(response.id_token); + return { + token: { + accessToken: response.access_token, + refreshToken: response.refresh_token ?? params.refresh_token ?? "", + expiresAtEpochMs: now + response.expires_in * 1_000, + ...(identity === null ? {} : { identity }), + } satisfies PersistedToken, + identity, + }; +}); + +const makePkceRequest = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32)); + const challenge = Encoding.encodeBase64Url( + yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier)), + ); + const state = Encoding.encodeBase64Url(yield* crypto.randomBytes(16)); + return { verifier, challenge, state }; +}); + +export interface OutOfBandOAuthPromptInput { + readonly authorizeUrl: string; + readonly validate: (value: string) => Effect.Effect; +} + +/** + * Out-of-band OAuth for machines without a local browser (SSH). The user + * opens the hosted /connect URL elsewhere, signs in, and enters the displayed + * code in this terminal. The PKCE verifier never leaves this process, so the + * authorization code is useless to an observer, and the state bundled into + * the blob preserves the loopback flow's CSRF check. + */ +export const outOfBandOAuthLogin = Effect.fn("cloud.cli_token.out_of_band_oauth_login")(function* < + E, + R, +>(promptForCode: (input: OutOfBandOAuthPromptInput) => Effect.Effect) { + const metadata = yield* cloudCliOAuthConfig; + const hostedAppUrl = yield* hostedAppUrlConfig; + const { verifier, challenge, state } = yield* makePkceRequest; + + const authorizationCode = yield* promptForCode({ + authorizeUrl: buildConnectAuthorizeRequestUrl({ hostedAppUrl, state, challenge }), + validate: (value) => { + const checked = checkConnectAuthCode(value, state); + return typeof checked === "string" ? Effect.fail(checked) : Effect.succeed(value); + }, + }).pipe( + // Clerk authorization codes expire on this horizon anyway; matching the + // loopback flow's timeout turns an abandoned prompt into a clear error. + Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT), + Effect.catchTag("TimeoutError", (cause) => + Effect.fail(new CloudCliAuthorizationTimeoutError({ cause })), + ), + ); + // promptForCode is caller-supplied, so re-check the returned value rather + // than trusting that the prompt ran validate. + const authCode = checkConnectAuthCode(authorizationCode, state); + if (typeof authCode === "string") { + return yield* new CloudCliAuthorizationError({ cause: authCode }); + } + + return yield* exchangeToken(metadata, { + grant_type: "authorization_code", + code: authCode.code, + redirect_uri: connectCallbackUrl(hostedAppUrl), + client_id: metadata.clientId, + code_verifier: verifier, + }); +}); + export const make = Effect.gen(function* () { + // Capture exactly the services the login/refresh flows need at build time + // (matching the behavior before the out-of-band flow captured the instances), not + // the whole ambient context. const crypto = yield* Crypto.Crypto; - const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); + const httpClient = yield* HttpClient.HttpClient; + const services = Context.make(Crypto.Crypto, crypto).pipe( + Context.add(HttpClient.HttpClient, httpClient), + ); const secrets = yield* ServerSecretStore.ServerSecretStore; + const terminal = yield* Terminal.Terminal; + const externalLauncher = yield* ExternalLauncher.ExternalLauncher; const semaphore = yield* Semaphore.make(1); const persist = Effect.fn("cloud.cli_token.persist")(function* (token: PersistedToken) { const encoded = yield* encodePersistedToken(token); @@ -136,7 +345,7 @@ export const make = Effect.gen(function* () { const clear = secrets .remove(CLOUD_CLI_OAUTH_TOKEN_SECRET) - .pipe(wrapError((cause) => new CloudCliCredentialRemovalError({ cause }))); + .pipe(Effect.mapError((cause) => new CloudCliCredentialRemovalError({ cause }))); const read = Effect.fn("cloud.cli_token.read")(function* () { const encoded = yield* secrets.get(CLOUD_CLI_OAUTH_TOKEN_SECRET); @@ -144,39 +353,21 @@ export const make = Effect.gen(function* () { return Option.some(yield* decodePersistedToken(bytesToString(encoded.value))); }); - const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* ( - metadata: CloudCliOAuthConfig, - params: Record, - ) { - const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe( - HttpClientRequest.bodyUrlParams(params), - httpClient.execute, - Effect.flatMap(HttpClientResponse.schemaBodyJson(OAuthTokenResponse)), - ); - const now = yield* Clock.currentTimeMillis; - return { - accessToken: response.access_token, - refreshToken: response.refresh_token ?? params.refresh_token ?? "", - expiresAtEpochMs: now + response.expires_in * 1_000, - } satisfies PersistedToken; - }); - const refresh = Effect.fn("cloud.cli_token.refresh")(function* (token: PersistedToken) { const metadata = yield* cloudCliOAuthConfig; - return yield* exchangeToken(metadata, { + const { token: refreshed } = yield* exchangeToken(metadata, { grant_type: "refresh_token", refresh_token: token.refreshToken, client_id: metadata.clientId, }); + return refreshed.identity === undefined && token.identity !== undefined + ? { ...refreshed, identity: token.identity } + : refreshed; }); const login = Effect.fn("cloud.cli_token.login")(function* () { const metadata = yield* cloudCliOAuthConfig; - const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32)); - const challenge = Encoding.encodeBase64Url( - yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier)), - ); - const state = yield* crypto.randomUUIDv4; + const { verifier, challenge, state } = yield* makePkceRequest; const callback = yield* Deferred.make(); const callbackRoute = HttpRouter.add( "GET", @@ -191,14 +382,7 @@ export const make = Effect.gen(function* () { }); } yield* Deferred.succeed(callback, code); - return yield* HttpServerResponse.html` - - -

T3 Connect authorization complete

-

You can close this window and return to your terminal.

- - -`; + return HttpServerResponse.html(renderLoopbackAuthorizationCompleteHtml()); }), ); yield* HttpRouter.serve(callbackRoute, { @@ -214,32 +398,37 @@ export const make = Effect.gen(function* () { ), Layer.build, ); - const authorizationUrl = new URL(metadata.authorizationEndpoint); - authorizationUrl.searchParams.set("client_id", metadata.clientId); - authorizationUrl.searchParams.set("redirect_uri", metadata.redirectUri); - authorizationUrl.searchParams.set("response_type", "code"); - authorizationUrl.searchParams.set("scope", metadata.scopes.join(" ")); - authorizationUrl.searchParams.set("state", state); - authorizationUrl.searchParams.set("code_challenge", challenge); - authorizationUrl.searchParams.set("code_challenge_method", "S256"); - yield* Console.log(`Open this URL to authorize T3 Connect:\n${authorizationUrl.toString()}\n`); - const code = yield* Deferred.await(callback).pipe( - Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT), - Effect.catchTag("TimeoutError", (cause) => - Effect.fail( - new CloudCliAuthorizationTimeoutError({ - cause, - }), + const authorizationUrl = buildConnectClerkAuthorizeUrl({ + authorizationEndpoint: metadata.authorizationEndpoint, + clientId: metadata.clientId, + redirectUri: metadata.redirectUri, + scopes: metadata.scopes, + state, + challenge, + }); + yield* Console.log(formatLoopbackAuthorizationPrompt(authorizationUrl)); + const authorization = yield* waitForLoopbackAuthorization({ + authorizationUrl, + callback: Deferred.await(callback).pipe( + Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT), + Effect.catchTag("TimeoutError", (cause) => + Effect.fail(new CloudCliAuthorizationTimeoutError({ cause })), ), ), - ); - return yield* exchangeToken(metadata, { + terminal, + launchBrowser: externalLauncher.launchBrowser, + }); + if (authorization._tag === "HeadlessRequested") { + return authorization; + } + const { token } = yield* exchangeToken(metadata, { grant_type: "authorization_code", - code, + code: authorization.code, redirect_uri: metadata.redirectUri, client_id: metadata.clientId, code_verifier: verifier, }); + return { _tag: "Authorized", token } as const; }); const getExistingNoLock = Effect.fn("cloud.cli_token.get_existing_no_lock")(function* () { @@ -253,24 +442,50 @@ export const make = Effect.gen(function* () { }); const getExisting = semaphore.withPermits(1)( - getExistingNoLock().pipe(wrapError((cause) => new CloudCliCredentialRefreshError({ cause }))), + getExistingNoLock().pipe( + Effect.mapError((cause) => new CloudCliCredentialRefreshError({ cause })), + Effect.provide(services), + ), ); const hasCredential = semaphore.withPermits(1)( read().pipe( Effect.map(Option.isSome), - wrapError((cause) => new CloudCliCredentialReadError({ cause })), + Effect.mapError((cause) => new CloudCliCredentialReadError({ cause })), ), ); const get = semaphore.withPermits(1)( Effect.gen(function* () { - const token = yield* getExistingNoLock(); - return Option.isSome(token) - ? token.value - : yield* Effect.scoped(login()).pipe(Effect.flatMap(persist)); - }).pipe(wrapError((cause) => new CloudCliAuthorizationError({ cause }))), + // A stored credential that can't be read or refreshed (corrupt, revoked, + // expired grant) must fall through to a fresh login rather than dead-end + // the command — authorizeCli applies the same fallback to out-of-band + // authorization. + const token = yield* getExistingNoLock().pipe( + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isSome(token)) { + return { _tag: "Authorized", token: token.value } as const; + } + const authorization = yield* Effect.scoped(login()); + return authorization._tag === "Authorized" + ? ({ _tag: "Authorized", token: yield* persist(authorization.token) } as const) + : authorization; + }).pipe( + Effect.mapError((cause) => + Terminal.isQuitError(cause) ? cause : new CloudCliAuthorizationError({ cause }), + ), + Effect.provide(services), + ), ); + const store = Effect.fn("cloud.cli_token.store")(function* (token: PersistedToken) { + yield* semaphore.withPermits(1)( + persist(token).pipe( + Effect.asVoid, + Effect.mapError((cause) => new CloudCliAuthorizationError({ cause })), + ), + ); + }); - return CloudCliTokenManager.of({ get, getExisting, hasCredential, clear }); + return CloudCliTokenManager.of({ get, getExisting, hasCredential, store, clear }); }); export const layer = Layer.effect(CloudCliTokenManager, make); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts new file mode 100644 index 000000000000..a24d54697d92 --- /dev/null +++ b/apps/server/src/cloud/bootService.test.ts @@ -0,0 +1,529 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import { + HostProcessArguments, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; + +import * as ProcessRunner from "../processRunner.ts"; +import * as BootService from "./bootService.ts"; + +const isUnsupportedError = Schema.is(BootService.BootServiceUnsupportedError); +const isCommandError = Schema.is(BootService.BootServiceCommandError); + +interface RecordedCommand { + readonly command: string; + readonly args: ReadonlyArray; +} + +const makeRecordingRunnerLayer = ( + commands: Array, + options?: { + readonly failCommand?: string; + readonly failWhen?: (command: string, args: ReadonlyArray) => boolean; + }, +) => + Layer.succeed( + ProcessRunner.ProcessRunner, + ProcessRunner.ProcessRunner.of({ + run: (input) => + Effect.sync(() => { + assert.isUndefined(input.env); + commands.push({ command: input.command, args: input.args }); + const failed = + input.command === options?.failCommand || + options?.failWhen?.(input.command, input.args) === true; + return { + stdout: "", + stderr: failed ? `${input.command} exploded` : "", + code: ChildProcessSpawner.ExitCode(failed ? 1 : 0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }), + ); + +const makeHost = (entry: string): BootService.BootServiceHost => ({ + execPath: "/usr/local/bin/node", + cliEntryPath: entry, +}); + +const provideHostRefs = (home: string, platform: NodeJS.Platform = "linux") => + Effect.provide( + Layer.mergeAll( + Layer.succeed(HostProcessPlatform, platform), + ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })), + ), + ); + +const makeTestContext = Effect.fn("test.makeTestContext")(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-boot-service-test-" }); + // A real file for the stable-entry cases so status can confirm the entry + // point exists. + const stableEntry = path.join(root, "bin.mjs"); + yield* fs.writeFileString(stableEntry, "#!/usr/bin/env node\n"); + return { + fs, + path, + dirs: { + home: root, + baseDir: path.join(root, ".t3"), + logsDir: path.join(root, ".t3", "userdata", "logs"), + stableEntry, + }, + }; +}); + +it("renders a systemd unit with absolute paths and append-mode logging", () => { + const unit = BootService.renderBootServiceUnit({ + nodePath: "/usr/local/bin/node", + t3EntryPath: "/home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs", + baseDir: "/home/theo/.t3", + logPath: "/home/theo/.t3/userdata/logs/boot-service.log", + unitPath: "/home/theo/.config/systemd/user/t3code.service", + }); + + assert.equal( + unit, + [ + "[Unit]", + "Description=T3 Code server (T3 Connect)", + "StartLimitIntervalSec=300", + "StartLimitBurst=5", + "", + "[Service]", + "Type=simple", + "WorkingDirectory=%h", + "Environment=T3CODE_HOME=/home/theo/.t3", + "ExecStart=/usr/local/bin/node /home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs serve", + "Restart=always", + "RestartSec=5", + "StandardOutput=append:/home/theo/.t3/userdata/logs/boot-service.log", + "StandardError=append:/home/theo/.t3/userdata/logs/boot-service.log", + "", + "[Install]", + "WantedBy=default.target", + "", + ].join("\n"), + ); +}); + +it("quotes systemd values containing spaces and escapes percent specifiers", () => { + assert.equal(BootService.quoteSystemdValue("/plain/path"), "/plain/path"); + assert.equal(BootService.quoteSystemdValue("/home/me/T3 Data"), '"/home/me/T3 Data"'); + assert.equal(BootService.quoteSystemdValue("/opt/100%cpu"), "/opt/100%%cpu"); + + const unit = BootService.renderBootServiceUnit({ + nodePath: "/home/me/my tools/node", + t3EntryPath: "/home/me/T3 Data/bin.mjs", + baseDir: "/home/me/T3 Data", + logPath: "/home/me/100%logs/boot.log", + unitPath: "/home/me/.config/systemd/user/t3code.service", + }); + assert.include(unit, 'ExecStart="/home/me/my tools/node" "/home/me/T3 Data/bin.mjs" serve'); + assert.include(unit, 'Environment=T3CODE_HOME="/home/me/T3 Data"'); + // append: paths take the rest of the line literally (spaces are fine, + // quoting is not), but % still goes through specifier expansion. + assert.include(unit, "StandardOutput=append:/home/me/100%%logs/boot.log"); + assert.include(unit, "StandardError=append:/home/me/100%%logs/boot.log"); +}); + +it("flags package-manager cache entry points as ephemeral", () => { + assert.isTrue( + BootService.isEphemeralCacheEntry("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"), + ); + assert.isTrue( + BootService.isEphemeralCacheEntry("C:\\Users\\theo\\AppData\\npm-cache\\_npx\\abc\\bin.mjs"), + ); + assert.isTrue( + BootService.isEphemeralCacheEntry( + "/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs", + ), + ); + assert.isTrue( + BootService.isEphemeralCacheEntry("/home/theo/.bun/install/cache/t3@0.0.27/dist/bin.mjs"), + ); + assert.isFalse(BootService.isEphemeralCacheEntry("/usr/local/lib/node_modules/t3/dist/bin.mjs")); + assert.isFalse( + BootService.isEphemeralCacheEntry( + "/home/theo/dev/pnpm/dlx-tools/t3/node_modules/t3/dist/bin.mjs", + ), + ); + assert.isFalse( + BootService.isEphemeralCacheEntry( + "/home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs", + ), + ); +}); + +it.layer(NodeServices.layer)("BootService", (it) => { + it.effect("installs the unit, enables the service, and enables linger", () => + Effect.gen(function* () { + const { dirs, fs, path } = yield* makeTestContext(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost(dirs.stableEntry), + }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); + + const plan = yield* service.install; + + // A stable entry point is reused directly — no npm install. + assert.equal(plan.t3EntryPath, dirs.stableEntry); + assert.deepEqual( + commands.map((entry) => [entry.command, ...entry.args].join(" ")), + [ + "systemctl --user daemon-reload", + "systemctl --user enable t3code.service", + // restart (not enable --now) so repairing a stale unit replaces a + // running process instead of leaving the old one until reboot. + "systemctl --user restart t3code.service", + "loginctl enable-linger", + ], + ); + + const unitPath = path.join(dirs.home, ".config", "systemd", "user", "t3code.service"); + const unit = yield* fs.readFileString(unitPath); + assert.include(unit, `ExecStart=/usr/local/bin/node ${dirs.stableEntry} serve`); + assert.include(unit, `Environment=T3CODE_HOME=${dirs.baseDir}`); + + const status = yield* service.status; + assert.isTrue(status.supported); + assert.isTrue(status.installed); + assert.isTrue(status.current); + + const removed = yield* service.uninstall; + assert.isTrue(removed); + assert.isFalse(yield* fs.exists(unitPath)); + const statusAfter = yield* service.status; + assert.isFalse(statusAfter.installed); + const removedAgain = yield* service.uninstall; + assert.isFalse(removedAgain); + }), + ); + + it.effect("pins a runtime via npm install when running from the npx cache", () => + Effect.gen(function* () { + const { dirs, fs, path } = yield* makeTestContext(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost("/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"), + }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); + + const plan = yield* service.install; + + const runtimeDir = path.join(dirs.baseDir, "runtime", "versions", "0.0.27"); + assert.equal( + plan.t3EntryPath, + path.join(runtimeDir, "node_modules", "t3", "dist", "bin.mjs"), + ); + assert.deepEqual(commands[0], { + command: "npm", + args: ["install", "--prefix", runtimeDir, "--no-fund", "--no-audit", "t3@0.0.27"], + }); + // Success is recorded via a sentinel so interrupted installs re-run. + assert.isTrue(yield* fs.exists(path.join(runtimeDir, ".install-complete"))); + }), + ); + + it.effect("reinstalls a pinned runtime when its entry point is missing", () => + Effect.gen(function* () { + const { dirs, fs, path } = yield* makeTestContext(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost("/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"), + }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); + + const plan = yield* service.install; + yield* fs.makeDirectory(path.dirname(plan.t3EntryPath), { recursive: true }); + yield* fs.writeFileString(plan.t3EntryPath, "#!/usr/bin/env node\n"); + yield* fs.remove(plan.t3EntryPath); + commands.length = 0; + + yield* service.install; + + assert.isTrue(commands.some(({ command }) => command === "npm")); + }), + ); + + it.effect("reads executable metadata from host process references", () => + Effect.gen(function* () { + const { dirs } = yield* makeTestContext(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + }).pipe( + Effect.provide(makeRecordingRunnerLayer(commands)), + provideHostRefs(dirs.home), + Effect.provideService(HostProcessExecutablePath, "/opt/node/bin/node"), + Effect.provideService(HostProcessArguments, ["/opt/node/bin/node", dirs.stableEntry]), + ); + + const plan = yield* service.install; + assert.equal(plan.nodePath, "/opt/node/bin/node"); + assert.equal(plan.t3EntryPath, dirs.stableEntry); + }), + ); + + it.effect("cleans up and fails when the pinned runtime install fails", () => + Effect.gen(function* () { + const { dirs, fs, path } = yield* makeTestContext(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost("/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"), + }).pipe( + Effect.provide(makeRecordingRunnerLayer(commands, { failCommand: "npm" })), + provideHostRefs(dirs.home), + ); + + const error = yield* service.install.pipe(Effect.flip); + assert.isTrue(isCommandError(error)); + const runtimeDir = path.join(dirs.baseDir, "runtime", "versions", "0.0.27"); + // The half-installed tree must not be reused by the next attempt. + assert.isFalse(yield* fs.exists(runtimeDir)); + assert.isFalse(yield* fs.exists(path.join(runtimeDir, ".install-complete"))); + }), + ); + + it.effect("reports an installed-but-stale unit so connect can offer a repair", () => + Effect.gen(function* () { + const { dirs, fs, path } = yield* makeTestContext(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost(dirs.stableEntry), + }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); + + const unitDir = path.join(dirs.home, ".config", "systemd", "user"); + yield* fs.makeDirectory(unitDir, { recursive: true }); + yield* fs.writeFileString( + path.join(unitDir, "t3code.service"), + "[Service]\nExecStart=/old/node /old/t3 serve\n", + ); + + const status = yield* service.status; + assert.isTrue(status.supported); + assert.isTrue(status.installed); + assert.isFalse(status.current); + }), + ); + + it.effect("reports a current unit as stale when its entry point is gone", () => + Effect.gen(function* () { + const { dirs, fs } = yield* makeTestContext(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost(dirs.stableEntry), + }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); + + yield* service.install; + assert.isTrue((yield* service.status).current); + + // The pinned runtime (or global bin) was deleted to reclaim space; the + // unit still matches byte-for-byte but would crashloop at boot. + yield* fs.remove(dirs.stableEntry); + const status = yield* service.status; + assert.isTrue(status.installed); + assert.isFalse(status.current); + }), + ); + + it.effect("fails on non-Linux platforms without touching the filesystem", () => + Effect.gen(function* () { + const { dirs, fs, path } = yield* makeTestContext(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"), + }).pipe( + Effect.provide(makeRecordingRunnerLayer(commands)), + provideHostRefs(dirs.home, "darwin"), + ); + + const error = yield* service.install.pipe(Effect.flip); + assert.isTrue(isUnsupportedError(error)); + assert.lengthOf(commands, 0); + assert.isFalse( + yield* fs.exists(path.join(dirs.home, ".config", "systemd", "user", "t3code.service")), + ); + + const status = yield* service.status; + assert.isFalse(status.supported); + assert.isFalse(status.installed); + }), + ); + + it.effect("removes the unit file when an activation step fails", () => + Effect.gen(function* () { + const { dirs, fs, path } = yield* makeTestContext(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"), + }).pipe( + Effect.provide(makeRecordingRunnerLayer(commands, { failCommand: "loginctl" })), + provideHostRefs(dirs.home), + ); + + const error = yield* service.install.pipe(Effect.flip); + assert.isTrue(isCommandError(error)); + // A leftover unit would make the next connect report "already set up" + // even though linger never happened. + assert.isFalse( + yield* fs.exists(path.join(dirs.home, ".config", "systemd", "user", "t3code.service")), + ); + const status = yield* service.status; + assert.isFalse(status.installed); + assert.isTrue( + commands.some( + ({ command, args }) => + command === "systemctl" && args.join(" ") === "--user disable --now t3code.service", + ), + ); + }), + ); + + it.effect("restores the previous unit when a repair cannot activate", () => + Effect.gen(function* () { + const { dirs, fs, path } = yield* makeTestContext(); + const initialCommands: Array = []; + const initialService = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost(dirs.stableEntry), + }).pipe( + Effect.provide(makeRecordingRunnerLayer(initialCommands)), + provideHostRefs(dirs.home), + ); + yield* initialService.install; + + const unitPath = path.join(dirs.home, ".config", "systemd", "user", "t3code.service"); + const previousUnit = yield* fs.readFileString(unitPath); + const replacementEntry = path.join(dirs.home, "replacement-bin.mjs"); + yield* fs.writeFileString(replacementEntry, "#!/usr/bin/env node\n"); + const repairCommands: Array = []; + const repairService = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.28", + host: makeHost(replacementEntry), + }).pipe( + Effect.provide(makeRecordingRunnerLayer(repairCommands, { failCommand: "loginctl" })), + provideHostRefs(dirs.home), + ); + + const error = yield* repairService.install.pipe(Effect.flip); + + assert.isTrue(isCommandError(error)); + assert.equal(yield* fs.readFileString(unitPath), previousUnit); + assert.isTrue( + repairCommands.some( + ({ command, args }) => + command === "systemctl" && args.join(" ") === "--user restart t3code.service", + ), + ); + }), + ); + + it.effect("keeps the unit when stopping it during uninstall fails", () => + Effect.gen(function* () { + const { dirs, fs, path } = yield* makeTestContext(); + const installCommands: Array = []; + const installedService = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost(dirs.stableEntry), + }).pipe( + Effect.provide(makeRecordingRunnerLayer(installCommands)), + provideHostRefs(dirs.home), + ); + yield* installedService.install; + + const uninstallCommands: Array = []; + const failingService = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost(dirs.stableEntry), + }).pipe( + Effect.provide( + makeRecordingRunnerLayer(uninstallCommands, { + failWhen: (command, args) => + command === "systemctl" && args.includes("disable") && args.includes("--now"), + }), + ), + provideHostRefs(dirs.home), + ); + + const error = yield* failingService.uninstall.pipe(Effect.flip); + + assert.isTrue(isCommandError(error)); + assert.isTrue( + yield* fs.exists(path.join(dirs.home, ".config", "systemd", "user", "t3code.service")), + ); + }), + ); + + it.effect("appends failed steps to the boot-service log", () => + Effect.gen(function* () { + const { dirs, fs, path } = yield* makeTestContext(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"), + }).pipe( + Effect.provide(makeRecordingRunnerLayer(commands, { failCommand: "systemctl" })), + provideHostRefs(dirs.home), + ); + + const error = yield* service.install.pipe(Effect.flip); + assert.isTrue(isCommandError(error)); + if (!isCommandError(error)) return; + assert.equal(error.exitCode, 1); + assert.equal(error.stderrLength, "systemctl exploded".length); + + const logPath = path.join(dirs.logsDir, "boot-service.log"); + assert.isTrue(yield* fs.exists(logPath)); + assert.include(yield* fs.readFileString(logPath), "exit code 1"); + }), + ); +}); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts new file mode 100644 index 000000000000..0662b3670494 --- /dev/null +++ b/apps/server/src/cloud/bootService.ts @@ -0,0 +1,452 @@ +import * as Context from "effect/Context"; +import * as Config from "effect/Config"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { + HostProcessArguments, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; + +import * as ProcessRunner from "../processRunner.ts"; + +/** + * Installs T3 Code as a per-user boot service so a connected machine stays + * reachable through T3 Connect after the SSH session ends. Linux-only for + * now: systemd user unit + loginctl enable-linger. The service runs a pinned + * runtime installed under /runtime — never `npx t3`, whose cache is + * ephemeral and whose registry fetch at boot would make startup depend on + * the network. + */ + +const BOOT_SERVICE_NAME = "t3code"; +const BOOT_RUNTIME_DIR = "runtime"; + +const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; +const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); + +const EPHEMERAL_CACHE_SEGMENTS = [ + "/_npx/", // npx + "\\_npx\\", + "/pnpm/dlx/", // pnpm dlx (~/.cache/pnpm/dlx and $PNPM_HOME/.pnpm/dlx) + "/.pnpm/dlx/", + "/.bun/install/cache/", // bunx +]; + +/** + * `npx t3` (and pnpm dlx / bunx) run out of ephemeral package-manager + * caches that can be evicted at any time — a boot service must never point + * there. Global installs, repo checkouts, and the pinned runtime below are + * all stable. + */ +export function isEphemeralCacheEntry(entryPath: string): boolean { + return EPHEMERAL_CACHE_SEGMENTS.some((segment) => entryPath.includes(segment)); +} + +/** + * systemd expands `%` specifiers in most directive values, including the + * `append:` file paths, which take the rest of the line literally and must + * NOT be quoted. + */ +export function escapeSystemdSpecifiers(value: string): string { + return value.replaceAll("%", "%%"); +} + +/** + * systemd word-splits ExecStart and Environment values and expands `%` + * specifiers, so paths with spaces or percents must be quoted and escaped. + */ +export function quoteSystemdValue(value: string): string { + const escaped = escapeSystemdSpecifiers(value); + return /[\s"'\\]/.test(escaped) + ? `"${escaped.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"` + : escaped; +} + +export interface BootServicePlan { + /** Absolute path of the node binary running this CLI. */ + readonly nodePath: string; + /** Absolute path of the pinned t3 entry point the unit will run. */ + readonly t3EntryPath: string; + readonly baseDir: string; + readonly logPath: string; + readonly unitPath: string; +} + +/** + * Pure so it is testable byte-for-byte. systemd user units run with a + * minimal environment: every path must be absolute, and the service must + * not rely on PATH, nvm shims, or shell profiles. Failures land in + * `logPath` because `systemctl --user` failures are otherwise invisible. + */ +export function renderBootServiceUnit(plan: BootServicePlan): string { + // No After=network-online.target: it does not exist in the systemd *user* + // manager, so ordering on it is silently ignored. The server retries its + // relay connection, and Restart=always covers early-boot failures. + return [ + "[Unit]", + "Description=T3 Code server (T3 Connect)", + // Give up after 5 crashes in 5 minutes so a persistently broken install + // (deleted runtime, broken workspace) stops instead of restarting every + // 5s forever and growing the unrotated append log without bound. + "StartLimitIntervalSec=300", + "StartLimitBurst=5", + "", + "[Service]", + "Type=simple", + "WorkingDirectory=%h", + `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`, + `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.t3EntryPath)} serve`, + "Restart=always", + "RestartSec=5", + `StandardOutput=append:${escapeSystemdSpecifiers(plan.logPath)}`, + `StandardError=append:${escapeSystemdSpecifiers(plan.logPath)}`, + "", + "[Install]", + "WantedBy=default.target", + "", + ].join("\n"); +} + +export class BootServiceUnsupportedError extends Schema.TaggedErrorClass()( + "BootServiceUnsupportedError", + { platform: Schema.String }, +) { + override get message(): string { + return `Background setup currently supports Linux with systemd; this machine reports '${this.platform}'.`; + } +} + +export class BootServiceCommandError extends Schema.TaggedErrorClass()( + "BootServiceCommandError", + { + step: Schema.String, + exitCode: Schema.optional(Schema.Number), + stdoutLength: Schema.optional(Schema.Number), + stderrLength: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.exitCode === undefined + ? `Background setup failed while ${this.step}.` + : `Background setup failed while ${this.step} (exit code ${this.exitCode}).`; + } +} + +export class BootServiceInstallError extends Schema.TaggedErrorClass()( + "BootServiceInstallError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not set up the T3 Code background service."; + } +} + +export type BootServiceError = + | BootServiceUnsupportedError + | BootServiceCommandError + | BootServiceInstallError; + +export interface BootServiceStatus { + readonly supported: boolean; + readonly installed: boolean; + /** False when the installed unit no longer matches what install would write. */ + readonly current: boolean; + readonly unitPath: string; + readonly logPath: string; +} + +export class BootService extends Context.Service< + BootService, + { + /** Installs the pinned runtime + unit, enables linger, starts the service. */ + readonly install: Effect.Effect; + /** + * Stops and removes the unit; leaves the pinned runtime for reuse. + * Returns whether a unit was actually removed. + */ + readonly uninstall: Effect.Effect; + readonly status: Effect.Effect; + } +>()("t3/cloud/bootService") {} + +export interface BootServiceHost { + readonly execPath: string; + readonly cliEntryPath: string; +} + +export const make = Effect.fn("cloud.boot_service.make")(function* (input: { + readonly baseDir: string; + readonly logsDir: string; + readonly cliVersion: string; + readonly host?: BootServiceHost; +}) { + const hostExecPath = yield* HostProcessExecutablePath; + const hostArguments = yield* HostProcessArguments; + const host = input.host ?? { + execPath: hostExecPath, + // When running the packed CLI this is dist/bin.mjs; when stable (global + // install, repo checkout) the boot service runs this same artifact. + cliEntryPath: hostArguments[1] ?? "", + }; + const platform = yield* HostProcessPlatform; + const homeDir = yield* Config.string("HOME").pipe(Config.withDefault("")); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runner = yield* ProcessRunner.ProcessRunner; + + const unitDir = path.join(homeDir, ".config", "systemd", "user"); + const unitPath = path.join(unitDir, BOOT_SERVICE_UNIT_FILE); + const logPath = path.join(input.logsDir, "boot-service.log"); + const runtimeVersionDir = path.join( + input.baseDir, + BOOT_RUNTIME_DIR, + "versions", + input.cliVersion, + ); + const runtimeEntryPath = path.join(runtimeVersionDir, "node_modules", "t3", "dist", "bin.mjs"); + const runtimeSentinelPath = path.join(runtimeVersionDir, ".install-complete"); + + const requireSystemdLinux = Effect.gen(function* () { + if (platform !== "linux" || homeDir === "") { + return yield* new BootServiceUnsupportedError({ platform }); + } + }); + + const runStep = Effect.fn("cloud.boot_service.run_step")(function* ( + step: string, + command: string, + args: ReadonlyArray, + options?: { readonly timeout?: Duration.Input }, + ) { + return yield* runner.run({ command, args, timeout: options?.timeout }).pipe( + Effect.mapError((cause) => new BootServiceCommandError({ step, cause })), + Effect.filterOrFail( + (result) => result.code === 0, + (result) => + new BootServiceCommandError({ + step, + exitCode: Number(result.code), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), + ), + Effect.tapError((error) => + DateTime.now.pipe( + Effect.flatMap((now) => + fs.writeFileString(logPath, `${DateTime.formatIso(now)} ${error.message}\n`, { + flag: "a", + }), + ), + Effect.ignore, + ), + ), + ); + }); + + /** + * Ensures plannedEntryPath exists before the unit points at it. A stable + * install (global bin, repo checkout) is used as-is; an ephemeral cache + * entry is replaced by `npm install --prefix`-ing the exact running + * version into /runtime/versions/. A real install (not a copy + * of bin.mjs) because t3 ships native deps like node-pty. + */ + const ensurePinnedRuntime = Effect.gen(function* () { + if (!isEphemeralCacheEntry(host.cliEntryPath)) { + return; + } + // The sentinel is written only after npm exits 0. Checking the entry + // file alone is not enough: npm extracts files before running native + // builds (node-pty), so a killed install leaves a plausible-looking but + // broken tree behind. + const alreadyPinned = yield* Effect.all([ + fs.exists(runtimeSentinelPath), + fs.exists(runtimeEntryPath), + ]).pipe( + Effect.map(([sentinelExists, entryExists]) => sentinelExists && entryExists), + Effect.mapError((cause) => new BootServiceInstallError({ cause })), + ); + if (alreadyPinned) { + return; + } + yield* fs.remove(runtimeVersionDir, { recursive: true, force: true }).pipe( + Effect.andThen(fs.makeDirectory(runtimeVersionDir, { recursive: true })), + Effect.mapError((cause) => new BootServiceInstallError({ cause })), + ); + yield* runStep( + "installing the pinned t3 runtime (this can take a few minutes)", + "npm", + [ + "install", + "--prefix", + runtimeVersionDir, + "--no-fund", + "--no-audit", + `t3@${input.cliVersion}`, + ], + // Native deps (node-pty) can compile from source on slow boxes; the + // ProcessRunner default of 60s would kill a healthy install. + { timeout: PINNED_RUNTIME_INSTALL_TIMEOUT }, + ).pipe( + Effect.tapError(() => + fs.remove(runtimeVersionDir, { recursive: true, force: true }).pipe(Effect.ignore), + ), + ); + yield* fs + .writeFileString(runtimeSentinelPath, `${input.cliVersion}\n`) + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + }); + + // Where the unit will point: derivable without touching the network, so + // status can compare units purely; install materializes it first. + const plannedEntryPath = isEphemeralCacheEntry(host.cliEntryPath) + ? runtimeEntryPath + : host.cliEntryPath; + const plan: BootServicePlan = { + nodePath: host.execPath, + t3EntryPath: plannedEntryPath, + baseDir: input.baseDir, + logPath, + unitPath, + }; + + const install: BootService["Service"]["install"] = Effect.gen(function* () { + yield* requireSystemdLinux; + yield* fs + .makeDirectory(input.logsDir, { recursive: true }) + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + + yield* ensurePinnedRuntime; + + const previousUnit = yield* fs.exists(unitPath).pipe( + Effect.flatMap((exists) => + exists + ? fs.readFileString(unitPath).pipe(Effect.map(Option.some)) + : Effect.succeed(Option.none()), + ), + Effect.mapError((cause) => new BootServiceInstallError({ cause })), + ); + + yield* fs.makeDirectory(unitDir, { recursive: true }).pipe( + Effect.andThen(fs.writeFileString(unitPath, renderBootServiceUnit(plan))), + Effect.mapError((cause) => new BootServiceInstallError({ cause })), + ); + + // If any activation step fails, remove the unit again: a leftover file + // would make the next `t3 connect` report the service as already set up + // even though it was never enabled or lingered. + yield* Effect.gen(function* () { + yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]); + yield* runStep("enabling the service", "systemctl", [ + "--user", + "enable", + BOOT_SERVICE_UNIT_FILE, + ]); + // restart rather than enable --now: --now does not replace an already + // running process, so repairing a stale unit would leave the old + // server running until reboot. restart also starts a stopped service. + yield* runStep("starting the service", "systemctl", [ + "--user", + "restart", + BOOT_SERVICE_UNIT_FILE, + ]); + // Linger keeps the user manager (and this service) running without an + // open session — the whole point on a box reached over SSH. No + // username argument: loginctl defaults to the calling user, which is + // always right, while $USER can be stale (su without -l) or unset. + yield* runStep("enabling lingering for this user", "loginctl", ["enable-linger"]); + }).pipe(Effect.tapError(() => rollbackFailedInstall(previousUnit))); + + return plan; + }).pipe(Effect.withSpan("cloud.boot_service.install")); + + // If activation fails partway (e.g. enable succeeds but restart/linger + // fails), leave nothing behind: disable removes the enable symlink, remove + // deletes the file, daemon-reload clears the stale definition — otherwise a + // dangling wants/ symlink logs "Failed to load unit" at every boot and the + // next connect misreports the state. + const rollbackFailedInstall = Effect.fn("cloud.boot_service.rollback_failed_install")(function* ( + previousUnit: Option.Option, + ) { + if (Option.isSome(previousUnit)) { + yield* fs.writeFileString(unitPath, previousUnit.value).pipe(Effect.ignore); + } else { + yield* runStep("cleaning up the service", "systemctl", [ + "--user", + "disable", + "--now", + BOOT_SERVICE_UNIT_FILE, + ]).pipe(Effect.ignore); + yield* fs.remove(unitPath).pipe(Effect.ignore); + } + yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]).pipe( + Effect.ignore, + ); + if (Option.isSome(previousUnit)) { + yield* runStep("restoring the previous service", "systemctl", [ + "--user", + "restart", + BOOT_SERVICE_UNIT_FILE, + ]).pipe(Effect.ignore); + } + }); + + const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () { + yield* requireSystemdLinux; + const exists = yield* fs + .exists(unitPath) + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + if (!exists) { + return false; + } + yield* runStep("stopping the service", "systemctl", [ + "--user", + "disable", + "--now", + BOOT_SERVICE_UNIT_FILE, + ]); + yield* fs + .remove(unitPath) + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]); + return true; + }).pipe(Effect.withSpan("cloud.boot_service.uninstall")); + + const status: BootService["Service"]["status"] = Effect.gen(function* () { + if (platform !== "linux" || homeDir === "") { + return { supported: false, installed: false, current: false, unitPath, logPath }; + } + const unitExists = yield* fs.exists(unitPath); + if (!unitExists) { + return { supported: true, installed: false, current: false, unitPath, logPath }; + } + const unit = yield* fs.readFileString(unitPath); + // A unit is current only if it matches what install would write now (an + // older CLI wrote a different runtime/node path) AND the entry point it + // references still exists (a pinned runtime under ~/.t3 can be deleted to + // reclaim space). Either mismatch makes connect offer a repair. + const entryExists = yield* fs.exists(plannedEntryPath); + const current = unit === renderBootServiceUnit(plan) && entryExists; + return { supported: true, installed: true, current, unitPath, logPath }; + }).pipe( + Effect.mapError((cause) => new BootServiceInstallError({ cause })), + Effect.withSpan("cloud.boot_service.status"), + ); + + return BootService.of({ install, uninstall, status }); +}); + +export const layer = (input: { + readonly baseDir: string; + readonly logsDir: string; + readonly cliVersion: string; + readonly host?: BootServiceHost; +}) => Layer.effect(BootService, make(input)); diff --git a/apps/server/src/cloud/cliAuthHtml.test.ts b/apps/server/src/cloud/cliAuthHtml.test.ts new file mode 100644 index 000000000000..1104927b9800 --- /dev/null +++ b/apps/server/src/cloud/cliAuthHtml.test.ts @@ -0,0 +1,31 @@ +import { expect, it } from "@effect/vitest"; + +import { + renderLoopbackAuthorizationCompleteHtml, + resolveLoopbackAuthorizationStage, +} from "./cliAuthHtml.ts"; + +it("renders the branded loopback authorization completion page", () => { + const html = renderLoopbackAuthorizationCompleteHtml(); + + expect(resolveLoopbackAuthorizationStage()).toBe("dev"); + expect(html).toContain("T3 Code (Dev)"); + expect(html).toContain('class="stage stage-dev"'); + expect(html).not.toContain("Secure terminal handoff"); + expect(html).toContain("You're connected"); + expect(html).toContain("Return to your terminal"); + expect(html).not.toContain('class="next"'); + expect(html).toContain('name="viewport"'); + expect(html).not.toContain('class="status"'); +}); + +it("renders the matching header treatment for each release channel", () => { + const nightly = renderLoopbackAuthorizationCompleteHtml("nightly"); + const latest = renderLoopbackAuthorizationCompleteHtml("latest"); + + expect(nightly).toContain("T3 Code (Nightly)"); + expect(nightly).toContain('class="stage stage-nightly"'); + expect(latest).toContain('

T3 Code

'); + expect(latest).not.toContain("(Latest)"); + expect(latest).toContain('class="stage stage-latest"'); +}); diff --git a/apps/server/src/cloud/cliAuthHtml.ts b/apps/server/src/cloud/cliAuthHtml.ts new file mode 100644 index 000000000000..5a22a25993a9 --- /dev/null +++ b/apps/server/src/cloud/cliAuthHtml.ts @@ -0,0 +1,155 @@ +export type LoopbackAuthorizationStage = "dev" | "nightly" | "latest"; + +declare const __T3CODE_BUILD_CHANNEL__: "nightly" | "latest" | undefined; + +export function resolveLoopbackAuthorizationStage(): LoopbackAuthorizationStage { + return typeof __T3CODE_BUILD_CHANNEL__ === "undefined" ? "dev" : __T3CODE_BUILD_CHANNEL__; +} + +const stageBrands = { + dev: "T3 Code (Dev)", + nightly: "T3 Code (Nightly)", + latest: "T3 Code", +} as const satisfies Record; + +export function renderLoopbackAuthorizationCompleteHtml( + stage: LoopbackAuthorizationStage = resolveLoopbackAuthorizationStage(), +): string { + const stageBrand = stageBrands[stage]; + + return ` + + + + + + T3 Connect authorization complete + + + +
+
+
+

${stageBrand}

+
+
+
+

Browser authorization complete

+

You're connected

+

Return to your terminal to finish setting up T3 Connect. You can close this window.

+
+
+ +`; +} diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index bab9cdd27f31..96abf2e4b4ae 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -200,6 +200,7 @@ describe("reconcileDesiredCloudLink", () => { get: unusedSecretStoreOperation(), getExisting: Effect.succeed(Option.none()), hasCredential: unusedSecretStoreOperation(), + store: () => unusedSecretStoreOperation(), clear: unusedSecretStoreOperation(), }), ), diff --git a/apps/server/src/cloud/publicConfig.test.ts b/apps/server/src/cloud/publicConfig.test.ts index c46e2671a46e..96a8a1b8b8a3 100644 --- a/apps/server/src/cloud/publicConfig.test.ts +++ b/apps/server/src/cloud/publicConfig.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Result from "effect/Result"; import { + hostedAppUrlConfig, makeCloudCliOAuthConfig, makeRelayUrlConfig, resolveRelayClientTracingConfig, @@ -47,6 +48,40 @@ it.effect("rejects an injected relay URL with a non-origin path", () => makeRelayUrlConfig("https://embedded.example.test/path").pipe(provideEnv({}), Effect.flip), ); +it.effect("normalizes the hosted app URL to an absolute origin", () => + Effect.gen(function* () { + assert.equal( + yield* hostedAppUrlConfig.pipe( + provideEnv({ T3CODE_HOSTED_APP_URL: "https://nightly.app.t3.codes" }), + ), + "https://nightly.app.t3.codes", + ); + assert.equal( + yield* hostedAppUrlConfig.pipe( + provideEnv({ T3CODE_HOSTED_APP_URL: "http://localhost:5733" }), + ), + "http://localhost:5733", + ); + }), +); + +it.effect("rejects malformed or insecure hosted app URLs", () => + Effect.gen(function* () { + for (const value of [ + "app.t3.codes", + "http://app.t3.codes", + "https://app.t3.codes/nested", + "https://app.t3.codes?alias=true", + ]) { + const result = yield* hostedAppUrlConfig.pipe( + provideEnv({ T3CODE_HOSTED_APP_URL: value }), + Effect.result, + ); + assert.isTrue(Result.isFailure(result), value); + } + }), +); + it.effect("derives direct Clerk OAuth endpoints from statically injected public config", () => Effect.gen(function* () { const config = yield* makeCloudCliOAuthConfig({ diff --git a/apps/server/src/cloud/publicConfig.ts b/apps/server/src/cloud/publicConfig.ts index 176b31d7566a..cb07d19721b5 100644 --- a/apps/server/src/cloud/publicConfig.ts +++ b/apps/server/src/cloud/publicConfig.ts @@ -1,3 +1,4 @@ +import { CONNECT_OAUTH_SCOPES, DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/connectAuth"; import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth"; import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; import * as Config from "effect/Config"; @@ -15,7 +16,7 @@ declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: string | undefi declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__: string | undefined; const CLOUD_CLI_OAUTH_REDIRECT_URI = "http://127.0.0.1:34338/callback"; -const CLOUD_CLI_OAUTH_SCOPES = ["openid", "profile", "email"] as const; +const CLOUD_CLI_OAUTH_SCOPES = CONNECT_OAUTH_SCOPES; function validateRelayUrl(value: string) { const relayUrl = normalizeSecureRelayUrl(value); @@ -100,6 +101,44 @@ export function makeRelayUrlConfig(fallback = buildTimeRelayUrl) { export const relayUrlConfig = makeRelayUrlConfig(); +/** + * Hosted app origin used for out-of-band OAuth on headless + * machines. Overridable so staging/nightly builds can point their CLIs at a + * matching hosted deployment. + */ +export const hostedAppUrlConfig = makePublicValueConfig( + "T3CODE_HOSTED_APP_URL", + DEFAULT_HOSTED_APP_URL, +).pipe(Config.mapOrFail(validateHostedAppUrl)); + +function validateHostedAppUrl(value: string) { + try { + const url = new URL(value); + const isLoopbackHttp = + url.protocol === "http:" && + (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]"); + if ( + (url.protocol !== "https:" && !isLoopbackHttp) || + url.pathname !== "/" || + url.search !== "" || + url.hash !== "" + ) { + throw new Error("invalid hosted app origin"); + } + return Effect.succeed(url.origin); + } catch { + return Effect.fail( + new Config.ConfigError( + new Schema.SchemaError( + new SchemaIssue.InvalidValue(Option.some(value), { + message: "Hosted app URL must be an absolute HTTPS origin (or HTTP loopback origin).", + }), + ), + ), + ); + } +} + function makePublicValueConfig(name: string, fallback: string) { const runtimeConfig = Config.nonEmptyString(name); return (fallback ? runtimeConfig.pipe(Config.withDefault(fallback)) : runtimeConfig).pipe( diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 80f5824ab2f3..9dc9b35af421 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -46,6 +46,10 @@ export interface ServerDerivedPaths { readonly secretsDir: string; } +export interface DeriveServerPathsOptions { + readonly baseDirIsExplicit?: boolean; +} + /** * ServerConfig - Service tag for server runtime configuration. */ @@ -93,9 +97,13 @@ export const layer = (config: ServerConfig["Service"]) => Layer.succeed(ServerCo export const deriveServerPaths = Effect.fn(function* ( baseDir: ServerConfig["Service"]["baseDir"], devUrl: ServerConfig["Service"]["devUrl"], + options: DeriveServerPathsOptions = {}, ): Effect.fn.Return { const { join } = yield* Path.Path; - const stateDir = join(baseDir, devUrl !== undefined ? "dev" : "userdata"); + const stateDir = join( + baseDir, + devUrl !== undefined && !options.baseDirIsExplicit ? "dev" : "userdata", + ); const dbPath = join(stateDir, "state.sqlite"); const attachmentsDir = join(stateDir, "attachments"); const logsDir = join(stateDir, "logs"); diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index d8557b922378..8a2f3b1d68f7 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -68,6 +68,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(first.environmentId).toBe(second.environmentId); expect(second.capabilities.repositoryIdentity).toBe(true); + expect(second.capabilities.connectionProbe).toBe(true); }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index c3b68fc8c1fa..f27233e42a59 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -179,6 +179,7 @@ export const make = Effect.gen(function* () { ...(serverBuildSha !== undefined ? { serverBuildSha } : {}), capabilities: { repositoryIdentity: true, + connectionProbe: true, }, }; diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index a76627e15095..cf099a758f83 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -161,15 +161,11 @@ export interface RunMigrationsOptions { export const runMigrations = Effect.fn("runMigrations")(function* ({ toMigrationInclusive, }: RunMigrationsOptions = {}) { - yield* Effect.log( - toMigrationInclusive === undefined - ? "Running all migrations..." - : `Running migrations 1 through ${toMigrationInclusive}...`, - ); const executedMigrations = yield* run({ loader: makeMigrationLoader(toMigrationInclusive) }); - yield* Effect.log("Migrations ran successfully").pipe( - Effect.annotateLogs({ migrations: executedMigrations.map(([id, name]) => `${id}_${name}`) }), - ); + const migrations = executedMigrations.map(([id, name]) => `${id}_${name}`); + yield* migrations.length === 0 + ? Effect.logDebug("Database schema is current") + : Effect.log("Migrations ran successfully").pipe(Effect.annotateLogs({ migrations })); return executedMigrations; }); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index a83c134d5bd2..46d0b3019bd6 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -37,7 +37,7 @@ const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJ const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; -const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 5_000; +const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000; const DEFAULT_HOSTNAME = "127.0.0.1"; export interface OpenCodeServerProcess { readonly url: string; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0ef48f9e5b71..b7a9f2b834e0 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -370,7 +370,10 @@ const RuntimeCoreDependenciesLive = RuntimeCoreDependenciesBaseLive.pipe( Layer.provideMerge(DeviceNotificationsLayerLive), Layer.provideMerge( Layer.mergeAll( - CloudCliTokenManager.layer.pipe(Layer.provide(ServerSecretStore.layer)), + CloudCliTokenManager.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provide(ExternalLauncher.layer), + ), CloudManagedEndpointRuntimeLive, ), ), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 356feb2a6170..c0e24654e775 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -286,6 +286,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [ORCHESTRATION_WS_METHODS.subscribeScheduledTasks, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.setScheduledTaskEnabled, AuthOrchestrationOperateScope], [ORCHESTRATION_WS_METHODS.deleteScheduledTask, AuthOrchestrationOperateScope], + [WS_METHODS.serverProbe, AuthOrchestrationReadScope], [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope], [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope], [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope], @@ -1404,6 +1405,10 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => ), { "rpc.aggregate": "orchestration" }, ), + [WS_METHODS.serverProbe]: (_input) => + observeRpcEffect(WS_METHODS.serverProbe, Effect.succeed({}), { + "rpc.aggregate": "server", + }), [WS_METHODS.serverGetConfig]: (_input) => observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { "rpc.aggregate": "server", diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 3510ba6f4b0e..73c2d9fbe85f 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -8,6 +8,7 @@ import { resolveBarePackageName, } from "../../scripts/lib/package-names.ts"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; +import packageJson from "./package.json" with { type: "json" }; const externalRuntimePackageNames = new Set([ "@effect/platform-bun", @@ -34,6 +35,7 @@ export function shouldBundleCliDependency(id: string): boolean { } const repoEnv = loadRepoEnv(); +const cliBuildChannel = packageJson.version.includes("-nightly.") ? "nightly" : "latest"; function resolveBuildSha(): string { const envSha = process.env.T3CODE_BUILD_SHA?.trim() ?? ""; @@ -70,6 +72,7 @@ export default mergeConfig( define: { __T3CODE_BUILD_SHA__: JSON.stringify(buildSha), __T3CODE_BUILD_VERSION__: JSON.stringify(buildVersion), + __T3CODE_BUILD_CHANNEL__: JSON.stringify(cliBuildChannel), __T3CODE_BUILD_RELAY_URL__: JSON.stringify(repoEnv.T3CODE_RELAY_URL?.trim() ?? ""), __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: JSON.stringify( repoEnv.T3CODE_CLERK_PUBLISHABLE_KEY?.trim() ?? "", diff --git a/apps/web/src/branding.logic.ts b/apps/web/src/branding.logic.ts index b87276f1b9cf..056fbb76e6ab 100644 --- a/apps/web/src/branding.logic.ts +++ b/apps/web/src/branding.logic.ts @@ -4,6 +4,10 @@ export function formatAppDisplayName(input: { readonly baseName: string; readonly stageLabel: string; }): string { + if (input.stageLabel.trim().toLowerCase() === "latest") { + return input.baseName; + } + return `${input.baseName} (${input.stageLabel})`; } diff --git a/apps/web/src/branding.test.ts b/apps/web/src/branding.test.ts index 4aa969c0279d..e1c87bcf0595 100644 --- a/apps/web/src/branding.test.ts +++ b/apps/web/src/branding.test.ts @@ -50,6 +50,17 @@ describe("branding", () => { expect(branding.APP_DISPLAY_NAME).toBe("T3 Code (Nightly)"); }); + it("does not label the latest hosted app channel", async () => { + vi.stubEnv("VITE_HOSTED_APP_CHANNEL", "latest"); + + const branding = await import("./branding"); + + expect(branding.HOSTED_APP_CHANNEL).toBe("latest"); + expect(branding.HOSTED_APP_CHANNEL_LABEL).toBe("Latest"); + expect(branding.APP_STAGE_LABEL).toBe("Latest"); + expect(branding.APP_DISPLAY_NAME).toBe("T3 Code"); + }); + it("ignores unknown hosted app channels", async () => { vi.stubEnv("VITE_HOSTED_APP_CHANNEL", "preview"); diff --git a/apps/web/src/cloud/connectCliAuth.test.ts b/apps/web/src/cloud/connectCliAuth.test.ts new file mode 100644 index 000000000000..61d854eb6ab3 --- /dev/null +++ b/apps/web/src/cloud/connectCliAuth.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + buildConnectCliClerkAuthorizeUrl, + hasConnectCliAuthConfig, + readConnectCliCallbackResult, +} from "./connectCliAuth"; + +// Any pk_test_* key decodes to .clerk.accounts.dev. +const TEST_PUBLISHABLE_KEY = `pk_test_${btoa("witty-mole-42.clerk.accounts.dev$")}`; + +describe("connectCliAuth", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("requires both the publishable key and the CLI OAuth client id", () => { + vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); + vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", "t3-relay"); + vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.com"); + expect(hasConnectCliAuthConfig()).toBe(false); + + vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123"); + expect(hasConnectCliAuthConfig()).toBe(true); + }); + + it("builds the Clerk authorize URL with the configured hosted origin's callback", () => { + vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); + vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123"); + vi.stubEnv("VITE_HOSTED_APP_URL", "https://nightly.app.t3.codes"); + + const authorizeUrl = buildConnectCliClerkAuthorizeUrl({ + state: "state-1", + challenge: "challenge-1", + }); + expect(authorizeUrl).not.toBeNull(); + + const url = new URL(authorizeUrl!); + expect(url.hostname).toBe("witty-mole-42.clerk.accounts.dev"); + expect(url.pathname).toBe("/oauth/authorize"); + expect(url.searchParams.get("redirect_uri")).toBe( + "https://nightly.app.t3.codes/connect/callback", + ); + expect(url.searchParams.get("state")).toBe("state-1"); + expect(url.searchParams.get("code_challenge")).toBe("challenge-1"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + }); + + it("returns null when the CLI OAuth client id is not configured", () => { + vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); + expect( + buildConnectCliClerkAuthorizeUrl({ state: "state-1", challenge: "challenge-1" }), + ).toBeNull(); + }); + + it("reads the code and state Clerk echoes back to the callback", () => { + expect( + readConnectCliCallbackResult( + new URL("https://app.t3.codes/connect/callback?code=abc&state=state-1"), + ), + ).toEqual({ code: "abc", state: "state-1" }); + expect( + readConnectCliCallbackResult(new URL("https://app.t3.codes/connect/callback?code=abc")), + ).toBeNull(); + expect( + readConnectCliCallbackResult(new URL("https://app.t3.codes/connect/callback?state=s")), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/cloud/connectCliAuth.ts b/apps/web/src/cloud/connectCliAuth.ts new file mode 100644 index 000000000000..849319dcebef --- /dev/null +++ b/apps/web/src/cloud/connectCliAuth.ts @@ -0,0 +1,91 @@ +import { + buildConnectClerkAuthorizeUrl, + connectCallbackUrl, + CONNECT_OAUTH_SCOPES, + type ConnectAuthorizeRequest, +} from "@t3tools/shared/connectAuth"; +import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth"; + +import { configuredHostedAppUrl, isHostedStaticApp } from "../hostedPairing"; +import { hasCloudPublicConfig, resolveCloudPublicConfig, trimNonEmpty } from "./publicConfig"; + +const CONNECT_CLI_AUTH_STATE_STORAGE_KEY = "t3code-connect-cli-auth-state"; + +export function resolveConnectCliOAuthClientId(): string | null { + return trimNonEmpty(import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID as string | undefined); +} + +export function hasConnectCliAuthConfig(): boolean { + return Boolean( + resolveCloudPublicConfig().clerkPublishableKey && resolveConnectCliOAuthClientId(), + ); +} + +/** + * Gate for the /connect routes: the CLI handshake only exists on the hosted + * deployment (the same bundle ships inside local instances) and needs the + * Clerk CLI OAuth client configured at build time. + */ +export function connectCliAuthRoutesEnabled(): boolean { + return isHostedStaticApp() && hasCloudPublicConfig() && hasConnectCliAuthConfig(); +} + +/** + * Builds the Clerk authorize URL for a CLI-initiated connect request. The + * state is mirrored into sessionStorage so the callback page can verify the + * response matches a request this browser actually started. + */ +export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeRequest): string | null { + const { clerkPublishableKey } = resolveCloudPublicConfig(); + const clientId = resolveConnectCliOAuthClientId(); + if (!clerkPublishableKey || !clientId) { + return null; + } + return buildConnectClerkAuthorizeUrl({ + authorizationEndpoint: `${clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey)}/oauth/authorize`, + clientId, + redirectUri: connectCallbackUrl(configuredHostedAppUrl()), + scopes: CONNECT_OAUTH_SCOPES, + state: request.state, + challenge: request.challenge, + }); +} + +export function rememberConnectCliAuthState(state: string): void { + try { + window.sessionStorage.setItem(CONNECT_CLI_AUTH_STATE_STORAGE_KEY, state); + } catch { + // Session storage can be unavailable (e.g. blocked). The callback page + // then falls back to trusting the state Clerk echoed back. + } +} + +/** + * Read-only on purpose: this runs during render, where a removal would be + * consumed by React's double-invoked/discarded renders (StrictMode) and + * silently disable the state check. The value is not a secret and is + * overwritten by the next /connect visit. + */ +export function readConnectCliAuthState(): string | null { + try { + return window.sessionStorage.getItem(CONNECT_CLI_AUTH_STATE_STORAGE_KEY); + } catch { + return null; + } +} + +export interface ConnectCliCallbackResult { + readonly code: string; + readonly state: string; +} + +export function readConnectCliCallbackResult( + url: URL = new URL(window.location.href), +): ConnectCliCallbackResult | null { + const code = url.searchParams.get("code")?.trim() ?? ""; + const state = url.searchParams.get("state")?.trim() ?? ""; + if (!code || !state) { + return null; + } + return { code, state }; +} diff --git a/apps/web/src/cloud/publicConfig.ts b/apps/web/src/cloud/publicConfig.ts index d9d0e5f44cb6..2caf4f52f360 100644 --- a/apps/web/src/cloud/publicConfig.ts +++ b/apps/web/src/cloud/publicConfig.ts @@ -24,7 +24,7 @@ export interface CloudPublicConfig { }; } -function trimNonEmpty(value: string | undefined): string | null { +export function trimNonEmpty(value: string | undefined): string | null { return value?.trim() || null; } diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 07ec0dc90778..587ec65cbc98 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -4,10 +4,18 @@ import { useLocation, useNavigate } from "@tanstack/react-router"; import { isElectron } from "../env"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; -import { isMacPlatform } from "../lib/utils"; +import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; import ThreadSidebar from "./Sidebar"; -import { Sidebar, SidebarProvider, SidebarRail, SidebarTrigger, useSidebar } from "./ui/sidebar"; +import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; +import { + Sidebar, + SidebarProvider, + SidebarRail, + SidebarTrigger, + useSidebar, + useSidebarVisibility, +} from "./ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; @@ -18,6 +26,8 @@ const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px"; function SidebarControl() { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { toggleSidebar } = useSidebar(); + const isSidebarVisible = useSidebarVisibility(); + const stageBackdropVariant = useSidebarStageBackdropVariant(); const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle"); useEffect(() => { @@ -42,7 +52,15 @@ function SidebarControl() { + } /> diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 40bc8417a96e..59b13f24b3c9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -79,7 +79,7 @@ import { isElectron } from "../env"; import { APP_STAGE_LABEL } from "../branding"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { isMacPlatform } from "../lib/utils"; +import { cn, isMacPlatform } from "../lib/utils"; import { readThreadShell, useProject, @@ -138,6 +138,7 @@ import { stackedThreadToast, toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; import { isProjectNotEmptyInvariant } from "./sidebarProjectDelete"; +import { SidebarStageBackdrop, resolveSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { Kbd } from "./ui/kbd"; import { getArm64IntelBuildWarningDescription, @@ -2988,35 +2989,47 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({ }: { isElectron: boolean; }) { - return isElectron ? ( - - - - - ) : ( - - - + const stageLabel = useSidebarStageLabel(); + const backdropVariant = resolveSidebarStageBackdropVariant(stageLabel); + + return ( + + {backdropVariant ? : null} + + ); }); -function SidebarBrand() { - const stageLabel = useSidebarStageLabel(); - +function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { return ( - + Code - - {stageLabel} - ); } @@ -3035,7 +3048,7 @@ function T3Wordmark() { return ( diff --git a/apps/web/src/components/SidebarStageBackdrop.tsx b/apps/web/src/components/SidebarStageBackdrop.tsx new file mode 100644 index 000000000000..6f0953a9773d --- /dev/null +++ b/apps/web/src/components/SidebarStageBackdrop.tsx @@ -0,0 +1,320 @@ +import { useAtomValue } from "@effect/atom-react"; + +import { APP_STAGE_LABEL } from "../branding"; +import { resolveServerBackedAppStageLabel } from "../branding.logic"; +import { primaryServerConfigAtom } from "../state/server"; + +export type SidebarStageBackdropVariant = "nightly" | "dev"; + +// A wide viewBox keeps the 96-unit art height at a fixed scale while sidebar resizing reveals +// more horizontal canvas instead of zooming the scene. +const STAGE_BACKDROP_VIEW_BOX = "0 0 8192 96"; + +export function resolveSidebarStageBackdropVariant( + stageLabel: string, +): SidebarStageBackdropVariant | null { + const normalized = stageLabel.trim().toLowerCase(); + if (normalized === "nightly") return "nightly"; + if (normalized === "dev") return "dev"; + return null; +} + +export function useSidebarStageBackdropVariant(): SidebarStageBackdropVariant | null { + const primaryServerVersion = + useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; + + return resolveSidebarStageBackdropVariant( + resolveServerBackedAppStageLabel({ + primaryServerVersion, + fallbackStageLabel: APP_STAGE_LABEL, + }), + ); +} + +/** Stage-channel header art; palettes mirror the per-channel app icons in `assets/`. */ +export function SidebarStageBackdrop({ variant }: { variant: SidebarStageBackdropVariant }) { + return ( +
+ +
+ ); +} + +export function StageBackdropArt({ variant }: { variant: SidebarStageBackdropVariant }) { + return variant === "nightly" ? : ; +} + +const NIGHTLY_STARS: ReadonlyArray<{ + cx: number; + cy: number; + r: number; + opacity: number; +}> = [ + { cx: 14, cy: 10, r: 0.6, opacity: 0.85 }, + { cx: 38, cy: 22, r: 0.4, opacity: 0.55 }, + { cx: 58, cy: 8, r: 0.5, opacity: 0.7 }, + { cx: 84, cy: 16, r: 0.4, opacity: 0.5 }, + { cx: 104, cy: 7, r: 0.6, opacity: 0.8 }, + { cx: 126, cy: 20, r: 0.4, opacity: 0.55 }, + { cx: 148, cy: 11, r: 0.5, opacity: 0.7 }, + { cx: 170, cy: 24, r: 0.4, opacity: 0.5 }, + { cx: 192, cy: 9, r: 0.6, opacity: 0.8 }, + { cx: 214, cy: 18, r: 0.4, opacity: 0.55 }, + { cx: 236, cy: 8, r: 0.5, opacity: 0.7 }, + { cx: 258, cy: 20, r: 0.45, opacity: 0.6 }, + { cx: 278, cy: 11, r: 0.55, opacity: 0.75 }, + { cx: 26, cy: 34, r: 0.4, opacity: 0.45 }, + { cx: 118, cy: 34, r: 0.4, opacity: 0.45 }, + { cx: 202, cy: 32, r: 0.4, opacity: 0.5 }, + { cx: 268, cy: 34, r: 0.4, opacity: 0.45 }, +]; + +const NIGHTLY_SPARKLES: ReadonlyArray<{ x: number; y: number }> = [ + { x: 70, y: 28 }, + { x: 160, y: 36 }, + { x: 246, y: 26 }, +]; + +function NightlySkyArt() { + return ( + + + + + + + + + + + + + + + + + + + + + + + {NIGHTLY_STARS.map((star) => ( + + ))} + + + {NIGHTLY_SPARKLES.map((sparkle) => ( + + + + + ))} + + + + + + + + + + + + + + + + + + + ); +} + +function DevBlueprintArt() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/web/src/components/auth/AuthSurfaceShell.tsx b/apps/web/src/components/auth/AuthSurfaceShell.tsx new file mode 100644 index 000000000000..1c45c98a9e49 --- /dev/null +++ b/apps/web/src/components/auth/AuthSurfaceShell.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from "react"; + +import { APP_DISPLAY_NAME, APP_STAGE_LABEL } from "../../branding"; +import { resolveSidebarStageBackdropVariant, StageBackdropArt } from "../SidebarStageBackdrop"; + +/** + * Full-screen card for standalone auth pages, mirroring the pairing surface's + * treatment. Used by the CLI-connect authorize and callback surfaces. + */ +export function AuthSurfaceShell({ children }: { readonly children: ReactNode }) { + const stageVariant = resolveSidebarStageBackdropVariant(APP_STAGE_LABEL); + + return ( +
+
+
+
+
+ +
+
+ {stageVariant ? ( +
+ +
+ ) : ( +
+ )} +
+
+

+ {APP_DISPLAY_NAME} +

+
+
+ +
{children}
+
+
+ ); +} diff --git a/apps/web/src/components/chat/ComposerBannerStack.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.test.tsx new file mode 100644 index 000000000000..16fdff644255 --- /dev/null +++ b/apps/web/src/components/chat/ComposerBannerStack.test.tsx @@ -0,0 +1,37 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { ComposerBannerStack, type ComposerBannerStackItem } from "./ComposerBannerStack"; + +const banner = (id: string): ComposerBannerStackItem => ({ + id, + variant: "warning", + icon: , + title: `${id} warning`, +}); + +describe("ComposerBannerStack", () => { + it("keeps expanded banners in layout flow so surrounding content moves out of their way", () => { + const markup = renderToStaticMarkup( + , + ); + + const expandedItems = markup.match( + /
/, + ); + + expect(expandedItems?.[1]).toContain("grid-rows-[0fr]"); + expect(expandedItems?.[1]).toContain("group-hover/banner-stack:grid-rows-[1fr]"); + expect(expandedItems?.[1]).toContain("z-20"); + expect(expandedItems?.[1]).not.toContain("absolute"); + expect(markup.indexOf("front warning")).toBeLessThan(markup.indexOf("stacked warning")); + expect(markup).toContain("invisible pointer-events-none"); + expect(markup).toContain("group-focus-within/banner-stack:visible"); + }); + + it("does not render an expandable region for a single banner", () => { + const markup = renderToStaticMarkup(); + + expect(markup).not.toContain("data-composer-banner-stack-expanded-items"); + }); +}); diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 1f82c452b058..d51c23a2f276 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -85,7 +85,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
@@ -119,30 +119,39 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
{hasStack ? (
- {stackedItems.map((item) => ( +
- requestDismiss(item)} - /> + {stackedItems.map((item) => ( +
+ requestDismiss(item)} + /> +
+ ))}
- ))} +
) : null}
diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 32d195d7b2ea..6a88fb8da19b 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -101,7 +101,7 @@ export function DraftHeroHeadline({ ); return ( -

+

{hasResolvedProject ? ( <>What should we build in {projectSelector}? ) : canChooseProject ? ( diff --git a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx new file mode 100644 index 000000000000..40ddc4c3dd98 --- /dev/null +++ b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx @@ -0,0 +1,188 @@ +import { useAuth, useClerk, useUser } from "@clerk/react"; +import { encodeConnectAuthCode, readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth"; +import { useEffect, useRef, useState } from "react"; + +import { + buildConnectCliClerkAuthorizeUrl, + readConnectCliAuthState, + readConnectCliCallbackResult, + rememberConnectCliAuthState, +} from "../../cloud/connectCliAuth"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { AuthSurfaceShell } from "../auth/AuthSurfaceShell"; +import { Button } from "../ui/button"; + +function ConnectCliAuthMessage({ + eyebrow, + title, + description, +}: { + readonly eyebrow?: string; + readonly title: string; + readonly description: string; +}) { + return ( + <> + {eyebrow ? ( +

+ {eyebrow} +

+ ) : null} +

{title}

+

{description}

+ + ); +} + +const invalidLinkMessage = { + eyebrow: "Authorization request", + title: "This connect link is incomplete", + description: + "The link is missing its authorization request. Re-run `t3 connect` in your terminal and open the freshly printed URL.", +} as const; + +/** + * /connect: the URL a headless CLI prints. Waits for a Clerk session, then + * forwards the CLI's PKCE request to Clerk's authorize endpoint. + */ +export function ConnectCliAuthorizeSurface() { + const [request] = useState(() => readConnectAuthorizeRequest(new URL(window.location.href))); + const clerk = useClerk(); + const { isLoaded, isSignedIn } = useAuth(); + const signInOpened = useRef(false); + const redirecting = useRef(false); + + useEffect(() => { + if (!request || !isLoaded || redirecting.current) { + return; + } + if (!isSignedIn) { + if (!signInOpened.current) { + signInOpened.current = true; + clerk.openSignIn({ forceRedirectUrl: window.location.href }); + } + return; + } + const authorizeUrl = buildConnectCliClerkAuthorizeUrl(request); + if (!authorizeUrl) { + return; + } + redirecting.current = true; + rememberConnectCliAuthState(request.state); + window.location.assign(authorizeUrl); + }, [clerk, isLoaded, isSignedIn, request]); + + if (!request) { + return ( + + + + ); + } + + return ( + + + {isLoaded && !isSignedIn ? ( +
+ +
+ ) : null} +
+ ); +} + +/** + * /connect/callback: Clerk's redirect target. Shows the one-time code the + * user enters in the waiting terminal. + */ +export function ConnectCliCallbackSurface() { + const [result] = useState(readConnectCliCallbackResult); + const [expectedState] = useState(readConnectCliAuthState); + const { user } = useUser(); + const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "authentication code" }); + + if (!result) { + return ( + + + + ); + } + + // Fail closed: the legitimate callback always lands in the same browser + // that visited /connect (which recorded the state), so a missing or + // mismatched state means this page was reached some other way — the CSRF + // shape the state parameter exists to stop. Refuse to display a code. + if (expectedState === null || expectedState !== result.state) { + return ( + + + + ); + } + + const accountLabel = user?.primaryEmailAddress?.emailAddress ?? user?.username ?? null; + const authCode = encodeConnectAuthCode(result); + + return ( + + + +
+
+ + One-time authorization code + + expires shortly +
+ + {authCode} + +
+ +
+ +
+ +

+ Only enter this code in a terminal session you started yourself. Anyone holding it can link + their machine to your T3 Connect account while it is valid. +

+
+ ); +} diff --git a/apps/web/src/hostedPairing.ts b/apps/web/src/hostedPairing.ts index 02b2d3a2163b..d777a4590200 100644 --- a/apps/web/src/hostedPairing.ts +++ b/apps/web/src/hostedPairing.ts @@ -1,6 +1,7 @@ +import { DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/connectAuth"; + import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "./pairingUrl"; -const DEFAULT_HOSTED_APP_URL = "https://app.t3.codes"; const CLOUDFLARE_PAGES_HOST_SUFFIX = ".pages.dev"; const DEFAULT_SERVER_BACKED_CLOUDFLARE_PAGES_HOSTS = ["dl5-5uq.pages.dev"]; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index c500eb68d7f8..77a5f027c139 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -231,6 +231,42 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } + /* Stage-channel sidebar art; ::after ramps to the sidebar bg color and the + mask lets the surface grain show through at the boundary. */ + .sidebar-stage-backdrop { + mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%); + -webkit-mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%); + } + + .sidebar-stage-backdrop::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + to bottom, + transparent 0%, + transparent 28%, + color-mix(in srgb, var(--app-chrome-background) 10%, transparent) 40%, + color-mix(in srgb, var(--app-chrome-background) 30%, transparent) 52%, + color-mix(in srgb, var(--app-chrome-background) 58%, transparent) 64%, + color-mix(in srgb, var(--app-chrome-background) 82%, transparent) 75%, + color-mix(in srgb, var(--app-chrome-background) 96%, transparent) 85%, + var(--app-chrome-background) 93% + ); + } + + .stage-blueprint { + --stage-bp-top: #67c2ff; + --stage-bp-mid: #347ff8; + --stage-bp-bottom: #1538d0; + } + + .dark .stage-blueprint { + --stage-bp-top: #3a7ad1; + --stage-bp-mid: #2050ae; + --stage-bp-bottom: #101f6e; + } + .workspace-topbar { display: flex; height: var(--workspace-topbar-height); diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 61fb25a5c85d..479bd10a5988 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SettingsRouteImport } from './routes/settings' import { Route as ScheduledRouteImport } from './routes/scheduled' import { Route as PairRouteImport } from './routes/pair' +import { Route as ConnectRouteImport } from './routes/connect' import { Route as ChatRouteImport } from './routes/_chat' import { Route as ScheduledIndexRouteImport } from './routes/scheduled.index' import { Route as ChatIndexRouteImport } from './routes/_chat.index' @@ -22,6 +23,7 @@ import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' +import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -40,6 +42,11 @@ const PairRoute = PairRouteImport.update({ path: '/pair', getParentRoute: () => rootRouteImport, } as any) +const ConnectRoute = ConnectRouteImport.update({ + id: '/connect', + path: '/connect', + getParentRoute: () => rootRouteImport, +} as any) const ChatRoute = ChatRouteImport.update({ id: '/_chat', getParentRoute: () => rootRouteImport, @@ -89,6 +96,11 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ path: '/archived', getParentRoute: () => SettingsRoute, } as any) +const ConnectCallbackRoute = ConnectCallbackRouteImport.update({ + id: '/connect_/callback', + path: '/connect/callback', + getParentRoute: () => rootRouteImport, +} as any) const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ id: '/draft/$draftId', path: '/draft/$draftId', @@ -103,9 +115,11 @@ const ChatEnvironmentIdThreadIdRoute = export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute + '/connect': typeof ConnectRoute '/pair': typeof PairRoute '/scheduled': typeof ScheduledRouteWithChildren '/settings': typeof SettingsRouteWithChildren + '/connect/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -118,8 +132,10 @@ export interface FileRoutesByFullPath { '/draft/$draftId': typeof ChatDraftDraftIdRoute } export interface FileRoutesByTo { + '/connect': typeof ConnectRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/connect/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -135,9 +151,11 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/_chat': typeof ChatRouteWithChildren + '/connect': typeof ConnectRoute '/pair': typeof PairRoute '/scheduled': typeof ScheduledRouteWithChildren '/settings': typeof SettingsRouteWithChildren + '/connect_/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -154,9 +172,11 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/connect' | '/pair' | '/scheduled' | '/settings' + | '/connect/callback' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -169,8 +189,10 @@ export interface FileRouteTypes { | '/draft/$draftId' fileRoutesByTo: FileRoutesByTo to: + | '/connect' | '/pair' | '/settings' + | '/connect/callback' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -185,9 +207,11 @@ export interface FileRouteTypes { id: | '__root__' | '/_chat' + | '/connect' | '/pair' | '/scheduled' | '/settings' + | '/connect_/callback' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -203,9 +227,11 @@ export interface FileRouteTypes { } export interface RootRouteChildren { ChatRoute: typeof ChatRouteWithChildren + ConnectRoute: typeof ConnectRoute PairRoute: typeof PairRoute ScheduledRoute: typeof ScheduledRouteWithChildren SettingsRoute: typeof SettingsRouteWithChildren + ConnectCallbackRoute: typeof ConnectCallbackRoute } declare module '@tanstack/react-router' { @@ -231,6 +257,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PairRouteImport parentRoute: typeof rootRouteImport } + '/connect': { + id: '/connect' + path: '/connect' + fullPath: '/connect' + preLoaderRoute: typeof ConnectRouteImport + parentRoute: typeof rootRouteImport + } '/_chat': { id: '/_chat' path: '' @@ -301,6 +334,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsArchivedRouteImport parentRoute: typeof SettingsRoute } + '/connect_/callback': { + id: '/connect_/callback' + path: '/connect/callback' + fullPath: '/connect/callback' + preLoaderRoute: typeof ConnectCallbackRouteImport + parentRoute: typeof rootRouteImport + } '/_chat/draft/$draftId': { id: '/_chat/draft/$draftId' path: '/draft/$draftId' @@ -370,9 +410,11 @@ const SettingsRouteWithChildren = SettingsRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { ChatRoute: ChatRouteWithChildren, + ConnectRoute: ConnectRoute, PairRoute: PairRoute, ScheduledRoute: ScheduledRouteWithChildren, SettingsRoute: SettingsRouteWithChildren, + ConnectCallbackRoute: ConnectCallbackRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 29db52fa2844..ae3073a56e15 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -98,7 +98,7 @@ function RootRouteView() { }; }, [pathname]); - if (pathname === "/pair") { + if (pathname === "/pair" || pathname === "/connect" || pathname.startsWith("/connect/")) { return ( <> diff --git a/apps/web/src/routes/connect.tsx b/apps/web/src/routes/connect.tsx new file mode 100644 index 000000000000..30750a977ea4 --- /dev/null +++ b/apps/web/src/routes/connect.tsx @@ -0,0 +1,13 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; + +import { connectCliAuthRoutesEnabled } from "../cloud/connectCliAuth"; +import { ConnectCliAuthorizeSurface } from "../components/cloud/ConnectCliAuthSurface"; + +export const Route = createFileRoute("/connect")({ + beforeLoad: () => { + if (!connectCliAuthRoutesEnabled()) { + throw redirect({ to: "/", replace: true }); + } + }, + component: ConnectCliAuthorizeSurface, +}); diff --git a/apps/web/src/routes/connect_.callback.tsx b/apps/web/src/routes/connect_.callback.tsx new file mode 100644 index 000000000000..a5beee0b9d3b --- /dev/null +++ b/apps/web/src/routes/connect_.callback.tsx @@ -0,0 +1,13 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; + +import { connectCliAuthRoutesEnabled } from "../cloud/connectCliAuth"; +import { ConnectCliCallbackSurface } from "../components/cloud/ConnectCliAuthSurface"; + +export const Route = createFileRoute("/connect_/callback")({ + beforeLoad: () => { + if (!connectCliAuthRoutesEnabled()) { + throw redirect({ to: "/", replace: true }); + } + }, + component: ConnectCliCallbackSurface, +}); diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index ff43861960d9..deea1824a9e1 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -10,6 +10,7 @@ interface ImportMetaEnv { readonly VITE_SERVER_BACKED_PAGES_HOSTS: string; readonly VITE_CLERK_PUBLISHABLE_KEY: string; readonly VITE_CLERK_JWT_TEMPLATE: string; + readonly VITE_CLERK_CLI_OAUTH_CLIENT_ID: string; readonly VITE_RELAY_OTLP_TRACES_URL: string; readonly VITE_RELAY_OTLP_TRACES_DATASET: string; readonly VITE_RELAY_OTLP_TRACES_TOKEN: string; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 4804a44079e2..ba64e9103be1 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -20,6 +20,7 @@ const configuredWsUrl = process.env.VITE_WS_URL?.trim(); const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || ""; const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || ""; const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || ""; +const configuredClerkCliOAuthClientId = repoEnv.VITE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() || ""; const configuredRelayTracingUrl = repoEnv.VITE_RELAY_OTLP_TRACES_URL?.trim() || ""; const configuredRelayTracingDataset = repoEnv.VITE_RELAY_OTLP_TRACES_DATASET?.trim() || ""; const configuredRelayTracingToken = repoEnv.VITE_RELAY_OTLP_TRACES_TOKEN?.trim() || ""; @@ -161,6 +162,9 @@ export default defineConfig(() => { "import.meta.env.VITE_T3CODE_RELAY_URL": JSON.stringify(configuredRelayUrl), "import.meta.env.VITE_CLERK_PUBLISHABLE_KEY": JSON.stringify(configuredClerkPublishableKey), "import.meta.env.VITE_CLERK_JWT_TEMPLATE": JSON.stringify(configuredClerkJwtTemplate), + "import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID": JSON.stringify( + configuredClerkCliOAuthClientId, + ), "import.meta.env.VITE_RELAY_OTLP_TRACES_URL": JSON.stringify(configuredRelayTracingUrl), "import.meta.env.VITE_RELAY_OTLP_TRACES_DATASET": JSON.stringify( configuredRelayTracingDataset, diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 5b98d1163ea4..1d55894b1824 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -165,16 +165,24 @@ The backend reads observability config at process start. If you change OTLP env The trace file is the fastest way to inspect raw span data. +Resolve the production or explicitly configured trace file once. Runtime state lives under the +base directory's `userdata` folder: + +```bash +TRACE_FILE="${T3CODE_HOME:-$HOME/.t3}/userdata/logs/server.trace.ndjson" +``` + Tail it: ```bash -tail -f "$T3CODE_HOME/userdata/logs/server.trace.ndjson" +tail -f "$TRACE_FILE" ``` -In monorepo dev, use: +For an implicit monorepo dev server, use: ```bash -tail -f ./dev/logs/server.trace.ndjson +TRACE_FILE="$HOME/.t3/dev/logs/server.trace.ndjson" +tail -f "$TRACE_FILE" ``` Show failed spans: @@ -185,7 +193,7 @@ jq -c 'select(.exit._tag != "Success") | { durationMs, exit, attributes -}' "$T3CODE_HOME/userdata/logs/server.trace.ndjson" +}' "$TRACE_FILE" ``` Show slow spans: @@ -196,7 +204,7 @@ jq -c 'select(.durationMs > 1000) | { durationMs, traceId, spanId -}' "$T3CODE_HOME/userdata/logs/server.trace.ndjson" +}' "$TRACE_FILE" ``` Inspect embedded log events: @@ -213,7 +221,7 @@ jq -c 'select(any(.events[]?; .attributes["effect.logLevel"] != null)) | { level: .attributes["effect.logLevel"] } ] -}' "$T3CODE_HOME/userdata/logs/server.trace.ndjson" +}' "$TRACE_FILE" ``` Follow one trace: @@ -224,7 +232,7 @@ jq -r 'select(.traceId == "TRACE_ID_HERE") | [ .spanId, (.parentSpanId // "-"), .durationMs -] | @tsv' "$T3CODE_HOME/userdata/logs/server.trace.ndjson" +] | @tsv' "$TRACE_FILE" ``` Filter orchestration commands: @@ -235,7 +243,7 @@ jq -c 'select(.attributes["orchestration.command_type"] != null) | { durationMs, commandType: .attributes["orchestration.command_type"], aggregateKind: .attributes["orchestration.aggregate_kind"] -}' "$T3CODE_HOME/userdata/logs/server.trace.ndjson" +}' "$TRACE_FILE" ``` Filter git activity: @@ -250,7 +258,7 @@ jq -c 'select(.attributes["git.operation"] != null) | { .events[] | select(.name == "git.hook.started" or .name == "git.hook.finished") ] -}' "$T3CODE_HOME/userdata/logs/server.trace.ndjson" +}' "$TRACE_FILE" ``` ### Use Tempo When You Need A Real Trace Viewer diff --git a/docs/reference/scripts.md b/docs/reference/scripts.md index 6bdea2666652..746aa66d563f 100644 --- a/docs/reference/scripts.md +++ b/docs/reference/scripts.md @@ -3,13 +3,15 @@ - `vp run dev` — Starts contracts, server, and web in watch mode. - `vp run dev:server` — Starts just the WebSocket server. The server process runs on Bun (`@effect/platform-bun` + `BunPtyAdapter`), but task running uses `vp run`. - `vp run dev:web` — Starts just the Vite dev server for the web app. -- Dev commands default `T3CODE_HOME` to `~/.t3` — the same shared home the desktop/production app uses. Override with `--home-dir` (see below) to keep dev state separate. -- Override server CLI-equivalent flags from root dev commands with `--`, for example: - `vp run dev -- --home-dir ~/.t3-2` +- Dev commands implicitly use `~/.t3/dev`, keeping development state separate from `~/.t3/userdata`. An explicit `--home-dir ` stores state under `/userdata`; the base directory remains available for caches, worktrees, and other shared data. +- Web dev commands do not auto-open a browser. Open the one-time pairing URL printed by the server so the first browser navigation is authenticated. Set `T3CODE_NO_BROWSER=0` only when interactive auto-open is intentional. +- Pass dev-runner flags directly after the root task name, for example: + `vp run dev --home-dir /tmp/t3code-dev` - `vp run start` — Runs the production server (serves built web app as static files). - `vp run build` — Builds contracts, web app, and server. - `vp run typecheck` — Strict TypeScript checks for all packages. - `vp run test` — Runs workspace tests. +- `node apps/server/scripts/t3-sqlite-state.ts --base-dir ...` — Inspects or seeds an isolated T3 SQLite database; writes create a private backup first. - `vp run dist:desktop:artifact -- --platform --target --arch ` — Builds a desktop artifact for a specific platform/target/arch. - `vp run dist:desktop:dmg` — Builds a shareable macOS `.dmg` into `./release`. - `vp run dist:desktop:dmg:x64` — Builds an Intel macOS `.dmg`. diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index d92e967e9b6b..31055424fbe1 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -947,7 +947,7 @@ describe("EnvironmentSupervisor", () => { }).pipe(Effect.provide(TestClock.layer())), ); - it.effect("keeps a healthy session when the application becomes active", () => + it.effect("probes the active session without reconnecting on application activation", () => Effect.gen(function* () { const probeCount = yield* Ref.make(0); const probeCalled = yield* Deferred.make(); diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index 460cacc8c33e..ed157d7663e5 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -118,6 +118,7 @@ const SERVER_CONFIG: ServerConfigType = { serverVersion: "0.0.0-test", capabilities: { repositoryIdentity: true, + connectionProbe: true, }, }, auth: { @@ -150,6 +151,16 @@ const decodeJson = Schema.decodeUnknownSync(Schema.UnknownFromJsonString); const decodeRpcRequest = Schema.decodeUnknownSync(RpcRequest); const encodeJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); const encodeServerConfig = Schema.encodeSync(ServerConfig); +const ENCODED_SERVER_CONFIG = encodeServerConfig(SERVER_CONFIG); +const LEGACY_SERVER_CONFIG = { + ...ENCODED_SERVER_CONFIG, + environment: { + ...ENCODED_SERVER_CONFIG.environment, + capabilities: { + repositoryIdentity: true, + }, + }, +}; const makeFactory = Effect.fn("TestRpcSessionFactory.make")(function* (options?: { readonly headerOptionsCapable?: boolean; @@ -195,9 +206,10 @@ const awaitSocket = Effect.fn("TestRpcSessionFactory.awaitSocket")(function* ( const awaitRequest = Effect.fn("TestRpcSessionFactory.awaitRequest")(function* ( socket: TestWebSocket, + index = 0, ) { for (let attempt = 0; attempt < 100; attempt += 1) { - const request = socket.sent[0]; + const request = socket.sent[index]; if (request) { return decodeRpcRequest(decodeJson(request)); } @@ -208,6 +220,7 @@ const awaitRequest = Effect.fn("TestRpcSessionFactory.awaitRequest")(function* ( const completeInitialConfig = Effect.fn("TestRpcSessionFactory.completeInitialConfig")(function* ( socket: TestWebSocket, + config: unknown = ENCODED_SERVER_CONFIG, ) { const request = yield* awaitRequest(socket); expect(request).toMatchObject({ @@ -221,7 +234,7 @@ const completeInitialConfig = Effect.fn("TestRpcSessionFactory.completeInitialCo requestId: request.id, exit: { _tag: "Success", - value: encodeServerConfig(SERVER_CONFIG), + value: config, }, }), ); @@ -244,6 +257,30 @@ describe("RpcSessionFactory", () => { expect(config).toEqual(SERVER_CONFIG); expect(socket.sent).toHaveLength(1); + const probeFiber = yield* Effect.forkChild(session.probe); + const probeRequest = yield* awaitRequest(socket, 1); + expect(probeRequest).toMatchObject({ + _tag: "Request", + tag: WS_METHODS.serverProbe, + payload: {}, + }); + socket.serverMessage( + encodeJson({ + _tag: "Exit", + requestId: probeRequest.id, + exit: { + _tag: "Success", + value: {}, + }, + }), + ); + yield* Fiber.join(probeFiber); + + expect(socket.sent.map((request) => decodeRpcRequest(decodeJson(request)).tag)).toEqual([ + WS_METHODS.serverGetConfig, + WS_METHODS.serverProbe, + ]); + socket.close(1012, "service restart"); const error = yield* Effect.flip(session.closed); @@ -449,6 +486,45 @@ describe("RpcSessionFactory", () => { }), ); + it.effect("uses the legacy config RPC for probes when the server lacks the capability", () => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + + socket.open(); + yield* completeInitialConfig(socket, LEGACY_SERVER_CONFIG); + yield* Fiber.join(readyFiber); + + const probeFiber = yield* Effect.forkChild(session.probe); + const probeRequest = yield* awaitRequest(socket, 1); + expect(probeRequest).toMatchObject({ + _tag: "Request", + tag: WS_METHODS.serverGetConfig, + payload: {}, + }); + socket.serverMessage( + encodeJson({ + _tag: "Exit", + requestId: probeRequest.id, + exit: { + _tag: "Success", + value: LEGACY_SERVER_CONFIG, + }, + }), + ); + yield* Fiber.join(probeFiber); + + expect(socket.sent.map((request) => decodeRpcRequest(decodeJson(request)).tag)).toEqual([ + WS_METHODS.serverGetConfig, + WS_METHODS.serverGetConfig, + ]); + }), + ), + ); + it.effect("fails readiness when the websocket never opens", () => Effect.gen(function* () { const { factory, sockets } = yield* makeFactory(); diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index c9ded9bc61c7..804b4f14b7e0 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -43,6 +43,7 @@ export class RpcSessionFactory extends Context.Service< type InitialConfigError = Effect.Error< ReturnType >; +type ProbeError = Effect.Error>; type HeaderOptionsWebSocketConstructor = { new ( @@ -52,7 +53,7 @@ type HeaderOptionsWebSocketConstructor = { ): globalThis.WebSocket; }; -function mapInitialConfigError(error: InitialConfigError): ConnectionAttemptError { +function mapSessionRpcError(error: InitialConfigError | ProbeError): ConnectionAttemptError { switch (error._tag) { case "EnvironmentAuthorizationError": return new ConnectionBlockedError({ @@ -136,12 +137,17 @@ export const make = Effect.gen(function* () { const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); const initialConfig = yield* Effect.cached( client[WS_METHODS.serverGetConfig]({}).pipe( - Effect.mapError(mapInitialConfigError), + Effect.mapError(mapSessionRpcError), Effect.withSpan("environment.initialSync"), ), ); - const probe = client[WS_METHODS.serverGetConfig]({}).pipe( - Effect.mapError(mapInitialConfigError), + const probe = initialConfig.pipe( + Effect.flatMap((config) => + (config.environment.capabilities.connectionProbe === true + ? client[WS_METHODS.serverProbe]({}) + : client[WS_METHODS.serverGetConfig]({}) + ).pipe(Effect.mapError(mapSessionRpcError)), + ), Effect.asVoid, Effect.withSpan("clientRuntime.connection.rpcSession.probe"), ); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 4c4e1b951325..527c4c630e4c 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -22,6 +22,7 @@ export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.T export const ExecutionEnvironmentCapabilities = Schema.Struct({ repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + connectionProbe: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 1d91fdd42fc8..1fc4c6778f3c 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -200,6 +200,7 @@ export const WS_METHODS = { previewReportStatus: "preview.reportStatus", // Server meta + serverProbe: "server.probe", serverGetConfig: "server.getConfig", serverRefreshProviders: "server.refreshProviders", serverUpdateProvider: "server.updateProvider", @@ -249,6 +250,12 @@ export const WsServerRemoveKeybindingRpc = Rpc.make(WS_METHODS.serverRemoveKeybi error: Schema.Union([KeybindingsConfigError, EnvironmentAuthorizationError]), }); +export const WsServerProbeRpc = Rpc.make(WS_METHODS.serverProbe, { + payload: Schema.Struct({}), + success: Schema.Struct({}), + error: EnvironmentAuthorizationError, +}); + export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { payload: Schema.Struct({}), success: ServerConfig, @@ -736,6 +743,7 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, }); export const WsRpcGroup = RpcGroup.make( + WsServerProbeRpc, WsServerGetConfigRpc, WsServerRefreshProvidersRpc, WsServerUpdateProviderRpc, diff --git a/packages/shared/package.json b/packages/shared/package.json index bf68ce766ef1..9c45753cd8ed 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -135,6 +135,10 @@ "types": "./src/cliArgs.ts", "import": "./src/cliArgs.ts" }, + "./connectAuth": { + "types": "./src/connectAuth.ts", + "import": "./src/connectAuth.ts" + }, "./path": { "types": "./src/path.ts", "import": "./src/path.ts" diff --git a/packages/shared/src/agentAwareness.ts b/packages/shared/src/agentAwareness.ts index 642f3a7cb102..02c13501b485 100644 --- a/packages/shared/src/agentAwareness.ts +++ b/packages/shared/src/agentAwareness.ts @@ -50,14 +50,6 @@ export function buildAgentAwarenessDeepLink(input: { return `/threads/${encodeURIComponent(input.environmentId)}/${encodeURIComponent(input.threadId)}`; } -export function isTerminalAgentAwarenessPhase(phase: AgentAwarenessPhase): boolean { - return phase === "completed" || phase === "failed"; -} - -export function isInterruptiveAgentAwarenessPhase(phase: AgentAwarenessPhase): boolean { - return phase === "waiting_for_approval" || phase === "waiting_for_input" || phase === "failed"; -} - export function projectThreadAwareness( input: ProjectThreadAwarenessInput, ): AgentAwarenessState | null { diff --git a/packages/shared/src/connectAuth.test.ts b/packages/shared/src/connectAuth.test.ts new file mode 100644 index 000000000000..9ffef3936bbf --- /dev/null +++ b/packages/shared/src/connectAuth.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildConnectAuthorizeRequestUrl, + buildConnectClerkAuthorizeUrl, + connectCallbackUrl, + encodeConnectAuthCode, + parseConnectAuthCode, + readConnectAuthorizeRequest, +} from "./connectAuth.ts"; + +describe("connectAuth", () => { + it("round-trips state and challenge through the authorize URL fragment", () => { + const url = buildConnectAuthorizeRequestUrl({ + hostedAppUrl: "https://app.t3.codes", + state: "q7mK9xV2pL4nR8sT6wYzAQ", + challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + }); + const parsed = new URL(url); + + expect(parsed.origin).toBe("https://app.t3.codes"); + expect(parsed.pathname).toBe("/connect"); + expect(parsed.search).toBe(""); + expect(readConnectAuthorizeRequest(parsed)).toEqual({ + state: "q7mK9xV2pL4nR8sT6wYzAQ", + challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + }); + }); + + it("rejects authorize requests missing state or challenge", () => { + expect(readConnectAuthorizeRequest(new URL("https://app.t3.codes/connect"))).toBeNull(); + expect( + readConnectAuthorizeRequest(new URL("https://app.t3.codes/connect#state=abc")), + ).toBeNull(); + expect( + readConnectAuthorizeRequest(new URL("https://app.t3.codes/connect#challenge=abc")), + ).toBeNull(); + }); + + it("builds a PKCE authorize URL against the Clerk endpoint", () => { + const url = new URL( + buildConnectClerkAuthorizeUrl({ + authorizationEndpoint: "https://clerk.t3.codes/oauth/authorize", + clientId: "oauthapp_123", + redirectUri: connectCallbackUrl("https://app.t3.codes"), + scopes: ["openid", "profile", "email"], + state: "state-1", + challenge: "challenge-1", + }), + ); + + expect(url.origin).toBe("https://clerk.t3.codes"); + expect(url.pathname).toBe("/oauth/authorize"); + expect(url.searchParams.get("client_id")).toBe("oauthapp_123"); + expect(url.searchParams.get("redirect_uri")).toBe("https://app.t3.codes/connect/callback"); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("scope")).toBe("openid profile email"); + expect(url.searchParams.get("state")).toBe("state-1"); + expect(url.searchParams.get("code_challenge")).toBe("challenge-1"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + }); + + it("round-trips the out-of-band authorization code and preserves dots inside it", () => { + const blob = encodeConnectAuthCode({ code: "az9.code.chunk", state: "state-uuid" }); + expect(parseConnectAuthCode(blob)).toEqual({ code: "az9.code.chunk", state: "state-uuid" }); + expect(parseConnectAuthCode(` ${blob}\n`)).toEqual({ + code: "az9.code.chunk", + state: "state-uuid", + }); + }); + + it("rejects malformed out-of-band authorization codes", () => { + expect(parseConnectAuthCode("")).toBeNull(); + expect(parseConnectAuthCode("no-separator")).toBeNull(); + expect(parseConnectAuthCode(".leading")).toBeNull(); + expect(parseConnectAuthCode("trailing.")).toBeNull(); + }); +}); diff --git a/packages/shared/src/connectAuth.ts b/packages/shared/src/connectAuth.ts new file mode 100644 index 000000000000..8d849d77ae6d --- /dev/null +++ b/packages/shared/src/connectAuth.ts @@ -0,0 +1,124 @@ +import { readHashParams } from "./remote.ts"; + +const CONNECT_AUTH_STATE_PARAM = "state"; +const CONNECT_AUTH_CHALLENGE_PARAM = "challenge"; +const CONNECT_AUTH_CODE_SEPARATOR = "."; + +const CONNECT_AUTHORIZE_PATH = "/connect"; +const CONNECT_CALLBACK_PATH = "/connect/callback"; + +/** + * The CLI prints URLs against this origin and the web bundle uses it to + * decide whether it is the hosted deployment — the two must agree, so the + * default lives here. + */ +export const DEFAULT_HOSTED_APP_URL = "https://app.t3.codes"; + +/** + * Requested at authorize time by the hosted page and honored by the CLI's + * token exchange; keep both sides on this single definition. + */ +export const CONNECT_OAUTH_SCOPES = ["openid", "profile", "email"] as const; + +export interface ConnectAuthorizeRequest { + readonly state: string; + readonly challenge: string; +} + +/** + * The URL a headless CLI prints for the user to open on a machine with a + * browser. `state` and `code_challenge` ride the fragment so they never reach + * the hosted app's server or CDN logs; neither is a secret. + */ +export function buildConnectAuthorizeRequestUrl(input: { + readonly hostedAppUrl: string; + readonly state: string; + readonly challenge: string; +}): string { + const url = new URL(CONNECT_AUTHORIZE_PATH, input.hostedAppUrl); + url.hash = new URLSearchParams([ + [CONNECT_AUTH_STATE_PARAM, input.state], + [CONNECT_AUTH_CHALLENGE_PARAM, input.challenge], + ]).toString(); + return url.toString(); +} + +export function readConnectAuthorizeRequest(url: URL): ConnectAuthorizeRequest | null { + const params = readHashParams(url); + const state = params.get(CONNECT_AUTH_STATE_PARAM)?.trim() ?? ""; + const challenge = params.get(CONNECT_AUTH_CHALLENGE_PARAM)?.trim() ?? ""; + if (!state || !challenge) { + return null; + } + return { state, challenge }; +} + +export function connectCallbackUrl(hostedAppUrl: string): string { + return new URL(CONNECT_CALLBACK_PATH, hostedAppUrl).toString(); +} + +export function buildConnectClerkAuthorizeUrl(input: { + readonly authorizationEndpoint: string; + readonly clientId: string; + readonly redirectUri: string; + readonly scopes: ReadonlyArray; + readonly state: string; + readonly challenge: string; +}): string { + const url = new URL(input.authorizationEndpoint); + url.searchParams.set("client_id", input.clientId); + url.searchParams.set("redirect_uri", input.redirectUri); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", input.scopes.join(" ")); + url.searchParams.set("state", input.state); + url.searchParams.set("code_challenge", input.challenge); + url.searchParams.set("code_challenge_method", "S256"); + return url.toString(); +} + +export interface ConnectAuthCode { + readonly code: string; + readonly state: string; +} + +/** + * The single blob the hosted callback page displays and the CLI accepts. + * Bundling `state` with the authorization code lets the CLI keep the loopback + * flow's CSRF check without any backend: it verifies the returned state + * matches the one it generated. Clerk authorization codes and the CLI's + * base64url states never contain ".". + */ +export function encodeConnectAuthCode(input: ConnectAuthCode): string { + return `${input.code}${CONNECT_AUTH_CODE_SEPARATOR}${input.state}`; +} + +/** + * Validates an out-of-band authorization code against the state of the request this process + * generated. Returns the parsed code or a user-facing error message; both + * the prompt's live validation and the authoritative post-prompt check go + * through here so they cannot drift. + */ +export function checkConnectAuthCode( + blob: string, + expectedState: string, +): ConnectAuthCode | string { + const parsed = parseConnectAuthCode(blob); + if (parsed === null) { + return "That does not look like a T3 Connect code. Copy the full code."; + } + if (parsed.state !== expectedState) { + return "That code belongs to a different connect request. Open the URL above and try again."; + } + return parsed; +} + +export function parseConnectAuthCode(blob: string): ConnectAuthCode | null { + const trimmed = blob.trim(); + const separatorIndex = trimmed.lastIndexOf(CONNECT_AUTH_CODE_SEPARATOR); + if (separatorIndex <= 0 || separatorIndex === trimmed.length - 1) { + return null; + } + const code = trimmed.slice(0, separatorIndex); + const state = trimmed.slice(separatorIndex + 1); + return { code, state }; +} diff --git a/packages/shared/src/hostProcess.ts b/packages/shared/src/hostProcess.ts index 1e5b69749cf3..baff2c5ffed7 100644 --- a/packages/shared/src/hostProcess.ts +++ b/packages/shared/src/hostProcess.ts @@ -30,4 +30,18 @@ export const HostProcessEnvironment = Context.Reference( }, ); +export const HostProcessExecutablePath = Context.Reference( + "@t3tools/shared/hostProcess/HostProcessExecutablePath", + { + defaultValue: () => process.execPath, + }, +); + +export const HostProcessArguments = Context.Reference>( + "@t3tools/shared/hostProcess/HostProcessArguments", + { + defaultValue: () => process.argv, + }, +); + export const isHostWindows = Effect.map(HostProcessPlatform, (platform) => platform === "win32"); diff --git a/packages/shared/src/remote.ts b/packages/shared/src/remote.ts index 724d64532f15..11e8be1920bb 100644 --- a/packages/shared/src/remote.ts +++ b/packages/shared/src/remote.ts @@ -8,7 +8,7 @@ const HOSTED_PAIRING_HOST_PARAM = "host"; const HOSTED_PAIRING_LABEL_PARAM = "label"; const SUPPORTED_REMOTE_BACKEND_PROTOCOLS = new Set(["http:", "https:", "ws:", "wss:"]); -const readHashParams = (url: URL): URLSearchParams => +export const readHashParams = (url: URL): URLSearchParams => new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash); export class RemoteBackendUrlMissingError extends Schema.TaggedErrorClass()( diff --git a/patches/@react-navigation%2Fnative-stack@7.17.6.patch b/patches/@react-navigation%2Fnative-stack@7.17.6.patch index cd2b86fb76c8..1ec4d978529f 100644 --- a/patches/@react-navigation%2Fnative-stack@7.17.6.patch +++ b/patches/@react-navigation%2Fnative-stack@7.17.6.patch @@ -1,3 +1,5 @@ +diff --git a/lib/module/views/useHeaderConfigProps.js b/lib/module/views/useHeaderConfigProps.js +index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b01175ec19220 100644 --- a/lib/module/views/useHeaderConfigProps.js +++ b/lib/module/views/useHeaderConfigProps.js @@ -19,6 +19,12 @@ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a78a8ad7831..d475aa2a0e62 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,7 +75,7 @@ patchedDependencies: '@legendapp/list@3.2.0': 45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3 '@pierre/diffs@1.3.0-beta.5': 7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a '@react-native-menu/menu@2.0.0': 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae - '@react-navigation/native-stack@7.17.6': 2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca + '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273 effect@4.0.0-beta.78: c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5 react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 @@ -228,7 +228,7 @@ importers: version: 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@react-navigation/native-stack': specifier: 7.17.6 - version: 7.17.6(patch_hash=2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca)(a49e8e72dc3ef754b9d26038db8e6d3f) + version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(a49e8e72dc3ef754b9d26038db8e6d3f) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -14205,7 +14205,7 @@ snapshots: optionalDependencies: '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@react-navigation/native-stack@7.17.6(patch_hash=2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca)(a49e8e72dc3ef754b9d26038db8e6d3f)': + '@react-navigation/native-stack@7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(a49e8e72dc3ef754b9d26038db8e6d3f)': dependencies: '@react-navigation/elements': 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) '@react-navigation/native': 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 432b50cf7285..3b79db49f5bb 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -1,5 +1,4 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as NodeOS from "node:os"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { assert, describe, it } from "@effect/vitest"; @@ -52,7 +51,7 @@ function mockProcess(exit: number | PlatformError.PlatformError) { const devServerInput = { mode: "dev:server", t3Home: "/tmp/t3code-dev-runner", - noBrowser: undefined, + browser: undefined, autoBootstrapProjectFromCwd: undefined, logWebSocketEvents: undefined, host: undefined, @@ -127,16 +126,56 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }); describe("createDevRunnerEnv", () => { - it.effect("defaults T3CODE_HOME to ~/.t3 when not provided", () => + it.effect("leaves the shared home implicit and disables browser auto-open", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode: "dev", + baseEnv: {}, + serverOffset: 0, + webOffset: 0, + t3Home: undefined, + browser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.T3CODE_HOME, undefined); + assert.equal(env.T3CODE_NO_BROWSER, "1"); + }), + ); + + it.effect("allows browser auto-open to be explicitly enabled", () => Effect.gen(function* () { - const path = yield* Path.Path; const env = yield* createDevRunnerEnv({ mode: "dev", baseEnv: {}, serverOffset: 0, webOffset: 0, t3Home: undefined, - noBrowser: undefined, + browser: true, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.T3CODE_NO_BROWSER, "0"); + }), + ); + + it.effect("requires the browser flag even when the environment enables auto-open", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode: "dev", + baseEnv: { T3CODE_NO_BROWSER: "0" }, + serverOffset: 0, + webOffset: 0, + t3Home: undefined, + browser: false, autoBootstrapProjectFromCwd: undefined, logWebSocketEvents: undefined, host: undefined, @@ -144,7 +183,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { devUrl: undefined, }); - assert.equal(env.T3CODE_HOME, path.resolve(NodeOS.homedir(), ".t3")); + assert.equal(env.T3CODE_NO_BROWSER, "1"); }), ); @@ -157,7 +196,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { serverOffset: 0, webOffset: 0, t3Home: "/tmp/custom-t3", - noBrowser: true, + browser: false, autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, host: "0.0.0.0", @@ -187,7 +226,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { serverOffset: 0, webOffset: 0, t3Home: undefined, - noBrowser: undefined, + browser: undefined, autoBootstrapProjectFromCwd: undefined, logWebSocketEvents: undefined, host: undefined, @@ -210,7 +249,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { serverOffset: 0, webOffset: 0, t3Home: undefined, - noBrowser: undefined, + browser: undefined, autoBootstrapProjectFromCwd: undefined, logWebSocketEvents: false, host: undefined, @@ -231,7 +270,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { serverOffset: 0, webOffset: 0, t3Home: "/tmp/my-t3", - noBrowser: undefined, + browser: undefined, autoBootstrapProjectFromCwd: undefined, logWebSocketEvents: undefined, host: undefined, @@ -259,7 +298,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { serverOffset: 0, webOffset: 0, t3Home: "/tmp/my-t3", - noBrowser: true, + browser: true, autoBootstrapProjectFromCwd: undefined, logWebSocketEvents: undefined, host: "127.0.0.1", @@ -288,7 +327,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { serverOffset: 0, webOffset: 0, t3Home: undefined, - noBrowser: undefined, + browser: undefined, autoBootstrapProjectFromCwd: undefined, logWebSocketEvents: undefined, host: undefined, diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 2953a2f4dec9..1938232300fc 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -221,7 +221,7 @@ interface CreateDevRunnerEnvInput { readonly serverOffset: number; readonly webOffset: number; readonly t3Home: string | undefined; - readonly noBrowser: boolean | undefined; + readonly browser: boolean | undefined; readonly autoBootstrapProjectFromCwd: boolean | undefined; readonly logWebSocketEvents: boolean | undefined; readonly host: string | undefined; @@ -235,7 +235,7 @@ export function createDevRunnerEnv({ serverOffset, webOffset, t3Home, - noBrowser, + browser, autoBootstrapProjectFromCwd, logWebSocketEvents, host, @@ -245,7 +245,8 @@ export function createDevRunnerEnv({ return Effect.gen(function* () { const serverPort = port ?? BASE_SERVER_PORT + serverOffset; const webPort = BASE_WEB_PORT + webOffset; - const resolvedBaseDir = yield* resolveBaseDir(t3Home); + const configuredBaseDir = t3Home?.trim() || baseEnv.T3CODE_HOME?.trim() || undefined; + const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir); const isDesktopMode = mode === "dev:desktop"; const output: NodeJS.ProcessEnv = { @@ -254,9 +255,14 @@ export function createDevRunnerEnv({ VITE_DEV_SERVER_URL: devUrl?.toString() ?? `http://${isDesktopMode ? DESKTOP_DEV_LOOPBACK_HOST : "localhost"}:${webPort}`, - T3CODE_HOME: resolvedBaseDir, }; + if (configuredBaseDir !== undefined) { + output.T3CODE_HOME = resolvedBaseDir; + } else { + delete output.T3CODE_HOME; + } + if (!isDesktopMode) { output.T3CODE_PORT = String(serverPort); output.VITE_HTTP_URL = `http://localhost:${serverPort}`; @@ -274,10 +280,8 @@ export function createDevRunnerEnv({ output.T3CODE_HOST = host; } - if (!isDesktopMode && noBrowser !== undefined) { - output.T3CODE_NO_BROWSER = noBrowser ? "1" : "0"; - } else if (!isDesktopMode) { - delete output.T3CODE_NO_BROWSER; + if (!isDesktopMode) { + output.T3CODE_NO_BROWSER = browser === true ? "0" : "1"; } if (autoBootstrapProjectFromCwd !== undefined) { @@ -469,7 +473,7 @@ export function resolveModePortOffsets({ interface DevRunnerCliInput { readonly mode: DevMode; readonly t3Home: string | undefined; - readonly noBrowser: boolean | undefined; + readonly browser: boolean | undefined; readonly autoBootstrapProjectFromCwd: boolean | undefined; readonly logWebSocketEvents: boolean | undefined; readonly host: string | undefined; @@ -507,7 +511,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { serverOffset, webOffset, t3Home: input.t3Home, - noBrowser: input.noBrowser, + browser: input.browser, autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd, logWebSocketEvents: input.logWebSocketEvents, host: input.host, @@ -519,9 +523,10 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { serverOffset !== offset || webOffset !== offset ? ` selectedOffset(server=${serverOffset},web=${webOffset})` : ""; + const baseDir = env.T3CODE_HOME ?? (yield* DEFAULT_T3_HOME); yield* Effect.logInfo( - `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${String(env.T3CODE_HOME)}`, + `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${baseDir}`, ); if (input.dryRun) { @@ -586,12 +591,13 @@ const devRunnerCli = Command.make("dev-runner", { Argument.withDescription("Development mode to run."), ), t3Home: Flag.string("home-dir").pipe( - Flag.withDescription("Base directory for all T3 Code data (equivalent to T3CODE_HOME)."), + Flag.withDescription( + "Explicit T3 Code data directory; runtime state is stored under userdata (equivalent to T3CODE_HOME).", + ), Flag.withFallbackConfig(optionalStringConfig("T3CODE_HOME")), ), - noBrowser: Flag.boolean("no-browser").pipe( - Flag.withDescription("Browser auto-open toggle (equivalent to T3CODE_NO_BROWSER)."), - Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_NO_BROWSER")), + browser: Flag.boolean("browser").pipe( + Flag.withDescription("Open a browser automatically (disabled by default for web dev)."), ), autoBootstrapProjectFromCwd: Flag.boolean("auto-bootstrap-project-from-cwd").pipe( Flag.withDescription( diff --git a/scripts/lib/public-config.ts b/scripts/lib/public-config.ts index 9b988df9c5ba..36ca5497f803 100644 --- a/scripts/lib/public-config.ts +++ b/scripts/lib/public-config.ts @@ -55,6 +55,7 @@ export function loadRepoEnv({ ...(config.clerkCliOAuthClientId ? { T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: config.clerkCliOAuthClientId, + VITE_CLERK_CLI_OAUTH_CLIENT_ID: config.clerkCliOAuthClientId, } : {}), ...(config.relayUrl @@ -116,7 +117,11 @@ export function resolvePublicConfig(...sources: readonly Environment[]): T3CodeP "VITE_CLERK_JWT_TEMPLATE", "EXPO_PUBLIC_CLERK_JWT_TEMPLATE", ), - clerkCliOAuthClientId: firstNonEmpty(sources, "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID"), + clerkCliOAuthClientId: firstNonEmpty( + sources, + "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID", + "VITE_CLERK_CLI_OAUTH_CLIENT_ID", + ), relayUrl: firstNonEmpty(sources, "T3CODE_RELAY_URL", "VITE_T3CODE_RELAY_URL"), mobileOtlpTracesUrl: firstNonEmpty( sources,