diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md new file mode 100644 index 00000000000..c573807d615 --- /dev/null +++ b/.agents/skills/test-t3-app/SKILL.md @@ -0,0 +1,69 @@ +--- +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 + +## 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 00000000000..a3b89c95e60 --- /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 00000000000..8eca543cf97 --- /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/.claude/skills b/.claude/skills new file mode 120000 index 00000000000..2b7a412b8fa --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.env.example b/.env.example index 79b2adaf0c8..61cdd66d246 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/.plans/t3-connect-remote-setup.html b/.plans/t3-connect-remote-setup.html new file mode 100644 index 00000000000..101c293bee1 --- /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 3a833a39e83..a3e489404c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,27 +2,14 @@ ## 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 use the `test-t3-app` skill once after integrating the work. Launch one isolated environment, authenticate through the printed pairing URL, and verify the affected flow in the controlled browser. + - Subagents must not independently launch dev servers or repeat integrated browser verification unless their delegated task explicitly requires it. + - Stop dev servers, watchers, and other long-running verification processes when the focused verification is complete. ## Fork Notes @@ -48,7 +35,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. @@ -60,8 +46,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 92da3f887ac..15d23f8e152 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 fa51e470628..f07d1c4cfc3 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/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index bbcbb8e4282..8a8c355b760 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -11,6 +11,7 @@ import { useEffect, useLayoutEffect, useMemo, + useRef, type ReactElement, type ReactNode, } from "react"; @@ -61,20 +62,116 @@ function normalizeScreenOptions( return normalized as NativeStackNavigationOptions; } +function optionsSignature(value: unknown, seen = new WeakSet()): 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 00000000000..d1ef1368918 --- /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 00000000000..de0402b3647 --- /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 e5e6c18c1e6..4f654ee886c 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -238,7 +238,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-"), @@ -256,7 +256,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"]), @@ -267,7 +267,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 601d11ae0bd..a836b004ef9 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -19,6 +19,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"), @@ -189,6 +199,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { tailscaleServeEnabled: true, tailscaleServePort: 8443, }); + assert.equal(resolved.dbPath, join(baseDir, "userdata", "state.sqlite")); }), ); @@ -203,7 +214,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( { @@ -405,7 +419,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( { @@ -472,7 +489,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, @@ -542,7 +559,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 8aa8de65fb1..4574331daf1 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -32,7 +32,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 basePathFlag = Flag.string("base-path").pipe( @@ -271,19 +273,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 f01c93f0538..e63cbf331b5 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 d09d0814222..8b11fc73346 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); } @@ -313,6 +396,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({ @@ -326,7 +424,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, @@ -335,6 +433,8 @@ const runCloudCommand = ( | CliTokenManager.CloudCliTokenManager | RelayClient.RelayClient | EnvironmentAuth.EnvironmentAuth + | BootService.BootService + | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient | Prompt.Environment @@ -344,37 +444,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)}`); }), ), ), @@ -382,6 +524,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).", @@ -394,41 +537,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.", - ); }), ), ), @@ -566,8 +683,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 00000000000..e6eb6b7cd6f --- /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 00709370b26..f01599bb96f 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 00000000000..a24d54697d9 --- /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 00000000000..0662b367049 --- /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 00000000000..1104927b980 --- /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 00000000000..5a22a25993a --- /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 bab9cdd27f3..96abf2e4b4a 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 c46e2671a46..96a8a1b8b8a 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 176b31d7566..cb07d19721b 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 6280c9602c0..bf91a236dda 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -48,6 +48,10 @@ export interface ServerDerivedPaths { readonly secretsDir: string; } +export interface DeriveServerPathsOptions { + readonly baseDirIsExplicit?: boolean; +} + /** * ServerConfig - Service tag for server runtime configuration. */ @@ -95,9 +99,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 forkDbPath = join(stateDir, "state-tarik02.sqlite"); const attachmentsDir = join(stateDir, "attachments"); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ba1131ee259..d468838d5d4 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -123,15 +123,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 a83c134d5bd..46d0b3019bd 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 72eb14938c8..25737200a05 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -337,7 +337,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(ServerSecretStore.layer), 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/vite.config.ts b/apps/server/vite.config.ts index 473df069ed7..521654f3279 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -3,6 +3,7 @@ import { defineConfig, mergeConfig } from "vite-plus"; import baseConfig from "../../vite.config.ts"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; +import packageJson from "./package.json" with { type: "json" }; const bundledPackagePrefixes = [ "@pierre/diffs", @@ -16,6 +17,7 @@ export function shouldBundleCliDependency(id: string): boolean { } const repoEnv = loadRepoEnv(); +const cliBuildChannel = packageJson.version.includes("-nightly.") ? "nightly" : "latest"; export default mergeConfig( baseConfig, @@ -42,6 +44,7 @@ export default mergeConfig( js: "#!/usr/bin/env node\n", }, define: { + __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 b87276f1b9c..056fbb76e6a 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 4aa969c0279..e1c87bcf059 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 00000000000..61d854eb6ab --- /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 00000000000..849319dcebe --- /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 d9d0e5f44cb..2caf4f52f36 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 07ec0dc9077..587ec65cbc9 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 cd776e29768..0f84b072a46 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -85,7 +85,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, @@ -134,6 +134,7 @@ import { import { stackedThreadToast, toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; +import { SidebarStageBackdrop, resolveSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { Kbd } from "./ui/kbd"; import { getArm64IntelBuildWarningDescription, @@ -2834,33 +2835,55 @@ 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({ stageLabel, onBackdrop }: { stageLabel: string; onBackdrop: boolean }) { return ( - + Code - + {stageLabel} @@ -2881,7 +2904,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 00000000000..d3d86186c03 --- /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/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 22cdeb1bff6..95917b43b91 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -33,6 +33,7 @@ import { useState, } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; +import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; import { useOpenInPreferredEditor } from "../editorPreferences"; @@ -337,7 +338,7 @@ export function TerminalViewport({ const selectionPointerRef = useRef<{ x: number; y: number } | null>(null); const selectionGestureActiveRef = useRef(false); const selectionActionRequestIdRef = useRef(0); - const selectionActionOpenRef = useRef(false); + const selectionActionMenuOpenRef = useRef(false); const selectionActionTimerRef = useRef(null); const keybindingsRef = useRef(keybindings); const handleSessionExited = useEffectEvent(() => { @@ -423,6 +424,7 @@ export function TerminalViewport({ const readSelectionAction = (): { position: { x: number; y: number }; + clipboardText: string; selection: TerminalContextSelection; } | null => { const activeTerminal = terminalRef.current; @@ -451,6 +453,7 @@ export function TerminalViewport({ }); return { position, + clipboardText: selectionText, selection: { terminalId, terminalLabel: readTerminalLabel(), @@ -466,7 +469,7 @@ export function TerminalViewport({ clearSelectionAction(); return; } - if (selectionActionOpenRef.current) { + if (selectionActionMenuOpenRef.current) { return; } const nextAction = readSelectionAction(); @@ -475,38 +478,46 @@ export function TerminalViewport({ return; } const requestId = ++selectionActionRequestIdRef.current; - selectionActionOpenRef.current = true; - try { - const clicked = await localApi.contextMenu.show( - TERMINAL_SELECTION_ACTION_MENU_ITEMS, + selectionActionMenuOpenRef.current = true; + const clicked = await localApi.contextMenu + .show( + [ + { id: "add-to-chat", label: "Add to chat" }, + { id: "copy", label: "Copy" }, + ], nextAction.position, - ); - if (requestId !== selectionActionRequestIdRef.current || clicked === null) { + ) + .finally(() => { + selectionActionMenuOpenRef.current = false; + }); + if (requestId !== selectionActionRequestIdRef.current || clicked === null) { + return; + } + switch (clicked) { + case "add-to-chat": + handleAddTerminalContext(nextAction.selection); + terminalRef.current?.clearSelection(); + terminalRef.current?.focus(); return; - } - switch (clicked) { - case "add-to-chat": - handleAddTerminalContext(nextAction.selection); - terminalRef.current?.clearSelection(); - terminalRef.current?.focus(); - return; - case "copy": - try { - await copyTerminalSelectionTextToClipboard(nextAction.selection.text); - } catch (error) { - const activeTerminal = terminalRef.current; - if (activeTerminal) { - writeSystemMessage( - activeTerminal, - error instanceof Error ? error.message : "Unable to copy terminal selection", - ); - } + case "copy": + try { + await writeTextToClipboard(nextAction.clipboardText, "terminal selection"); + } catch (error) { + if (requestId !== selectionActionRequestIdRef.current) { + return; } + const activeTerminal = terminalRef.current; + if (activeTerminal) { + writeSystemMessage( + activeTerminal, + error instanceof Error ? error.message : "Unable to copy terminal selection", + ); + } + } + if (requestId === selectionActionRequestIdRef.current) { terminalRef.current?.focus(); - return; - } - } finally { - selectionActionOpenRef.current = false; + } + return; } }; diff --git a/apps/web/src/components/auth/AuthSurfaceShell.tsx b/apps/web/src/components/auth/AuthSurfaceShell.tsx new file mode 100644 index 00000000000..1c45c98a9e4 --- /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/cloud/ConnectCliAuthSurface.tsx b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx new file mode 100644 index 00000000000..40ddc4c3dd9 --- /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 176c0aa4691..b49b4142d30 100644 --- a/apps/web/src/hostedPairing.ts +++ b/apps/web/src/hostedPairing.ts @@ -1,6 +1,6 @@ -import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "./pairingUrl"; +import { DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/connectAuth"; -const DEFAULT_HOSTED_APP_URL = "https://app.t3.codes"; +import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "./pairingUrl"; export interface HostedPairingRequest { readonly host: string; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index c500eb68d7f..77a5f027c13 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 3a9140e278c..b524fe018e9 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SettingsRouteImport } from './routes/settings' import { Route as PairRouteImport } from './routes/pair' +import { Route as ConnectRouteImport } from './routes/connect' import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' @@ -20,6 +21,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' @@ -33,6 +35,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, @@ -77,6 +84,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', @@ -91,8 +103,10 @@ const ChatEnvironmentIdThreadIdRoute = export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute + '/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 @@ -104,8 +118,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 @@ -120,8 +136,10 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/_chat': typeof ChatRouteWithChildren + '/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 @@ -137,8 +155,10 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/connect' | '/pair' | '/settings' + | '/connect/callback' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -150,8 +170,10 @@ export interface FileRouteTypes { | '/draft/$draftId' fileRoutesByTo: FileRoutesByTo to: + | '/connect' | '/pair' | '/settings' + | '/connect/callback' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -165,8 +187,10 @@ export interface FileRouteTypes { id: | '__root__' | '/_chat' + | '/connect' | '/pair' | '/settings' + | '/connect_/callback' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -181,8 +205,10 @@ export interface FileRouteTypes { } export interface RootRouteChildren { ChatRoute: typeof ChatRouteWithChildren + ConnectRoute: typeof ConnectRoute PairRoute: typeof PairRoute SettingsRoute: typeof SettingsRouteWithChildren + ConnectCallbackRoute: typeof ConnectCallbackRoute } declare module '@tanstack/react-router' { @@ -201,6 +227,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: '' @@ -264,6 +297,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' @@ -321,8 +361,10 @@ const SettingsRouteWithChildren = SettingsRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { ChatRoute: ChatRouteWithChildren, + ConnectRoute: ConnectRoute, PairRoute: PairRoute, 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 69739ce4570..ff6bc5b3952 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -97,7 +97,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 00000000000..30750a977ea --- /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 00000000000..a5beee0b9d3 --- /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 03a1e86ce5e..ffe16008643 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_HOSTED_APP_CHANNEL: 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 09060368c88..1b38fe5602d 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -18,6 +18,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() || ""; @@ -123,6 +124,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 5b98d1163ea..1d55894b182 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 6bdea266665..746aa66d563 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/shared/package.json b/packages/shared/package.json index 9673b2b203d..64891da6153 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -143,6 +143,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 248b983cd4f..c0f5842eb7c 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 00000000000..9ffef3936bb --- /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 00000000000..8d849d77ae6 --- /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 1e5b69749cf..baff2c5ffed 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 7df573bb1de..6f648a9fafe 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 cd2b86fb76c..1ec4d978529 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 6fb749ad1f2..dd906182c41 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 expo-modules-jsi@56.0.10: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 @@ -229,7 +229,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))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) '@react-navigation/native-stack': specifier: 7.17.6 - version: 7.17.6(patch_hash=2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca)(@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))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native-screens@4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) + version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(@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))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native-screens@4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -13260,7 +13260,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.3) use-sync-external-store: 1.6.0(react@19.2.3) - '@react-navigation/native-stack@7.17.6(patch_hash=2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca)(@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))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native-screens@4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)': + '@react-navigation/native-stack@7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(@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))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native-screens@4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)': dependencies: '@react-navigation/elements': 2.9.26(@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))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) '@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))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 432b50cf728..3b79db49f5b 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 2953a2f4dec..1938232300f 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 9b988df9c5b..36ca5497f80 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,