From 8d7c700c1788c4f42932cd3be541b3c630bd664d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 14 Sep 2026 13:00:28 -0700 Subject: [PATCH 01/16] feat(web): clone repositories in the background instead of holding the palette open (#11762) Co-authored-by: Claude Fable 5 --- apps/server/src/auth/RpcAuthorization.ts | 4 + apps/server/src/bin.test.ts | 12 +- .../src/environment/ServerEnvironment.ts | 1 + apps/server/src/orchestration/http.ts | 17 +- .../src/project/ProjectCloneTracker.test.ts | 303 +++++++++++ .../server/src/project/ProjectCloneTracker.ts | 487 ++++++++++++++++++ apps/server/src/project/gitCloneProgress.ts | 44 ++ apps/server/src/server.test.ts | 108 ++++ apps/server/src/server.ts | 6 + .../SourceControlRepositoryService.test.ts | 150 +++++- .../SourceControlRepositoryService.ts | 206 +++++++- apps/server/src/vcs/GitVcsDriver.ts | 6 + apps/server/src/vcs/GitVcsDriverCore.test.ts | 32 ++ apps/server/src/vcs/GitVcsDriverCore.ts | 17 +- apps/server/src/ws.ts | 78 +++ apps/web/src/components/ChatView.tsx | 118 ++++- apps/web/src/components/CommandPalette.tsx | 67 ++- .../ProjectCloneToastCoordinator.tsx | 240 +++++++++ apps/web/src/hooks/useRemoveClonedProject.ts | 62 +++ apps/web/src/routes/__root.tsx | 2 + apps/web/src/state/projectClones.ts | 52 ++ docs/user/source-control.md | 5 +- packages/client-runtime/src/rpc/client.ts | 1 + .../client-runtime/src/state/sourceControl.ts | 38 ++ packages/contracts/src/environment.ts | 5 + packages/contracts/src/index.ts | 1 + packages/contracts/src/projectClone.ts | 122 +++++ packages/contracts/src/rpc.ts | 47 ++ 28 files changed, 2206 insertions(+), 25 deletions(-) create mode 100644 apps/server/src/project/ProjectCloneTracker.test.ts create mode 100644 apps/server/src/project/ProjectCloneTracker.ts create mode 100644 apps/server/src/project/gitCloneProgress.ts create mode 100644 apps/web/src/components/ProjectCloneToastCoordinator.tsx create mode 100644 apps/web/src/hooks/useRemoveClonedProject.ts create mode 100644 apps/web/src/state/projectClones.ts create mode 100644 packages/contracts/src/projectClone.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index ca55e4e95e01..36371713a7a8 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -97,6 +97,10 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, + [WS_METHODS.projectCloneStart]: AuthOrchestrationOperateScope, + [WS_METHODS.projectCloneCancel]: AuthOrchestrationOperateScope, + [WS_METHODS.projectCloneRetry]: AuthOrchestrationOperateScope, + [WS_METHODS.subscribeProjectClones]: AuthOrchestrationReadScope, [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 5ba2f281126c..c1bea133f718 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -39,6 +39,7 @@ import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSna import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; +import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import { @@ -363,7 +364,16 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef Effect.gen(function* () { const config = yield* makeCliTestServerConfig(baseDir); const routesLayer = HttpApiBuilder.layer(ProjectCliHttpApi).pipe( - Layer.provide(orchestrationHttpApiLayer), + Layer.provide( + orchestrationHttpApiLayer.pipe( + Layer.provide( + Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({ + get: () => Effect.succeed(null), + discard: () => Effect.void, + }), + ), + ), + ), Layer.provide(environmentAuthenticatedAuthLayer), ); const appLayer = HttpRouter.serve(routesLayer, { diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 92e32d005492..9c25767245df 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -236,6 +236,7 @@ export const make = Effect.gen(function* () { pullRequestStackActions: true, threadPullRequestLinking: true, environmentIcon: true, + projectCloneTracking: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" || desktopAppUpdate ? { diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index f7147106c7a9..77a04442ccfb 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -16,6 +16,7 @@ import { failEnvironmentNotFound, requireEnvironmentScope, } from "../auth/http.ts"; +import * as ProjectCloneTracker from "../project/ProjectCloneTracker.ts"; import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; @@ -25,6 +26,7 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( Effect.fnUntraced(function* (handlers) { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const orchestrationEngine = yield* OrchestrationEngineService; + const projectCloneTracker = yield* ProjectCloneTracker.ProjectCloneTracker; return handlers .handle( @@ -93,10 +95,18 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( Effect.fn("environment.orchestration.dispatch")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); yield* requireEnvironmentScope(AuthOrchestrationOperateScope); + yield* ProjectCloneTracker.rejectCommandsDuringClone( + projectCloneTracker, + args.payload, + ).pipe( + Effect.catch((cause) => + failEnvironmentInternal("orchestration_dispatch_failed", cause), + ), + ); const normalizedCommand = yield* normalizeDispatchCommand(args.payload).pipe( Effect.catch(() => failEnvironmentInvalidRequest("invalid_command")), ); - return yield* orchestrationEngine.dispatch(normalizedCommand).pipe( + const result = yield* orchestrationEngine.dispatch(normalizedCommand).pipe( Effect.tapError(() => cleanupFailedUploadedAttachments(args.payload, normalizedCommand), ), @@ -104,6 +114,11 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( failEnvironmentInternal("orchestration_dispatch_failed", cause), ), ); + yield* ProjectCloneTracker.discardCloneForDeletedProject( + projectCloneTracker, + normalizedCommand, + ); + return result; }), ); }), diff --git a/apps/server/src/project/ProjectCloneTracker.test.ts b/apps/server/src/project/ProjectCloneTracker.test.ts new file mode 100644 index 000000000000..d26a2ed712ac --- /dev/null +++ b/apps/server/src/project/ProjectCloneTracker.test.ts @@ -0,0 +1,303 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + OrchestrationDispatchCommandError, + ProjectId, + SourceControlRepositoryError, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts"; +import * as ProjectCloneTracker from "./ProjectCloneTracker.ts"; +import { parseGitCloneProgressLine } from "./gitCloneProgress.ts"; + +const projectId = ProjectId.make("project-1"); +const startInput = { + projectId, + title: "t3code", + createdAt: "2026-01-01T00:00:00.000Z", + remoteUrl: "git@github.com:octocat/t3code.git", + destinationPath: "/workspace/t3code", +}; + +function makeHarness(options?: { + readonly clone?: SourceControlRepositoryService.SourceControlRepositoryService["Service"]["cloneRepository"]; +}) { + const created: Array<{ projectId: ProjectId; workspaceRoot: string }> = []; + const cloned: Array = []; + const discarded: Array = []; + const hooks: ProjectCloneTracker.ProjectCloneHooks = { + createProject: (input) => + Effect.sync(() => { + created.push({ projectId: input.projectId, workspaceRoot: input.workspaceRoot }); + }), + onCloned: (input) => Effect.sync(() => void cloned.push(input.projectId)), + }; + const layer = ProjectCloneTracker.layer.pipe( + Layer.provide( + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ + prepareClone: (input) => + Effect.succeed({ + destinationPath: input.destinationPath, + remoteUrl: input.remoteUrl ?? "", + cloneUrl: input.remoteUrl ?? "", + repository: null, + }), + cloneRepository: + options?.clone ?? + ((input) => + Effect.succeed({ + cwd: input.destinationPath, + remoteUrl: input.remoteUrl ?? "", + repository: null, + })), + discardClone: (destination) => Effect.sync(() => void discarded.push(destination)), + }), + ), + ); + return { layer, hooks, created, cloned, discarded }; +} + +describe("ProjectCloneTracker", () => { + it.effect("creates the project first and reports the clone through the stream", () => { + const release = Deferred.makeUnsafe(); + const harness = makeHarness({ + clone: (input, options) => + Effect.gen(function* () { + yield* ( + options?.onProgress?.({ stage: "receiving", percent: 40, detail: "1 MiB" }) ?? + Effect.void + ); + yield* Deferred.await(release); + return { cwd: input.destinationPath, remoteUrl: input.remoteUrl ?? "", repository: null }; + }), + }); + return Effect.gen(function* () { + const tracker = yield* ProjectCloneTracker.ProjectCloneTracker; + const collected = yield* tracker.stream.pipe( + Stream.takeUntil((clones) => clones[0]?.phase === "done"), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + + const result = yield* tracker.start(startInput, harness.hooks); + expect(result.cwd).toBe("/workspace/t3code"); + // The project exists before git runs so the draft can open immediately. + expect(harness.created).toEqual([{ projectId, workspaceRoot: "/workspace/t3code" }]); + + yield* Effect.yieldNow; + const running = yield* tracker.get(projectId); + expect(running).toMatchObject({ phase: "running", stage: "receiving", percent: 40 }); + + yield* Deferred.succeed(release, undefined); + const lists = yield* Fiber.join(collected); + const final = lists.at(-1)?.[0]; + expect(final).toMatchObject({ phase: "done", percent: 100 }); + expect(harness.cloned).toEqual([projectId]); + + // Done clones drop out after the grace window so the toast can settle. + yield* TestClock.adjust("31 seconds"); + expect(yield* tracker.get(projectId)).toBeNull(); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("keeps a failed clone with git's own explanation and retries it", () => { + let attempts = 0; + const harness = makeHarness({ + clone: (input) => + Effect.suspend(() => { + attempts += 1; + return attempts === 1 + ? Effect.fail( + new SourceControlRepositoryError({ + operation: "cloneRepository", + provider: "unknown", + detail: "fatal: repository not found", + }), + ) + : Effect.succeed({ + cwd: input.destinationPath, + remoteUrl: input.remoteUrl ?? "", + repository: null, + }); + }), + }); + return Effect.gen(function* () { + const tracker = yield* ProjectCloneTracker.ProjectCloneTracker; + yield* tracker.start(startInput, harness.hooks); + yield* Effect.yieldNow; + const failed = yield* tracker.get(projectId); + expect(failed).toMatchObject({ phase: "failed", error: "fatal: repository not found" }); + + expect(yield* tracker.retry(projectId)).toBe(true); + // The partial checkout is cleared so git sees an empty destination. + expect(harness.discarded).toEqual(["/workspace/t3code"]); + yield* Effect.yieldNow; + expect((yield* tracker.get(projectId))?.phase).toBe("done"); + expect(attempts).toBe(2); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("cancel interrupts the clone and removes the partial checkout", () => { + const harness = makeHarness({ clone: () => Effect.never }); + return Effect.gen(function* () { + const tracker = yield* ProjectCloneTracker.ProjectCloneTracker; + yield* tracker.start(startInput, harness.hooks); + yield* Effect.yieldNow; + + expect(yield* tracker.cancel(projectId)).toBe(true); + expect((yield* tracker.get(projectId))?.phase).toBe("cancelled"); + expect(harness.discarded).toEqual(["/workspace/t3code"]); + // Nothing left to cancel; retry is what brings it back. + expect(yield* tracker.cancel(projectId)).toBe(false); + expect(yield* tracker.retry(projectId)).toBe(true); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("a cancel that lands after git finished keeps the checkout", () => { + const gate = Deferred.makeUnsafe(); + const harness = makeHarness(); + // The clone itself completes instantly; the post-clone hook is what hangs. + const hooks: ProjectCloneTracker.ProjectCloneHooks = { + ...harness.hooks, + onCloned: () => Deferred.await(gate), + }; + return Effect.gen(function* () { + const tracker = yield* ProjectCloneTracker.ProjectCloneTracker; + yield* tracker.start(startInput, hooks); + yield* Effect.yieldNow; + expect((yield* tracker.get(projectId))?.phase).toBe("done"); + expect(yield* tracker.cancel(projectId)).toBe(false); + expect(harness.discarded).toEqual([]); + yield* Deferred.succeed(gate, undefined); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("hands git the credential-bearing URL while snapshots carry the redacted one", () => { + const cloneUrls: Array = []; + const harness = makeHarness({ + clone: (input) => + Effect.sync(() => { + cloneUrls.push(input.remoteUrl ?? ""); + return { cwd: input.destinationPath, remoteUrl: "", repository: null }; + }), + }); + const layer = ProjectCloneTracker.layer.pipe( + Layer.provide( + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ + prepareClone: (input) => + Effect.succeed({ + destinationPath: input.destinationPath, + remoteUrl: "https://github.com/octocat/t3code.git", + cloneUrl: "https://user:s3cret@github.com/octocat/t3code.git", + repository: null, + }), + cloneRepository: (input) => + Effect.sync(() => { + cloneUrls.push(input.remoteUrl ?? ""); + return { cwd: input.destinationPath, remoteUrl: "", repository: null }; + }), + discardClone: () => Effect.void, + }), + ), + ); + return Effect.gen(function* () { + const tracker = yield* ProjectCloneTracker.ProjectCloneTracker; + const result = yield* tracker.start(startInput, harness.hooks); + yield* Effect.yieldNow; + expect(result.remoteUrl).toBe("https://github.com/octocat/t3code.git"); + expect(cloneUrls).toEqual(["https://user:s3cret@github.com/octocat/t3code.git"]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("discard forgets a project's clone when the project is deleted", () => { + const harness = makeHarness({ clone: () => Effect.never }); + return Effect.gen(function* () { + const tracker = yield* ProjectCloneTracker.ProjectCloneTracker; + yield* tracker.start(startInput, harness.hooks); + yield* Effect.yieldNow; + yield* tracker.discard(projectId); + expect(yield* tracker.get(projectId)).toBeNull(); + expect(harness.discarded).toEqual(["/workspace/t3code"]); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("releases the claim when project creation fails", () => { + const harness = makeHarness(); + const hooks: ProjectCloneTracker.ProjectCloneHooks = { + ...harness.hooks, + createProject: () => + Effect.fail(new OrchestrationDispatchCommandError({ message: "workspace root exists" })), + }; + return Effect.gen(function* () { + const tracker = yield* ProjectCloneTracker.ProjectCloneTracker; + const error = yield* Effect.flip(tracker.start(startInput, hooks)); + expect(error.message).toContain("workspace root exists"); + expect(yield* tracker.get(projectId)).toBeNull(); + // The destination is free again for a corrected attempt. + yield* tracker.start(startInput, harness.hooks); + yield* Effect.yieldNow; + expect((yield* tracker.get(projectId))?.phase).toBe("done"); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("does not create a project when the clone cannot be prepared", () => { + const harness = makeHarness(); + const layer = ProjectCloneTracker.layer.pipe( + Layer.provide( + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ + prepareClone: () => + Effect.fail( + new SourceControlRepositoryError({ + operation: "cloneRepository", + provider: "unknown", + detail: "Destination path already exists and is not empty.", + }), + ), + }), + ), + ); + return Effect.gen(function* () { + const tracker = yield* ProjectCloneTracker.ProjectCloneTracker; + const error = yield* Effect.flip(tracker.start(startInput, harness.hooks)); + expect(error.message).toContain("not empty"); + expect(harness.created).toEqual([]); + expect(yield* tracker.get(projectId)).toBeNull(); + }).pipe(Effect.provide(layer)); + }); +}); + +describe("parseGitCloneProgressLine", () => { + it("parses git's transfer counters and ignores other output", () => { + expect( + parseGitCloneProgressLine("Receiving objects: 45% (4500/10000), 12.30 MiB | 5.00 MiB/s"), + ).toEqual({ stage: "receiving", percent: 45, detail: "12.30 MiB | 5.00 MiB/s" }); + expect(parseGitCloneProgressLine("Resolving deltas: 100% (700/700), done.")).toEqual({ + stage: "resolving", + percent: 100, + detail: null, + }); + expect(parseGitCloneProgressLine("remote: Compressing objects: 12% (3/25)")).toEqual({ + stage: "counting", + percent: 12, + detail: null, + }); + expect(parseGitCloneProgressLine("Updating files: 78% (2104/2700)")).toEqual({ + stage: "checkout", + percent: 78, + detail: null, + }); + expect(parseGitCloneProgressLine("remote: Enumerating objects: 10, done.")).toEqual({ + stage: "counting", + percent: null, + detail: null, + }); + expect(parseGitCloneProgressLine("Cloning into 't3code'...")).toBeNull(); + expect(parseGitCloneProgressLine("fatal: repository not found")).toBeNull(); + }); +}); diff --git a/apps/server/src/project/ProjectCloneTracker.ts b/apps/server/src/project/ProjectCloneTracker.ts new file mode 100644 index 000000000000..854c97817529 --- /dev/null +++ b/apps/server/src/project/ProjectCloneTracker.ts @@ -0,0 +1,487 @@ +import type { + OrchestrationCommand, + ProjectCloneSnapshot, + ProjectCloneStage, + ProjectCloneStartInput, + ProjectCloneStartResult, + ProjectId, + SourceControlRepositoryInfo, +} from "@t3tools/contracts"; +import { + OrchestrationDispatchCommandError, + PROJECT_CLONE_DETAIL_MAX_LENGTH, + PROJECT_CLONE_ERROR_MAX_LENGTH, + SourceControlRepositoryError, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts"; + +/** + * Runs repository clones that back newly added projects and tracks their + * progress so clients can show it anywhere, not just in the surface that + * started the clone. + * + * The clone is detached from the request that started it: the palette closes + * immediately, the project already exists (pointing at the empty destination), + * and the composer can hold a draft for it. Snapshots are memory only. A done + * clone is dropped after a short grace window; a failed one stays until it is + * retried or the server restarts, since the empty project is the durable + * record the user can act on. + */ +export class ProjectCloneTracker extends Context.Service< + ProjectCloneTracker, + { + /** + * Resolves the remote, creates the project, and starts the clone in the + * background. Fails before creating anything when the destination or + * repository is unusable so the caller can report it inline. + */ + readonly start: ( + input: ProjectCloneStartInput, + hooks: ProjectCloneHooks, + ) => Effect.Effect< + ProjectCloneStartResult, + SourceControlRepositoryError | OrchestrationDispatchCommandError + >; + /** Interrupts a running clone and deletes the partial checkout. */ + readonly cancel: (projectId: ProjectId) => Effect.Effect; + /** Restarts a failed or cancelled clone into the same destination. */ + readonly retry: (projectId: ProjectId) => Effect.Effect; + /** + * Stops tracking a project's clone, interrupting it if it still runs and + * removing an unfinished checkout. Called when the project is deleted. + */ + readonly discard: (projectId: ProjectId) => Effect.Effect; + readonly get: (projectId: ProjectId) => Effect.Effect; + /** Emits every tracked clone first, then the full list after each change. */ + readonly stream: Stream.Stream>; + } +>()("t3/project/ProjectCloneTracker") {} + +/** + * Orchestration side effects the caller owns. The tracker never depends on the + * engine directly: the WebSocket handler dispatches with the client's origin, + * and the tracker only needs the outcome. + */ +export interface ProjectCloneHooks { + readonly createProject: (input: { + readonly projectId: ProjectId; + readonly title: string; + readonly workspaceRoot: string; + readonly createdAt: string; + }) => Effect.Effect; + /** Runs after a successful clone so cached repository identity and git status refresh. */ + readonly onCloned: (input: { + readonly projectId: ProjectId; + readonly workspaceRoot: string; + }) => Effect.Effect; +} + +/** Finished snapshots stay visible this long so a late subscriber sees the outcome. */ +const DONE_RETENTION = "30 seconds"; + +function clampText(text: string, maxLength: number): string { + return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`; +} + +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + +interface TrackedClone { + readonly snapshot: ProjectCloneSnapshot; + readonly fiber: Fiber.Fiber | null; + readonly hooks: ProjectCloneHooks; + readonly input: { + /** What git is given; may carry credentials and never leaves the server. */ + readonly cloneUrl: string; + readonly destinationPath: string; + readonly repository: SourceControlRepositoryInfo | null; + }; +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const repositories = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const clones = yield* Ref.make(new Map()); + const changes = yield* PubSub.unbounded>(); + const retentionFibers = new Map>(); + let sequence = 0; + // Clone fibers outlive the RPC that started them but not the server. + const cloneScope = yield* Scope.make("parallel"); + yield* Effect.addFinalizer(() => Scope.close(cloneScope, Exit.void)); + // start/cancel/retry/discard mutate the same entry and the same directory; + // one at a time keeps a double-clicked Retry from racing two clones into it. + const actionLock = yield* Semaphore.make(1); + const locked = (effect: Effect.Effect) => actionLock.withPermits(1)(effect); + + const list = Ref.get(clones).pipe( + Effect.map((current) => Array.from(current.values(), (tracked) => tracked.snapshot)), + ); + const publish = list.pipe(Effect.flatMap((snapshots) => PubSub.publish(changes, snapshots))); + + const modify = ( + projectId: ProjectId, + mutate: (tracked: TrackedClone) => TrackedClone, + ): Effect.Effect => + Ref.modify(clones, (current) => { + const existing = current.get(projectId); + if (!existing) return [null, current] as const; + const nextTracked = mutate(existing); + const nextSnapshot = { ...nextTracked.snapshot, sequence: ++sequence }; + const next = new Map(current); + next.set(projectId, { ...nextTracked, snapshot: nextSnapshot }); + return [nextSnapshot, next] as const; + }).pipe(Effect.tap((snapshot) => (snapshot ? publish : Effect.void))); + + const clearRetention = (projectId: ProjectId) => { + const fiber = retentionFibers.get(projectId); + retentionFibers.delete(projectId); + return fiber ? Fiber.interrupt(fiber).pipe(Effect.ignore) : Effect.void; + }; + + const remove = (projectId: ProjectId) => + Ref.update(clones, (current) => { + if (!current.has(projectId)) return current; + const next = new Map(current); + next.delete(projectId); + return next; + }).pipe(Effect.andThen(publish)); + + const scheduleRemoval = (projectId: ProjectId) => + Effect.gen(function* () { + yield* clearRetention(projectId); + const fiber = yield* remove(projectId).pipe( + Effect.delay(DONE_RETENTION), + Effect.ensuring( + Effect.sync(() => { + if (retentionFibers.get(projectId) === fiber) retentionFibers.delete(projectId); + }), + ), + Effect.forkDetach, + ); + retentionFibers.set(projectId, fiber); + }); + + const progress = ( + projectId: ProjectId, + update: { + readonly stage: ProjectCloneStage; + readonly percent: number | null; + readonly detail: string | null; + }, + ) => + modify(projectId, (tracked) => ({ + ...tracked, + snapshot: { + ...tracked.snapshot, + stage: update.stage, + percent: update.percent, + detail: + update.detail === null ? null : clampText(update.detail, PROJECT_CLONE_DETAIL_MAX_LENGTH), + }, + })).pipe(Effect.asVoid); + + const finish = ( + projectId: ProjectId, + phase: "done" | "failed" | "cancelled", + error: string | null, + ) => + Effect.gen(function* () { + const endedAt = yield* nowIso; + yield* modify(projectId, (tracked) => ({ + ...tracked, + fiber: null, + snapshot: { + ...tracked.snapshot, + phase, + endedAt, + percent: phase === "done" ? 100 : tracked.snapshot.percent, + error: error === null ? null : clampText(error, PROJECT_CLONE_ERROR_MAX_LENGTH), + }, + })); + if (phase === "done") yield* scheduleRemoval(projectId); + }); + + /** + * The clone body. Runs in its own fiber; the tracker records the outcome + * through `onExit`, which still runs when the fiber is interrupted (a + * `matchCause` handler would be skipped). Once git has finished the clone + * is marked done before the post-clone hook runs, so a late Cancel cannot + * tear down a complete checkout. + */ + const runClone = (projectId: ProjectId, tracked: TrackedClone) => + repositories + .cloneRepository( + { remoteUrl: tracked.input.cloneUrl, destinationPath: tracked.input.destinationPath }, + { onProgress: (update) => progress(projectId, update), timeoutMs: null }, + ) + .pipe( + Effect.onExit((exit) => + Exit.isSuccess(exit) + ? finish(projectId, "done", null) + : Cause.hasInterruptsOnly(exit.cause) + ? finish(projectId, "cancelled", null) + : finish(projectId, "failed", describeCloneFailure(exit.cause)), + ), + Effect.flatMap(() => + tracked.hooks + .onCloned({ projectId, workspaceRoot: tracked.input.destinationPath }) + .pipe(Effect.ignoreCause({ log: true })), + ), + Effect.ignoreCause(), + ); + + const launch = (projectId: ProjectId) => + Effect.gen(function* () { + const current = yield* Ref.get(clones); + const tracked = current.get(projectId); + if (!tracked) return; + const fiber = yield* runClone(projectId, tracked).pipe(Effect.forkIn(cloneScope)); + yield* Ref.update(clones, (map) => { + const existing = map.get(projectId); + if (!existing) return map; + const next = new Map(map); + next.set(projectId, { ...existing, fiber }); + return next; + }); + }); + + const start: ProjectCloneTracker["Service"]["start"] = Effect.fn("ProjectCloneTracker.start")( + function* (input, hooks) { + const prepared = yield* repositories.prepareClone(input); + const startedAt = yield* nowIso; + const snapshot: ProjectCloneSnapshot = { + projectId: input.projectId, + remoteUrl: prepared.remoteUrl, + destinationPath: prepared.destinationPath, + repository: prepared.repository, + phase: "running", + stage: "connecting", + percent: null, + detail: null, + error: null, + startedAt, + endedAt: null, + sequence: ++sequence, + }; + const claimed = yield* Ref.modify(clones, (current) => { + // A second start for the same project, or for a destination another + // clone already owns, must not race two gits into one directory. + const conflict = Array.from(current.values()).some( + (tracked) => + tracked.snapshot.projectId === input.projectId || + tracked.input.destinationPath === prepared.destinationPath, + ); + if (conflict) return [false, current] as const; + const next = new Map(current); + next.set(input.projectId, { + snapshot, + fiber: null, + hooks, + input: { + cloneUrl: prepared.cloneUrl, + destinationPath: prepared.destinationPath, + repository: prepared.repository, + }, + }); + return [true, next] as const; + }); + if (!claimed) { + return yield* new SourceControlRepositoryError({ + operation: "cloneRepository", + provider: input.provider ?? "unknown", + detail: "A clone into this destination is already in progress.", + }); + } + // Everything after the claim runs to completion even if the requesting + // connection drops: a claimed entry with no fiber could neither be + // cancelled nor retried. Any failure in here releases the claim. + yield* Effect.uninterruptible( + Effect.gen(function* () { + yield* clearRetention(input.projectId); + // The entry is registered before the project exists so a + // thread.create or project.delete racing this call already sees it. + yield* hooks.createProject({ + projectId: input.projectId, + title: input.title, + workspaceRoot: prepared.destinationPath, + createdAt: input.createdAt, + }); + yield* publish; + yield* launch(input.projectId); + }).pipe(Effect.tapError(() => remove(input.projectId))), + ); + return { + projectId: input.projectId, + cwd: prepared.destinationPath, + remoteUrl: prepared.remoteUrl, + repository: prepared.repository, + }; + }, + ); + + const get: ProjectCloneTracker["Service"]["get"] = (projectId) => + Ref.get(clones).pipe(Effect.map((current) => current.get(projectId)?.snapshot ?? null)); + + const cancel: ProjectCloneTracker["Service"]["cancel"] = (projectId) => + Effect.gen(function* () { + const current = yield* Ref.get(clones); + const tracked = current.get(projectId); + if (!tracked || tracked.snapshot.phase !== "running" || !tracked.fiber) return false; + // Uninterruptible past this point: a client that disconnects mid-cancel + // must not leave a dead fiber behind a "running" snapshot. + yield* Effect.uninterruptible( + Effect.gen(function* () { + yield* Fiber.interrupt(tracked.fiber!); + const after = yield* get(projectId); + // Git finished in the window before the interrupt landed: keep it. + if (after?.phase === "done") return; + if (after?.phase === "running") yield* finish(projectId, "cancelled", null); + // The partial checkout goes so a retry starts from an empty destination. + yield* repositories.discardClone(tracked.input.destinationPath).pipe(Effect.ignore); + }), + ); + return true; + }); + + const retry: ProjectCloneTracker["Service"]["retry"] = (projectId) => + Effect.gen(function* () { + const current = yield* Ref.get(clones); + const tracked = current.get(projectId); + if ( + !tracked || + (tracked.snapshot.phase !== "failed" && tracked.snapshot.phase !== "cancelled") + ) { + return false; + } + // A retry into leftover files would fail on the non-empty destination + // with a less useful message, so a cleanup failure is the error here. + yield* repositories.discardClone(tracked.input.destinationPath); + const startedAt = yield* nowIso; + yield* modify(projectId, (entry) => ({ + ...entry, + snapshot: { + ...entry.snapshot, + phase: "running", + stage: "connecting", + percent: null, + detail: null, + error: null, + startedAt, + endedAt: null, + }, + })); + yield* launch(projectId); + return true; + }); + + const discard: ProjectCloneTracker["Service"]["discard"] = (projectId) => + Effect.gen(function* () { + const current = yield* Ref.get(clones); + const tracked = current.get(projectId); + if (!tracked) return; + if (tracked.fiber) yield* Fiber.interrupt(tracked.fiber); + // Git may have finished while the interrupt was landing; the project + // is going away either way, but a complete checkout is the user's. + const after = yield* get(projectId); + if (after?.phase !== "done") { + yield* repositories.discardClone(tracked.input.destinationPath).pipe(Effect.ignore); + } + yield* clearRetention(projectId); + yield* remove(projectId); + }); + + // One-slot sliding mailbox per subscriber: a slow socket only ever holds the + // newest list, and lists are whole states so skipping intermediates is safe. + const stream: ProjectCloneTracker["Service"]["stream"] = Stream.callback< + ReadonlyArray + >( + (mailbox) => + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(changes); + Queue.offerUnsafe(mailbox, yield* list); + yield* Stream.fromSubscription(subscription).pipe( + Stream.runForEach((snapshots) => + Effect.sync(() => Queue.offerUnsafe(mailbox, snapshots)), + ), + Effect.forkScoped, + ); + }), + { bufferSize: 1, strategy: "sliding" }, + ); + + return ProjectCloneTracker.of({ + start: (input, hooks) => locked(start(input, hooks)), + cancel: (projectId) => locked(cancel(projectId)), + retry: (projectId) => locked(retry(projectId)), + discard: (projectId) => locked(discard(projectId)), + get, + stream, + }); +}); + +const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); + +/** + * A project whose clone has not landed has no files to work in. Every + * dispatch transport (WebSocket, HTTP) runs this before normalizing so no + * client can start a thread on an empty tree, and no attachment copies are + * made for a command that is about to be refused. + */ +export const rejectCommandsDuringClone = ( + tracker: ProjectCloneTracker["Service"], + command: { readonly type: string; readonly projectId?: ProjectId; readonly bootstrap?: unknown }, +): Effect.Effect => + Effect.gen(function* () { + const projectId = + command.type === "thread.create" + ? (command.projectId ?? null) + : command.type === "thread.turn.start" + ? bootstrapProjectId(command.bootstrap) + : null; + if (projectId === null) return; + const clone = yield* tracker.get(projectId); + if (clone === null || clone.phase === "done") return; + return yield* new OrchestrationDispatchCommandError({ + message: + clone.phase === "running" + ? "The repository is still being cloned." + : "The repository was not cloned. Retry the clone first.", + }); + }); + +function bootstrapProjectId(bootstrap: unknown): ProjectId | null { + if (typeof bootstrap !== "object" || bootstrap === null) return null; + const createThread = (bootstrap as { createThread?: { projectId?: ProjectId } }).createThread; + return createThread?.projectId ?? null; +} + +/** Removing a project mid-clone stops the clone and drops its partial checkout. */ +export const discardCloneForDeletedProject = ( + tracker: ProjectCloneTracker["Service"], + command: OrchestrationCommand, +): Effect.Effect => + command.type === "project.delete" ? tracker.discard(command.projectId) : Effect.void; + +function describeCloneFailure(cause: Cause.Cause): string { + const error = Cause.squash(cause); + if (isSourceControlRepositoryError(error)) return error.detail; + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The repository could not be cloned."; +} + +export const layer = Layer.effect(ProjectCloneTracker, make); diff --git a/apps/server/src/project/gitCloneProgress.ts b/apps/server/src/project/gitCloneProgress.ts new file mode 100644 index 000000000000..a6108442b85a --- /dev/null +++ b/apps/server/src/project/gitCloneProgress.ts @@ -0,0 +1,44 @@ +import type { ProjectCloneStage } from "@t3tools/contracts"; + +export interface GitCloneProgressLine { + readonly stage: ProjectCloneStage; + readonly percent: number | null; + /** Transfer detail after the count, e.g. `12.30 MiB | 5.00 MiB/s`. */ + readonly detail: string | null; +} + +const STAGE_PREFIXES: ReadonlyArray = [ + [/^remote: Enumerating objects/, "counting"], + [/^remote: Counting objects/, "counting"], + [/^remote: Compressing objects/, "counting"], + [/^Receiving objects/, "receiving"], + [/^Resolving deltas/, "resolving"], + [/^Updating files/, "checkout"], + [/^Checking out files/, "checkout"], +]; + +const PERCENT = /:\s+(\d+)%\s+\((\d+)\/(\d+)\)(?:,\s*(.*?))?\s*(?:,\s*done\.)?\s*$/; + +/** + * Parses one line of `git clone --progress` stderr. Git redraws each counter + * with a bare `\r`, so callers hand over each redraw as its own line. Lines + * that are not progress counters (hints, warnings, `Cloning into ...`) return + * null and are left for the error surface. + */ +export function parseGitCloneProgressLine(line: string): GitCloneProgressLine | null { + const trimmed = line.trim(); + const stageEntry = STAGE_PREFIXES.find(([pattern]) => pattern.test(trimmed)); + if (!stageEntry) return null; + const stage = stageEntry[1]; + const match = PERCENT.exec(trimmed); + if (!match) return { stage, percent: null, detail: null }; + const percent = Number(match[1]); + const rawDetail = match[4]?.trim() ?? ""; + // The trailer of a finished line is "done." which carries no information. + const detail = rawDetail.length > 0 && rawDetail !== "done." ? rawDetail : null; + return { + stage, + percent: Number.isFinite(percent) ? Math.max(0, Math.min(100, percent)) : null, + detail, + }; +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index d5ba7bc6bf56..7402e908fcf8 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -148,6 +148,7 @@ import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; +import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; @@ -933,6 +934,13 @@ const buildAppUnderTest = (options?: { ...options?.layers?.terminalManager, }), WorktreeSetupTracker.layer, + ProjectCloneTracker.layer.pipe( + Layer.provide( + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ + ...options?.layers?.sourceControlRepositoryService, + }), + ), + ), ), ), Layer.provide( @@ -7238,6 +7246,106 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("starts a project clone in the background and blocks threads until it lands", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parentDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-clone-" }); + const destinationPath = path.join(parentDir, "t3code"); + const projectId = ProjectId.make("project-clone-1"); + const dispatched: Array = []; + const cloneGate = yield* Deferred.make(); + const metaUpdateDispatched = yield* Deferred.make(); + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatched.push(command.type); + return { sequence: dispatched.length }; + }).pipe( + Effect.tap(() => + command.type === "project.meta.update" + ? Deferred.succeed(metaUpdateDispatched, undefined) + : Effect.void, + ), + ), + }, + sourceControlRepositoryService: { + prepareClone: (input) => + Effect.succeed({ + destinationPath: input.destinationPath, + remoteUrl: input.remoteUrl ?? "", + cloneUrl: input.remoteUrl ?? "", + repository: null, + }), + cloneRepository: (input) => + Deferred.await(cloneGate).pipe( + Effect.as({ + cwd: input.destinationPath, + remoteUrl: input.remoteUrl ?? "", + repository: null, + }), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const started = yield* client[WS_METHODS.projectCloneStart]({ + projectId, + title: "t3code", + createdAt: "2026-01-01T00:00:00.000Z", + remoteUrl: "git@github.com:octocat/t3code.git", + destinationPath, + }); + assert.equal(started.cwd, destinationPath); + // The project exists before the clone finishes. + assert.deepEqual(dispatched, ["project.create"]); + + const blocked = yield* Effect.flip( + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-create-while-cloning"), + threadId: ThreadId.make("thread-while-cloning"), + projectId, + title: "Draft", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:01.000Z", + }), + ); + assert.include(String(blocked.message), "still being cloned"); + + const snapshots = yield* client[WS_METHODS.subscribeProjectClones]({}).pipe( + Stream.takeUntil((clones) => clones[0]?.phase === "done"), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* Deferred.succeed(cloneGate, undefined); + const lists = yield* Fiber.join(snapshots); + assert.equal(lists.at(-1)?.[0]?.phase, "done"); + // The finished clone refreshes the project so its repository + // identity updates. That hook runs after the done snapshot. + yield* Deferred.await(metaUpdateDispatched); + assert.deepEqual(dispatched, ["project.create", "project.meta.update"]); + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("records thread analytics only after a client command succeeds", () => Effect.gen(function* () { const effects: string[] = []; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 189d62dd8362..cd867c0e4651 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -103,6 +103,7 @@ import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; +import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; @@ -354,6 +355,10 @@ const SourceControlRepositoryServiceLayerLive = SourceControlRepositoryService.l Layer.provideMerge(SourceControlProviderRegistryLayerLive), ); +const ProjectCloneTrackerLayerLive = ProjectCloneTracker.layer.pipe( + Layer.provide(SourceControlRepositoryServiceLayerLive), +); + const ReviewLayerLive = ReviewService.layer.pipe( Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(VcsDriverRegistryLayerLive), @@ -366,6 +371,7 @@ const VcsLayerLive = Layer.empty.pipe( Layer.provideMerge(GitWorkflowLayerLive), Layer.provideMerge(ReviewLayerLive), Layer.provideMerge(SourceControlRepositoryServiceLayerLive), + Layer.provideMerge(ProjectCloneTrackerLayerLive), Layer.provideMerge( VcsStatusBroadcaster.layer.pipe( Layer.provide(GitWorkflowLayerLive), diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 461bff08668a..da45f9eabf2b 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -178,7 +178,7 @@ it.effect("clones a looked-up repository into the requested destination", () => assert.deepStrictEqual(cloneCalls, [ { cwd: parent, - args: ["clone", CLONE_URLS.url, "t3code"], + args: ["clone", "--progress", CLONE_URLS.url, "t3code"], }, ]); }).pipe( @@ -197,6 +197,154 @@ it.effect("clones a looked-up repository into the requested destination", () => }).pipe(Effect.provide(NodeServices.layer)), ); +it.effect("reports clone progress from git's stderr and keeps its error text on failure", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-source-control-clone-progress-", + }); + const destinationPath = path.join(parent, "t3code"); + const progress: Array<{ stage: string; percent: number | null; detail: string | null }> = []; + + const stderrLines = [ + "Cloning into 't3code'...", + "remote: Enumerating objects: 10, done.", + "Receiving objects: 40% (4/10), 1.00 MiB | 2.00 MiB/s", + "Receiving objects: 100% (10/10), 2.50 MiB | 2.00 MiB/s, done.", + "fatal: early EOF", + "fatal: unable to access 'https://user:s3c@ret@github.com/octocat/t3code.git/': could not resolve host", + ]; + const error = yield* Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + return yield* Effect.flip( + service.cloneRepository( + { remoteUrl: CLONE_URLS.sshUrl, destinationPath }, + { onProgress: (line) => Effect.sync(() => void progress.push(line)) }, + ), + ); + }).pipe( + Effect.provide( + makeLayer({ + git: { + execute: (input) => + Effect.gen(function* () { + for (const line of stderrLines) { + yield* input.progress?.onStderrLine?.(line) ?? Effect.void; + } + return yield* new GitCommandError({ + operation: input.operation, + command: "git", + cwd: input.cwd, + detail: "Git command exited with a non-zero status.", + exitCode: 128, + }); + }), + }, + }), + ), + ); + + assert.deepStrictEqual(progress, [ + { stage: "counting", percent: null, detail: null }, + { stage: "receiving", percent: 40, detail: "1.00 MiB | 2.00 MiB/s" }, + { stage: "receiving", percent: 100, detail: "2.50 MiB | 2.00 MiB/s" }, + ]); + // Git echoes the remote in some failures; the credentials must not follow. + assert.strictEqual( + error.detail, + "fatal: early EOF fatal: unable to access 'https://github.com/octocat/t3code.git/': could not resolve host", + ); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("strips embedded credentials from the remote URL it reports", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-source-control-redact-" }); + const destinationPath = path.join(parent, "t3code"); + const cloneArgs: Array> = []; + const result = yield* Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + return yield* service.prepareClone({ + remoteUrl: "https://user:s3cret@github.com/octocat/t3code.git", + destinationPath, + }); + }).pipe( + Effect.provide( + makeLayer({ + git: { + execute: (input) => + Effect.sync(() => { + cloneArgs.push(input.args); + return processOutput(); + }), + }, + }), + ), + ); + assert.equal(result.remoteUrl, "https://github.com/octocat/t3code.git"); + // Git itself still receives the credentials. + assert.equal(result.cloneUrl, "https://user:s3cret@github.com/octocat/t3code.git"); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("discards only a directory git wrote to", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-source-control-discard-" }); + const partial = path.join(parent, "partial"); + yield* fs.makeDirectory(path.join(partial, ".git"), { recursive: true }); + yield* fs.writeFileString(path.join(partial, "README.md"), "half"); + const foreign = path.join(parent, "foreign"); + yield* fs.makeDirectory(foreign); + yield* fs.writeFileString(path.join(foreign, "notes.txt"), "mine"); + + // A file where the directory should be must not be removed either. + const replaced = path.join(parent, "replaced"); + yield* fs.writeFileString(replaced, "not a directory"); + + yield* Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + yield* service.discardClone(partial); + const error = yield* Effect.flip(service.discardClone(foreign)); + assert.include(error.detail, "not from the clone"); + const replacedError = yield* Effect.flip(service.discardClone(replaced)); + assert.include(replacedError.detail, "could not be inspected"); + // A destination that never got created is nothing to discard. + yield* service.discardClone(path.join(parent, "missing")); + }).pipe(Effect.provide(makeLayer({}))); + + // The partial clone is emptied but its directory (the workspace root) stays. + assert.deepStrictEqual(yield* fs.readDirectory(partial), []); + assert.deepStrictEqual(yield* fs.readDirectory(foreign), ["notes.txt"]); + assert.strictEqual(yield* fs.readFileString(replaced), "not a directory"); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("redacts query tokens and userinfo containing '@' from reported URLs", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-source-control-redact2-" }); + yield* Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const query = yield* service.prepareClone({ + remoteUrl: "https://github.com/octocat/t3code.git?access_token=s3cret", + destinationPath: path.join(parent, "a"), + }); + assert.equal(query.remoteUrl, "https://github.com/octocat/t3code.git"); + const nested = yield* service.prepareClone({ + remoteUrl: "https://user:pa@rt@github.com/octocat/t3code.git", + destinationPath: path.join(parent, "b"), + }); + assert.equal(nested.remoteUrl, "https://github.com/octocat/t3code.git"); + }).pipe(Effect.provide(makeLayer({}))); + }).pipe(Effect.provide(NodeServices.layer)), +); + it.effect("preserves destination probe failures instead of treating them as missing paths", () => { const fileSystemCause = PlatformError.systemError({ _tag: "PermissionDenied", diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 0addeca9e785..9bb3b1e029b6 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -3,6 +3,7 @@ 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 Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import { @@ -20,6 +21,10 @@ import { import { ServerConfig } from "../config.ts"; import { expandHomePathWith } from "../pathExpansion.ts"; +import { + parseGitCloneProgressLine, + type GitCloneProgressLine, +} from "../project/gitCloneProgress.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); @@ -30,15 +35,56 @@ export class SourceControlRepositoryService extends Context.Service< readonly lookupRepository: ( input: SourceControlRepositoryLookupInput, ) => Effect.Effect; + /** + * Everything `cloneRepository` checks before running git: the resolved + * remote, the normalized destination, and that the destination is empty. + * Lets a caller create the project first and clone afterwards. + */ + readonly prepareClone: ( + input: SourceControlCloneRepositoryInput, + ) => Effect.Effect; readonly cloneRepository: ( input: SourceControlCloneRepositoryInput, + options?: SourceControlCloneOptions, ) => Effect.Effect; + /** Removes a partial or failed clone so the destination is empty again. */ + readonly discardClone: ( + destinationPath: string, + ) => Effect.Effect; readonly publishRepository: ( input: SourceControlPublishRepositoryInput, ) => Effect.Effect; } >()("t3/sourceControl/SourceControlRepositoryService") {} +export interface SourceControlPreparedClone { + readonly destinationPath: string; + /** Credential-free; safe to show and to store in snapshots. */ + readonly remoteUrl: string; + /** What git is given; may carry embedded credentials. */ + readonly cloneUrl: string; + readonly repository: SourceControlRepositoryInfo | null; +} + +export interface SourceControlCloneOptions { + readonly onProgress?: (line: GitCloneProgressLine) => Effect.Effect; + /** Overrides the default clone budget; `null` disables the deadline. */ + readonly timeoutMs?: number | null; +} + +// The synchronous RPC (older clients, mobile) keeps a deadline: nothing else +// tells the user a clone stalled. The tracked path passes null and relies on +// progress and Cancel instead. +const CLONE_TIMEOUT_MS = 120_000; +const CLONE_ENV = { + // `--progress` forces the transfer counters through the pipe; the delay env + // makes the checkout counter start immediately. No tty means a credential + // prompt would hang forever, so tell git to fail instead. + GIT_PROGRESS_DELAY: "0", + GIT_TERMINAL_PROMPT: "0", + LC_ALL: "C", +} satisfies NodeJS.ProcessEnv; + function mapRepositoryError(operation: string, provider: SourceControlProviderKind) { return Effect.mapError((cause: unknown) => isSourceControlRepositoryError(cause) @@ -64,6 +110,36 @@ function toRepositoryInfo( }; } +/** + * The URL clients see. A pasted `https://user:token@host/…` must not travel + * back over `subscribeProjectClones` to every reader; git still gets the + * original. + */ +function redactRemoteUrl(remoteUrl: string): string { + try { + const url = new URL(remoteUrl); + // Clone URLs have no legitimate query; when one is present it is a token. + if (url.username.length === 0 && url.password.length === 0 && url.search.length === 0) { + return remoteUrl; + } + url.username = ""; + url.password = ""; + url.search = ""; + return url.toString(); + } catch { + return remoteUrl; + } +} + +// Userinfo may itself contain `@`; everything up to the last one before the +// host boundary goes. +const URL_WITH_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/]+@/gi; + +/** Drops `user:token@` from any URL embedded in free text. */ +function redactUrlCredentials(text: string): string { + return text.replace(URL_WITH_USERINFO, "$1"); +} + function selectRemoteUrl( urls: SourceControlRepositoryCloneUrls, protocol: SourceControlCloneProtocol | undefined, @@ -168,7 +244,7 @@ export const make = Effect.gen(function* () { }, ); - const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* ( + const prepareClone = Effect.fn("SourceControlRepositoryService.prepareClone")(function* ( input: SourceControlCloneRepositoryInput, ) { const preparedDestination = yield* prepareDestination(input.destinationPath); @@ -194,21 +270,121 @@ export const make = Effect.gen(function* () { }); } - yield* git.execute({ - operation: "SourceControlRepositoryService.cloneRepository", - cwd: preparedDestination.parentPath, - args: ["clone", remoteUrl, preparedDestination.directoryName], - timeoutMs: 120_000, - maxOutputBytes: 256 * 1024, - }); - return { - cwd: preparedDestination.destinationPath, - remoteUrl, + destinationPath: preparedDestination.destinationPath, + remoteUrl: redactRemoteUrl(remoteUrl), + cloneUrl: remoteUrl, repository, + } satisfies SourceControlPreparedClone; + }); + + const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* ( + input: SourceControlCloneRepositoryInput, + options?: SourceControlCloneOptions, + ) { + const prepared = yield* prepareClone(input); + const onProgress = options?.onProgress; + // Git interleaves progress redraws with its real messages on stderr. The + // last non-progress lines are what explain a failure ("Repository not + // found", "Permission denied"), so keep them for the error detail. + const stderrTail: Array = []; + const onStderrLine = (line: string) => { + const parsed = parseGitCloneProgressLine(line); + if (parsed) return onProgress ? onProgress(parsed) : Effect.void; + return Effect.sync(() => { + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith("Cloning into")) return; + // Git echoes the remote in some failures; the tail becomes user-facing text. + stderrTail.push(redactUrlCredentials(trimmed)); + if (stderrTail.length > 4) stderrTail.shift(); + }); + }; + yield* git + .execute({ + operation: "SourceControlRepositoryService.cloneRepository", + cwd: path.dirname(prepared.destinationPath), + args: ["clone", "--progress", prepared.cloneUrl, path.basename(prepared.destinationPath)], + timeoutMs: options?.timeoutMs === undefined ? CLONE_TIMEOUT_MS : options.timeoutMs, + // Progress redraws add up on a slow multi-GB clone. The buffered copy + // is never read (the tail is kept by hand above), so keep it small + // and let the line callbacks keep flowing past the cap. + maxOutputBytes: 256 * 1024, + appendTruncationMarker: true, + keepLineCallbacksAfterTruncation: true, + env: CLONE_ENV, + progress: { onStderrLine }, + }) + .pipe( + Effect.mapError( + (cause) => + new SourceControlRepositoryError({ + operation: "cloneRepository", + provider: input.provider ?? "unknown", + detail: + stderrTail.length > 0 + ? stderrTail.join(" ") + : "The repository could not be cloned.", + cause, + }), + ), + ); + + return { + cwd: prepared.destinationPath, + remoteUrl: prepared.remoteUrl, + repository: prepared.repository, }; }); + const discardClone = Effect.fn("SourceControlRepositoryService.discardClone")(function* ( + destinationPath: string, + ) { + const normalized = yield* normalizeDestinationPath(destinationPath); + // Only what git left behind may go. The destination was empty when the + // clone started, so anything without a `.git` inside was put there by + // someone else since; refuse rather than delete their files. + // A missing destination is already discarded; any other read failure + // (a file in its place, permissions) is not something to remove through. + const entries = yield* fileSystem.readDirectory(normalized).pipe( + Effect.catchIf( + (cause) => cause.reason._tag === "NotFound", + () => Effect.succeed>([]), + ), + Effect.mapError( + (cause) => + new SourceControlRepositoryError({ + operation: "discardClone", + provider: "unknown", + detail: "The clone destination could not be inspected.", + cause, + }), + ), + ); + if (entries.length > 0 && !entries.includes(".git")) { + return yield* new SourceControlRepositoryError({ + operation: "discardClone", + provider: "unknown", + detail: "Destination path contains files that are not from the clone.", + }); + } + // The directory itself is the project's workspace root and must stay; + // only git's partial contents go. An interrupted git may still be closing + // files, so removal retries briefly. + yield* fileSystem.remove(normalized, { recursive: true, force: true }).pipe( + Effect.andThen(fileSystem.makeDirectory(normalized, { recursive: true })), + Effect.retry({ schedule: Schedule.spaced("200 millis"), times: 5 }), + Effect.mapError( + (cause) => + new SourceControlRepositoryError({ + operation: "discardClone", + provider: "unknown", + detail: "The partial clone could not be removed.", + cause, + }), + ), + ); + }); + const publishRepository = Effect.fn("SourceControlRepositoryService.publishRepository")( function* (input: SourceControlPublishRepositoryInput) { const providerKind = yield* ensureConcreteProvider({ @@ -269,10 +445,14 @@ export const make = Effect.gen(function* () { return SourceControlRepositoryService.of({ lookupRepository: (input) => lookupRepository(input).pipe(mapRepositoryError("lookupRepository", input.provider)), - cloneRepository: (input) => - cloneRepository(input).pipe( + prepareClone: (input) => + prepareClone(input).pipe(mapRepositoryError("cloneRepository", input.provider ?? "unknown")), + cloneRepository: (input, options) => + cloneRepository(input, options).pipe( mapRepositoryError("cloneRepository", input.provider ?? "unknown"), ), + discardClone: (destinationPath) => + discardClone(destinationPath).pipe(mapRepositoryError("discardClone", "unknown")), publishRepository: (input) => publishRepository(input).pipe(mapRepositoryError("publishRepository", input.provider)), }); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 4d63a447d63f..b5bd9aeb484a 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -48,6 +48,12 @@ export interface ExecuteGitInput { readonly timeoutMs?: number | null; readonly maxOutputBytes?: number; readonly appendTruncationMarker?: boolean; + /** + * With `appendTruncationMarker`, keep invoking the line callbacks after the + * buffered copy is full. For long-running commands whose output is only + * consumed through `progress`. + */ + readonly keepLineCallbacksAfterTruncation?: boolean; readonly progress?: ExecuteGitProgress; } diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index bc8a12700997..f1da4a6bda33 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -770,6 +770,38 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("keeps line callbacks flowing past the output cap when asked", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + // 4 KiB of multi-byte lines, well past a 512-byte cap; the last line + // is the one a failure surface would need. + const lines: Array = []; + const result = yield* driver.execute({ + operation: "GitVcsDriver.test.callbacksPastCap", + cwd, + args: [ + "-c", + 'alias.spew=!for i in $(seq 1 128); do printf "é%03d\\n" $i >&2; done; echo fatal: last line >&2', + "spew", + ], + maxOutputBytes: 512, + appendTruncationMarker: true, + keepLineCallbacksAfterTruncation: true, + progress: { onStderrLine: (line) => Effect.sync(() => void lines.push(line)) }, + }); + + assert.isTrue(result.stderrTruncated); + assert.isAtMost(result.stderr.length, 600); + assert.equal(lines.length, 129); + assert.equal(lines[0], "é001"); + assert.equal(lines[127], "é128"); + assert.equal(lines.at(-1), "fatal: last line"); + // No replacement characters: the cap landing inside "é" is invisible to callbacks. + assert.isFalse(lines.some((line) => line.includes("\uFFFD"))); + }), + ); + it.effect("recovers a structurally identified missing cwd as a non-repository", () => Effect.gen(function* () { const parent = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 86a3e2ebd812..28ae6c5288ae 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -666,12 +666,19 @@ const collectOutput = Effect.fnUntraced(function* ( maxOutputBytes: number, appendTruncationMarker: boolean, onLine: ((line: string) => Effect.Effect) | undefined, + keepLineCallbacksAfterTruncation = false, ): Effect.fn.Return<{ readonly text: string; readonly truncated: boolean }, GitCommandError> { const decoder = new TextDecoder(); + // With callbacks continuing past the cap, lines are decoded by their own + // decoder from the first byte so no character is ever split at the cap. + const lineDecoder = keepLineCallbacksAfterTruncation && onLine ? new TextDecoder() : null; let bytes = 0; let text = ""; let lineBuffer = ""; let truncated = false; + // A separator-free stream past the cap must not grow the line buffer + // without bound; a line longer than this is not one the callbacks want. + const maxPendingLineBytes = 64 * 1024; // Git redraws progress with a bare `\r` between updates and only ends the // line once the step is done, so `\r` has to count as a line break here. @@ -697,6 +704,11 @@ const collectOutput = Effect.fnUntraced(function* ( const processChunk = Effect.fnUntraced(function* (chunk: Uint8Array) { if (appendTruncationMarker && truncated) { + if (lineDecoder) { + lineBuffer += lineDecoder.decode(chunk, { stream: true }); + yield* emitCompleteLines(false); + if (lineBuffer.length > maxPendingLineBytes) lineBuffer = ""; + } return; } const nextBytes = bytes + chunk.byteLength; @@ -717,7 +729,7 @@ const collectOutput = Effect.fnUntraced(function* ( const decoded = decoder.decode(chunkToDecode, { stream: !truncated }); text += decoded; - lineBuffer += decoded; + lineBuffer += lineDecoder ? lineDecoder.decode(chunk, { stream: true }) : decoded; yield* emitCompleteLines(false); }); @@ -735,6 +747,7 @@ const collectOutput = Effect.fnUntraced(function* ( const remainder = truncated ? "" : decoder.decode(); text += remainder; lineBuffer += remainder; + if (lineDecoder) lineBuffer += lineDecoder.decode(); yield* emitCompleteLines(true); return { text, @@ -802,6 +815,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* maxOutputBytes, appendTruncationMarker, input.progress?.onStdoutLine, + input.keepLineCallbacksAfterTruncation, ), collectOutput( commandInput, @@ -809,6 +823,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* maxOutputBytes, appendTruncationMarker, input.progress?.onStderrLine, + input.keepLineCallbacksAfterTruncation, ), child.exitCode.pipe( Effect.mapError( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6a16640f7034..6e5d9c02db39 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -8,6 +8,7 @@ import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -133,6 +134,8 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; +import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; +import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; import { importRecentAgentThreads } from "./project/AgentSessionImporter.ts"; @@ -605,6 +608,17 @@ const makeWsRpcLayer = ( }); const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const worktreeSetupTracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; + const projectCloneTracker = yield* ProjectCloneTracker.ProjectCloneTracker; + const repositoryIdentityResolver = + yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + // Clone hooks run on the tracker's fiber, outside any RPC, so the + // normalizer's services are captured here rather than inherited. + const normalizerContext = yield* Effect.context< + | FileSystem.FileSystem + | Path.Path + | ServerConfig.ServerConfig + | WorkspacePaths.WorkspacePaths + >(); const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; @@ -1615,6 +1629,7 @@ const makeWsRpcLayer = ( observeRpcEffect( ORCHESTRATION_WS_METHODS.dispatchCommand, Effect.gen(function* () { + yield* ProjectCloneTracker.rejectCommandsDuringClone(projectCloneTracker, command); const normalizedCommand = yield* normalizeDispatchCommand(command); // Archive removes the thread from the client, so this transport // closes its session and terminals after the command lands. @@ -1646,6 +1661,10 @@ const makeWsRpcLayer = ( Effect.tapError(() => cleanupFailedUploadedAttachments(command, normalizedCommand)), ); yield* recordClientCommandAnalytics(normalizedCommand); + yield* ProjectCloneTracker.discardCloneForDeletedProject( + projectCloneTracker, + normalizedCommand, + ); if (archiveCommand) { if (shouldStopSessionAfterCommand) { yield* Effect.gen(function* () { @@ -2657,6 +2676,65 @@ const makeWsRpcLayer = ( "rpc.aggregate": "source-control", }, ), + [WS_METHODS.projectCloneStart]: (input) => + observeRpcEffect( + WS_METHODS.projectCloneStart, + projectCloneTracker.start(input, { + createProject: (project) => + Effect.gen(function* () { + const normalizedCommand = yield* normalizeDispatchCommand({ + type: "project.create", + commandId: yield* serverCommandId("project-clone-create"), + projectId: project.projectId, + title: project.title, + workspaceRoot: project.workspaceRoot, + createWorkspaceRootIfMissing: true, + createdAt: project.createdAt, + }); + yield* dispatchNormalizedCommand(normalizedCommand); + yield* recordClientCommandAnalytics(normalizedCommand); + }).pipe(Effect.provideContext(normalizerContext)), + onCloned: (project) => + // The project was created against an empty directory, so its + // cached identity is "not a repository" until this refresh. + // Re-emitting the project shell carries the new identity to + // every client without a round trip. + repositoryIdentityResolver.resolve(project.workspaceRoot, { refresh: true }).pipe( + Effect.andThen( + Effect.gen(function* () { + const command = yield* normalizeDispatchCommand({ + type: "project.meta.update", + commandId: yield* serverCommandId("project-clone-done"), + projectId: project.projectId, + }); + yield* dispatchNormalizedCommand(command); + }), + ), + Effect.andThen(refreshGitStatus(project.workspaceRoot)), + Effect.ignoreCause({ log: true }), + Effect.provideContext(normalizerContext), + ), + }), + { "rpc.aggregate": "source-control" }, + ), + [WS_METHODS.projectCloneCancel]: (input) => + observeRpcEffect( + WS_METHODS.projectCloneCancel, + projectCloneTracker + .cancel(input.projectId) + .pipe(Effect.map((applied) => ({ applied }))), + { "rpc.aggregate": "source-control" }, + ), + [WS_METHODS.projectCloneRetry]: (input) => + observeRpcEffect( + WS_METHODS.projectCloneRetry, + projectCloneTracker.retry(input.projectId).pipe(Effect.map((applied) => ({ applied }))), + { "rpc.aggregate": "source-control" }, + ), + [WS_METHODS.subscribeProjectClones]: () => + observeRpcStream(WS_METHODS.subscribeProjectClones, projectCloneTracker.stream, { + "rpc.aggregate": "source-control", + }), [WS_METHODS.sourceControlPublishRepository]: (input) => observeRpcEffect( WS_METHODS.sourceControlPublishRepository, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0b6262387a96..303427ebd2e7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -226,6 +226,7 @@ import { AlarmClockIcon, CheckCircle2Icon, ChevronDownIcon, + DownloadIcon, GitBranchIcon, Minimize2Icon, PaperclipIcon, @@ -259,6 +260,7 @@ import { import { useNowMinute } from "../hooks/useNowMinute"; import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; import { useThreadActions } from "../hooks/useThreadActions"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; @@ -328,6 +330,9 @@ import { } from "@t3tools/client-runtime/state/threads"; import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import { vcsEnvironment } from "../state/vcs"; +import { sourceControlEnvironment } from "../state/sourceControl"; +import { useProjectClone } from "../state/projectClones"; +import { projectCloneDisplayName, projectCloneProgressSummary } from "@t3tools/contracts"; import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; import { useProject, @@ -2094,6 +2099,113 @@ export default function ChatView(props: ChatViewProps) { () => (activeProject ? resolveProjectScripts(settings, activeProject) : []), [activeProject, settings], ); + // A project added by cloning exists before its files do. The draft stays + // editable throughout; only sending waits for the clone, and a failed + // clone offers its retry right where the user is looking. + const activeProjectClone = useProjectClone(activeProjectRef); + const cancelProjectClone = useAtomCommand(sourceControlEnvironment.cancelProjectClone, { + reportFailure: false, + }); + const retryProjectClone = useAtomCommand(sourceControlEnvironment.retryProjectClone, { + reportFailure: false, + }); + const removeClonedProject = useRemoveClonedProject(); + // The banner mirrors the server's clone state, so a request that never got + // there needs its own feedback. + const runProjectCloneAction = useCallback( + async ( + title: string, + action: () => Promise>, + ): Promise => { + const result = await action(); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }, + [], + ); + const projectCloneSendBlockReason = + activeProjectClone === null + ? null + : activeProjectClone.phase === "running" + ? "Cloning repository" + : activeProjectClone.phase === "done" + ? null + : "Repository not cloned"; + const projectCloneBannerItem = useMemo(() => { + if (!activeProjectClone || !activeProjectRef || activeProjectClone.phase === "done") { + return null; + } + const name = projectCloneDisplayName(activeProjectClone); + const { environmentId, projectId } = activeProjectRef; + if (activeProjectClone.phase === "running") { + return { + id: `project-clone:${projectId}`, + variant: "info", + priority: "activity", + icon: , + title: `Cloning ${name}`, + description: projectCloneProgressSummary(activeProjectClone), + actions: ( + + ), + }; + } + const cancelled = activeProjectClone.phase === "cancelled"; + return { + id: `project-clone:${projectId}`, + variant: cancelled ? "warning" : "error", + icon: , + title: cancelled ? `Cancelled cloning ${name}` : `Failed to clone ${name}`, + description: cancelled ? "Retry to bring in the repository." : activeProjectClone.error, + actions: ( + <> + + + + ), + }; + }, [ + activeProjectClone, + activeProjectRef, + cancelProjectClone, + removeClonedProject, + retryProjectClone, + runProjectCloneAction, + ]); const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection; const handleNewThreadInActiveProject = useCallback(() => { startNewThreadForProject(activeProjectRef, handleNewThread); @@ -6248,10 +6360,12 @@ export default function ChatView(props: ChatViewProps) { const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; // The user asked for this one, so it leads the notice tier instead of trailing it. const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; + const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { return [ ...feedbackBannerItems, ...usageLimitsItems, + ...projectCloneItems, ...systemComposerBannerItems, ...backgroundLivenessItems, ...resumeCompactionItems, @@ -6262,6 +6376,7 @@ export default function ChatView(props: ChatViewProps) { return [ ...feedbackBannerItems, ...usageLimitsItems, + ...projectCloneItems, ...systemComposerBannerItems, ...backgroundLivenessItems, ...resumeCompactionItems, @@ -6314,6 +6429,7 @@ export default function ChatView(props: ChatViewProps) { isRestoringThreadBranch, localCheckoutBranchMismatch, parkedThreadBannerItem, + projectCloneBannerItem, resumeCompactionBannerItem, showBranchMismatchBanner, systemComposerBannerItems, @@ -9162,7 +9278,7 @@ export default function ChatView(props: ChatViewProps) { ? "Sending feedback" : threadDetailLoading ? "Messages loading" - : null + : projectCloneSendBlockReason } isPreparingWorktree={isPreparingWorktree} bannerItems={composerBannerItems} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index f33a9cce63b0..a8d1e57e5172 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -84,7 +84,7 @@ import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; -import { useProjects, useServerConfigs, useThreadShells } from "../state/entities"; +import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; import { useThreadSearch } from "../state/queries"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { @@ -643,6 +643,9 @@ function OpenCommandPaletteDialog(props: { const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { reportFailure: false, }); + const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, { + reportFailure: false, + }); const { environments } = useEnvironments(); const desktopLocalBootstraps = useDesktopLocalBootstraps(); const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -2198,28 +2201,80 @@ function OpenCommandPaletteDialog(props: { return; } + // Older servers only offer the blocking clone: the palette has to wait + // for git so it can add the project afterwards. + if (browseEnvironment?.serverConfig?.environment.capabilities.projectCloneTracking !== true) { + setIsRemoteProjectCloning(true); + const cloneResult = await cloneRepository({ + environmentId: addProjectCloneFlow.environmentId, + input: { + remoteUrl: addProjectCloneFlow.remoteUrl, + destinationPath, + }, + }); + setIsRemoteProjectCloning(false); + if (cloneResult._tag === "Failure") { + if (!isAtomCommandInterrupted(cloneResult)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Clone failed", + description: errorMessage(squashAtomCommandFailure(cloneResult)), + }), + ); + } + return; + } + await handleAddProject(cloneResult.value.cwd); + return; + } + + // The server creates the project and clones in the background; progress + // shows in a toast and in the draft's composer banner, so the palette + // closes as soon as the clone is under way. Only problems found before + // git runs (bad destination, unknown repository) come back here. + const projectId = newProjectId(); setIsRemoteProjectCloning(true); - const cloneResult = await cloneRepository({ + const startResult = await startProjectClone({ environmentId: addProjectCloneFlow.environmentId, input: { + projectId, + title: inferProjectTitleFromPath(destinationPath), + createdAt: new Date().toISOString(), remoteUrl: addProjectCloneFlow.remoteUrl, destinationPath, }, }); setIsRemoteProjectCloning(false); - if (cloneResult._tag === "Failure") { - if (!isAtomCommandInterrupted(cloneResult)) { + if (startResult._tag === "Failure") { + if (!isAtomCommandInterrupted(startResult)) { toastManager.add( stackedThreadToast({ type: "error", title: "Clone failed", - description: errorMessage(squashAtomCommandFailure(cloneResult)), + description: errorMessage(squashAtomCommandFailure(startResult)), }), ); } return; } - await handleAddProject(cloneResult.value.cwd); + setOpen(false); + const projectRef = scopeProjectRef(addProjectCloneFlow.environmentId, projectId); + // The create event usually lands before this call returns; give the shell + // stream a moment so the draft opens with its project resolved instead of + // flashing the project picker. + await waitForProject(projectRef, 3_000).catch(() => null); + const navigationResult = await settlePromise(() => handleNewThread(projectRef)); + if (navigationResult._tag === "Failure") { + const error = squashAtomCommandFailure(navigationResult); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to open project", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } } const browseTo = useCallback( diff --git a/apps/web/src/components/ProjectCloneToastCoordinator.tsx b/apps/web/src/components/ProjectCloneToastCoordinator.tsx new file mode 100644 index 000000000000..2c4a477a2f2e --- /dev/null +++ b/apps/web/src/components/ProjectCloneToastCoordinator.tsx @@ -0,0 +1,240 @@ +import { useParams } from "@tanstack/react-router"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { + type AtomCommandResult, + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + projectCloneDisplayName, + projectCloneProgressSummary, + type EnvironmentId, + type ProjectCloneSnapshot, + type ProjectId, +} from "@t3tools/contracts"; +import { useCallback, useEffect, useRef } from "react"; + +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject"; +import { useEnvironments } from "../state/environments"; +import { useEnvironmentProjectClones } from "../state/projectClones"; +import { sourceControlEnvironment } from "../state/sourceControl"; +import { useAtomCommand } from "../state/use-atom-command"; +import { type DraftId, useComposerDraftStore } from "../composerDraftStore"; +import { toastManager } from "./ui/toast"; +import { stackedThreadToast } from "./ui/toastHelpers"; + +/** + * One toast per clone in flight, on every environment. The palette that + * started a clone closes right away, so this is where its progress lives: + * the toast updates in place as git reports stages, then settles into a + * success or failure state with the matching action. + */ +export function ProjectCloneToastCoordinator() { + const { environments } = useEnvironments(); + return environments.map((environment) => ( + + )); +} + +interface TrackedToast { + readonly toastId: ReturnType; + /** The last snapshot rendered, so an identical redraw does not touch the toast. */ + readonly renderedKey: string; + readonly phase: ProjectCloneSnapshot["phase"]; +} + +function renderKey(clone: ProjectCloneSnapshot): string { + return `${clone.phase}:${clone.stage}:${clone.percent ?? ""}:${clone.detail ?? ""}:${clone.error ?? ""}`; +} + +function EnvironmentCloneToasts({ environmentId }: { environmentId: EnvironmentId }) { + const clones = useEnvironmentProjectClones(environmentId); + const handleNewThread = useNewThreadHandler(); + const { draftId: routeDraftId } = useParams({ strict: false }); + const cancelClone = useAtomCommand(sourceControlEnvironment.cancelProjectClone, { + reportFailure: false, + }); + const retryClone = useAtomCommand(sourceControlEnvironment.retryProjectClone, { + reportFailure: false, + }); + // The toast mirrors the server's clone state, so a request that never got + // there needs its own feedback. + const runCloneAction = useCallback( + async (title: string, action: () => Promise>) => { + const result = await action(); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }, + [], + ); + const removeClonedProject = useRemoveClonedProject(); + const toasts = useRef(new Map()); + + // Whether the user is already looking at this project's draft: the composer + // banner shows the same progress and actions there, so the toast steps + // aside and comes back if they navigate away mid-clone. + const isViewingProjectDraft = useCallback( + (projectId: ProjectId) => { + if (!routeDraftId) return false; + const draft = useComposerDraftStore.getState().getDraftSession(routeDraftId as DraftId); + return draft?.environmentId === environmentId && draft.projectId === projectId; + }, + [environmentId, routeDraftId], + ); + + const openProject = useCallback( + (projectId: ProjectId) => { + void handleNewThread(scopeProjectRef(environmentId, projectId)); + }, + [environmentId, handleNewThread], + ); + + useEffect(() => { + const seen = new Set(); + for (const clone of clones) { + seen.add(clone.projectId); + const key = renderKey(clone); + const tracked = toasts.current.get(clone.projectId); + const name = projectCloneDisplayName(clone); + // Handlers run later than this pass, so they look the toast up then. + const closeToast = () => { + const current = toasts.current.get(clone.projectId); + if (!current) return; + toastManager.close(current.toastId); + toasts.current.delete(clone.projectId); + }; + if (isViewingProjectDraft(clone.projectId)) { + closeToast(); + continue; + } + if (tracked?.renderedKey === key) continue; + + if (clone.phase === "running") { + const options = stackedThreadToast({ + type: "loading", + title: `Cloning ${name}`, + description: projectCloneProgressSummary(clone), + timeout: 0, + actionProps: { + children: "Cancel", + onClick: () => { + void runCloneAction("Failed to cancel clone", () => + cancelClone({ environmentId, input: { projectId: clone.projectId } }), + ); + }, + }, + data: { hideCopyButton: true }, + }); + if (tracked) { + toastManager.update(tracked.toastId, options); + toasts.current.set(clone.projectId, { ...tracked, renderedKey: key, phase: "running" }); + } else { + const toastId = toastManager.add(options); + toasts.current.set(clone.projectId, { toastId, renderedKey: key, phase: "running" }); + } + continue; + } + + if (clone.phase === "done") { + const options = stackedThreadToast({ + type: "success", + title: `Cloned ${name}`, + description: clone.destinationPath, + timeout: 8_000, + actionProps: { + children: "Open project", + onClick: () => { + closeToast(); + openProject(clone.projectId); + }, + }, + data: { hideCopyButton: true }, + }); + if (tracked) { + toastManager.update(tracked.toastId, options); + toasts.current.set(clone.projectId, { ...tracked, renderedKey: key, phase: "done" }); + } else { + const toastId = toastManager.add(options); + toasts.current.set(clone.projectId, { toastId, renderedKey: key, phase: "done" }); + } + continue; + } + + // Failed or cancelled: the project stays, pointing at an empty folder. + // Retry from here; the draft's composer banner offers the same. + const cancelled = clone.phase === "cancelled"; + const options = stackedThreadToast({ + type: cancelled ? "info" : "error", + title: cancelled ? `Cancelled cloning ${name}` : `Failed to clone ${name}`, + description: cancelled ? clone.destinationPath : (clone.error ?? "The clone failed."), + timeout: 0, + actionProps: { + children: "Retry", + onClick: () => { + void runCloneAction("Failed to retry clone", () => + retryClone({ environmentId, input: { projectId: clone.projectId } }), + ); + }, + }, + data: { + ...(cancelled ? { hideCopyButton: true } : {}), + secondaryActionProps: { + children: "Remove project", + onClick: () => { + // The server drops the clone with the project, which closes + // this toast; a failed removal leaves it (and Retry) in place. + void removeClonedProject({ environmentId, projectId: clone.projectId }); + }, + }, + }, + }); + if (tracked) { + toastManager.update(tracked.toastId, options); + toasts.current.set(clone.projectId, { ...tracked, renderedKey: key, phase: clone.phase }); + } else { + const toastId = toastManager.add(options); + toasts.current.set(clone.projectId, { toastId, renderedKey: key, phase: clone.phase }); + } + } + + // A clone the server stopped tracking (done and expired, or its project + // was removed) takes its toast with it, unless it already settled into a + // timed success toast that dismisses itself. + for (const [projectId, tracked] of toasts.current) { + if (seen.has(projectId)) continue; + if (tracked.phase !== "done") toastManager.close(tracked.toastId); + toasts.current.delete(projectId); + } + }, [ + cancelClone, + clones, + environmentId, + isViewingProjectDraft, + openProject, + removeClonedProject, + retryClone, + runCloneAction, + ]); + + useEffect( + () => () => { + for (const tracked of toasts.current.values()) toastManager.close(tracked.toastId); + toasts.current.clear(); + }, + [], + ); + + return null; +} diff --git a/apps/web/src/hooks/useRemoveClonedProject.ts b/apps/web/src/hooks/useRemoveClonedProject.ts new file mode 100644 index 000000000000..72c5b91cde32 --- /dev/null +++ b/apps/web/src/hooks/useRemoveClonedProject.ts @@ -0,0 +1,62 @@ +import { useRouter } from "@tanstack/react-router"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import type { ScopedProjectRef } from "@t3tools/contracts"; +import { useCallback } from "react"; + +import { useComposerDraftStore } from "../composerDraftStore"; +import { resolveThreadRouteTarget } from "../threadRoutes"; +import { releaseProjectDraftUploads } from "../lib/composerDraftUploads"; +import { projectEnvironment } from "../state/projects"; +import { useAtomCommand } from "../state/use-atom-command"; +import { stackedThreadToast, toastManager } from "../components/ui/toast"; + +/** + * Removes a project whose clone never landed. The server clears the empty + * folder along with the clone, so there is nothing to confirm: no threads + * exist yet and the draft is the only thing lost, which the user is looking + * at when they click. + */ +export function useRemoveClonedProject() { + const router = useRouter(); + const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); + + return useCallback( + async (projectRef: ScopedProjectRef) => { + const draftStore = useComposerDraftStore.getState(); + const result = await deleteProject({ + environmentId: projectRef.environmentId, + // Not forced: a project whose clone never landed has no threads, and + // if one appeared in the meantime the server refuses rather than + // silently deleting it. + input: { projectId: projectRef.projectId }, + }); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to remove project", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return false; + } + // Read the route after the await: the user may have moved on while the + // delete was in flight, and only a draft of this project needs to go. + const routeParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; + const routeTarget = resolveThreadRouteTarget(routeParams); + const viewingDraft = + routeTarget?.kind === "draft" ? draftStore.getDraftSession(routeTarget.draftId) : null; + const viewingThisProject = + viewingDraft?.environmentId === projectRef.environmentId && + viewingDraft.projectId === projectRef.projectId; + releaseProjectDraftUploads(projectRef); + const projectDraft = draftStore.getDraftThreadByProjectRef(projectRef); + if (projectDraft) draftStore.clearDraftThread(projectDraft.draftId); + draftStore.clearProjectDraftThreadId(projectRef); + if (viewingThisProject) void router.navigate({ to: "/", replace: true }); + return true; + }, + [deleteProject, router], + ); +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 89a1ff23cc83..4db30275ee6b 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -27,6 +27,7 @@ import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator"; import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator"; +import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator"; import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; @@ -222,6 +223,7 @@ function RootRouteView() { + {primaryEnvironmentAuthenticated ? ( diff --git a/apps/web/src/state/projectClones.ts b/apps/web/src/state/projectClones.ts new file mode 100644 index 000000000000..b5645167c1cc --- /dev/null +++ b/apps/web/src/state/projectClones.ts @@ -0,0 +1,52 @@ +import { useAtomValue } from "@effect/atom-react"; +import { parseScopedProjectKey, scopedProjectKey } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, ProjectCloneSnapshot, ScopedProjectRef } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { environmentServerConfigsAtom } from "./server"; +import { sourceControlEnvironment } from "./sourceControl"; + +const EMPTY_CLONES: ReadonlyArray = []; +const EMPTY_CLONE_ATOM = Atom.make(null).pipe( + Atom.withLabel("web-project-clone:empty"), +); + +/** + * Latest clone list an environment has streamed; empty until the subscription + * delivers, and never subscribed on servers that predate clone tracking. + */ +const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get): ReadonlyArray => { + const supported = + get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .projectCloneTracking === true; + if (!supported) return EMPTY_CLONES; + const result = get(sourceControlEnvironment.projectClones({ environmentId, input: {} })); + return Option.getOrElse(AsyncResult.value(result), () => EMPTY_CLONES); + }).pipe(Atom.withLabel(`web-project-clones:${environmentId}`)), +); + +const projectCloneAtom = Atom.family((key: string) => { + const ref = parseScopedProjectKey(key); + return Atom.make((get): ProjectCloneSnapshot | null => { + if (ref === null) return null; + const clones = get(environmentProjectClonesAtom(ref.environmentId)); + return clones.find((clone) => clone.projectId === ref.projectId) ?? null; + }).pipe(Atom.withLabel(`web-project-clone:${key}`)); +}); + +/** + * The tracked clone for a project, or null once it finished (or never + * existed). Subscribing here opens the environment's clone stream, which is + * cheap: the server sends an empty list and stays quiet until a clone starts. + */ +export function useProjectClone(ref: ScopedProjectRef | null): ProjectCloneSnapshot | null { + return useAtomValue(ref === null ? EMPTY_CLONE_ATOM : projectCloneAtom(scopedProjectKey(ref))); +} + +export function useEnvironmentProjectClones( + environmentId: EnvironmentId, +): ReadonlyArray { + return useAtomValue(environmentProjectClonesAtom(environmentId)); +} diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 64cdc19c2812..d8a06fc34b64 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -76,7 +76,10 @@ az login ## Clone or publish a project Use **Add Project** in the command palette (`Cmd/Ctrl+K`) to clone a repository. Choose a hosting -provider or paste a Git URL, then choose where to save it. +provider or paste a Git URL, then choose where to save it. The project opens right away while the +clone runs in the background: you can write your first prompt, and sending waits until the files +are in place. A toast tracks progress and lets you cancel; if the clone fails, retry it from the +toast or from the banner above the composer. For a local Git repository without a remote, **Publish Repository** creates a hosted repository, adds it as `origin`, and pushes your commits. If there are no commits yet, it creates the remote; diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index af140ef2fcde..e80a2f0f4b12 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -57,6 +57,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.previewAutomationConnect | typeof WS_METHODS.subscribeVcsStatus | typeof WS_METHODS.subscribeWorktreeSetup + | typeof WS_METHODS.subscribeProjectClones | typeof WS_METHODS.terminalAttach; export type EnvironmentStreamCommandRpcTag = diff --git a/packages/client-runtime/src/state/sourceControl.ts b/packages/client-runtime/src/state/sourceControl.ts index c1598b49eaeb..39c7a9544549 100644 --- a/packages/client-runtime/src/state/sourceControl.ts +++ b/packages/client-runtime/src/state/sourceControl.ts @@ -5,6 +5,7 @@ import { createAtomCommandScheduler, createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily, + createEnvironmentRpcSubscriptionAtomFamily, } from "./runtime.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; @@ -33,6 +34,43 @@ export function createSourceControlEnvironmentAtoms( key: ({ environmentId }) => environmentId, }, }), + // Clone-backed project creation. The RPC returns once the project exists + // and the clone runs in the background; `projectClones` carries progress. + startProjectClone: createEnvironmentRpcCommand(runtime, { + label: "environment-data:source-control:project-clone-start", + tag: WS_METHODS.projectCloneStart, + scheduler: commandScheduler, + concurrency: { + mode: "serial", + key: ({ environmentId }) => environmentId, + }, + }), + // Cancel and retry share the start queue so a double click cannot race + // two actions against the same clone. + cancelProjectClone: createEnvironmentRpcCommand(runtime, { + label: "environment-data:source-control:project-clone-cancel", + tag: WS_METHODS.projectCloneCancel, + scheduler: commandScheduler, + concurrency: { + mode: "serial", + key: ({ environmentId }) => environmentId, + }, + }), + retryProjectClone: createEnvironmentRpcCommand(runtime, { + label: "environment-data:source-control:project-clone-retry", + tag: WS_METHODS.projectCloneRetry, + scheduler: commandScheduler, + concurrency: { + mode: "serial", + key: ({ environmentId }) => environmentId, + }, + }), + // Every clone the environment tracks. Empty until a clone starts; a + // finished clone drops out after a grace period, a failed one stays. + projectClones: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:source-control:project-clones", + tag: WS_METHODS.subscribeProjectClones, + }), publishRepository: createEnvironmentRpcCommand(runtime, { label: "environment-data:source-control:publish-repository", tag: WS_METHODS.sourceControlPublishRepository, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 88e2661523a3..c8b8833ead86 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -153,6 +153,11 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ this is false — no update would ever repaint it. Absent on older servers, which may still publish, so only an explicit false skips. */ agentActivityPublishing: Schema.optionalKey(Schema.Boolean), + /** Server runs repository clones for new projects in the background and + streams their progress (`projectClone.*`, `subscribeProjectClones`). + Absent on older servers, where clients must clone with the blocking + `sourceControl.cloneRepository` call instead. */ + projectCloneTracking: Schema.optionalKey(Schema.Boolean), /** Server detects `platform.machine` and persists the `environmentIcon` setting. Older servers drop the key on write, so clients show the picker inert rather than offering a choice that would never stick. */ diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 007120dcba05..978a0459e69b 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -25,6 +25,7 @@ export * from "./settings.ts"; export * from "./git.ts"; export * from "./vcs.ts"; export * from "./sourceControl.ts"; +export * from "./projectClone.ts"; export * from "./pullRequest.ts"; export * from "./orchestration.ts"; export * from "./t3ProjectFile.ts"; diff --git a/packages/contracts/src/projectClone.ts b/packages/contracts/src/projectClone.ts new file mode 100644 index 000000000000..e624ec17a72f --- /dev/null +++ b/packages/contracts/src/projectClone.ts @@ -0,0 +1,122 @@ +import * as Schema from "effect/Schema"; + +import { IsoDateTime, NonNegativeInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { + SourceControlCloneProtocol, + SourceControlProviderKind, + SourceControlRepositoryInfo, +} from "./sourceControl.ts"; + +/** + * Live progress of a repository clone that backs a freshly added project. The + * server keeps this in memory only: a finished clone is dropped after a short + * grace period, a failed one stays until it is retried or the project is + * removed, and a server restart forgets in-flight clones (the project keeps + * its empty workspace root and can be removed like any other project). + */ +/** Producers clamp free text to these before publishing so encoding never fails. */ +export const PROJECT_CLONE_DETAIL_MAX_LENGTH = 200; +export const PROJECT_CLONE_ERROR_MAX_LENGTH = 1000; + +/** Follows git's own clone phases as they appear on stderr, in order. */ +export const ProjectCloneStage = Schema.Literals([ + "connecting", + "counting", + "receiving", + "resolving", + "checkout", +]); +export type ProjectCloneStage = typeof ProjectCloneStage.Type; + +export const ProjectClonePhase = Schema.Literals(["running", "done", "failed", "cancelled"]); +export type ProjectClonePhase = typeof ProjectClonePhase.Type; + +export const ProjectCloneSnapshot = Schema.Struct({ + projectId: ProjectId, + remoteUrl: TrimmedNonEmptyString, + destinationPath: TrimmedNonEmptyString, + repository: Schema.NullOr(SourceControlRepositoryInfo), + phase: ProjectClonePhase, + stage: ProjectCloneStage, + /** Percent of the current stage, parsed from git's progress lines. */ + percent: Schema.NullOr(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))), + /** Trailing text from the progress line, typically transfer size and rate. */ + detail: Schema.NullOr(Schema.String.check(Schema.isMaxLength(PROJECT_CLONE_DETAIL_MAX_LENGTH))), + /** Human readable reason when phase is failed. */ + error: Schema.NullOr(Schema.String.check(Schema.isMaxLength(PROJECT_CLONE_ERROR_MAX_LENGTH))), + startedAt: IsoDateTime, + endedAt: Schema.NullOr(IsoDateTime), + sequence: NonNegativeInt, +}); +export type ProjectCloneSnapshot = typeof ProjectCloneSnapshot.Type; + +export const ProjectCloneSubscribeInput = Schema.Struct({}); +export type ProjectCloneSubscribeInput = typeof ProjectCloneSubscribeInput.Type; + +/** Every tracked clone on the environment. Sent first, then after every change. */ +export const ProjectCloneListEvent = Schema.Array(ProjectCloneSnapshot); +export type ProjectCloneListEvent = typeof ProjectCloneListEvent.Type; + +export const ProjectCloneStartInput = Schema.Struct({ + projectId: ProjectId, + title: TrimmedNonEmptyString, + createdAt: IsoDateTime, + provider: Schema.optional(SourceControlProviderKind), + repository: Schema.optional(TrimmedNonEmptyString), + remoteUrl: Schema.optional(TrimmedNonEmptyString), + destinationPath: TrimmedNonEmptyString, + protocol: Schema.optional(SourceControlCloneProtocol), +}); +export type ProjectCloneStartInput = typeof ProjectCloneStartInput.Type; + +export const ProjectCloneStartResult = Schema.Struct({ + projectId: ProjectId, + cwd: TrimmedNonEmptyString, + remoteUrl: TrimmedNonEmptyString, + repository: Schema.NullOr(SourceControlRepositoryInfo), +}); +export type ProjectCloneStartResult = typeof ProjectCloneStartResult.Type; + +export const ProjectCloneActionInput = Schema.Struct({ + projectId: ProjectId, +}); +export type ProjectCloneActionInput = typeof ProjectCloneActionInput.Type; + +export const ProjectCloneActionResult = Schema.Struct({ + applied: Schema.Boolean, +}); +export type ProjectCloneActionResult = typeof ProjectCloneActionResult.Type; + +function projectCloneStageLabel(stage: ProjectCloneStage): string { + switch (stage) { + case "connecting": + return "Connecting"; + case "counting": + return "Counting objects"; + case "receiving": + return "Receiving objects"; + case "resolving": + return "Resolving deltas"; + case "checkout": + return "Checking out files"; + } +} + +/** Display name for a clone: the looked-up `owner/repo`, else the folder being cloned into. */ +export function projectCloneDisplayName( + snapshot: Pick, +): string { + if (snapshot.repository) return snapshot.repository.nameWithOwner; + const segments = snapshot.destinationPath.split(/[/\\]/).filter((segment) => segment.length > 0); + return segments[segments.length - 1] ?? snapshot.destinationPath; +} + +/** One-line progress summary: `Receiving objects · 45% · 12.3 MiB | 5.0 MiB/s`. */ +export function projectCloneProgressSummary( + snapshot: Pick, +): string { + const parts = [projectCloneStageLabel(snapshot.stage)]; + if (snapshot.percent !== null) parts.push(`${snapshot.percent}%`); + if (snapshot.detail) parts.push(snapshot.detail); + return parts.join(" · "); +} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index d792a31885c1..9096dd6e27f9 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -249,6 +249,14 @@ import { } from "./providerUsageLimits.ts"; import { UsagePricing, UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; +import { + ProjectCloneActionInput, + ProjectCloneActionResult, + ProjectCloneListEvent, + ProjectCloneStartInput, + ProjectCloneStartResult, + ProjectCloneSubscribeInput, +} from "./projectClone.ts"; import { SourceControlCloneRepositoryInput, SourceControlCloneRepositoryResult, @@ -407,6 +415,10 @@ export const WS_METHODS = { sourceControlLookupRepository: "sourceControl.lookupRepository", sourceControlCloneRepository: "sourceControl.cloneRepository", sourceControlPublishRepository: "sourceControl.publishRepository", + projectCloneStart: "projectClone.start", + projectCloneCancel: "projectClone.cancel", + projectCloneRetry: "projectClone.retry", + subscribeProjectClones: "subscribeProjectClones", // Streaming subscriptions subscribeVcsStatus: "subscribeVcsStatus", @@ -842,6 +854,37 @@ const WsSourceControlCloneRepositoryRpc = Rpc.make(WS_METHODS.sourceControlClone error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), }); +// Clone-backed project creation. `start` returns once the project exists and +// the clone is running; progress arrives on the subscription. +const WsProjectCloneStartRpc = Rpc.make(WS_METHODS.projectCloneStart, { + payload: ProjectCloneStartInput, + success: ProjectCloneStartResult, + error: Schema.Union([ + SourceControlRepositoryError, + OrchestrationDispatchCommandError, + EnvironmentAuthorizationError, + ]), +}); + +const WsProjectCloneCancelRpc = Rpc.make(WS_METHODS.projectCloneCancel, { + payload: ProjectCloneActionInput, + success: ProjectCloneActionResult, + error: EnvironmentAuthorizationError, +}); + +const WsProjectCloneRetryRpc = Rpc.make(WS_METHODS.projectCloneRetry, { + payload: ProjectCloneActionInput, + success: ProjectCloneActionResult, + error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), +}); + +const WsSubscribeProjectClonesRpc = Rpc.make(WS_METHODS.subscribeProjectClones, { + payload: ProjectCloneSubscribeInput, + success: ProjectCloneListEvent, + error: EnvironmentAuthorizationError, + stream: true, +}); + const WsSourceControlPublishRepositoryRpc = Rpc.make(WS_METHODS.sourceControlPublishRepository, { payload: SourceControlPublishRepositoryInput, success: SourceControlPublishRepositoryResult, @@ -1379,6 +1422,10 @@ export const WsRpcGroup = RpcGroup.make( WsSourceControlLookupRepositoryRpc, WsSourceControlCloneRepositoryRpc, WsSourceControlPublishRepositoryRpc, + WsProjectCloneStartRpc, + WsProjectCloneCancelRpc, + WsProjectCloneRetryRpc, + WsSubscribeProjectClonesRpc, WsProjectsListEntriesRpc, WsProjectsReadFileRpc, WsProjectsSearchContentsRpc, From 549d182aaadbf0e1e195d1a8cdc1805200e6751e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 14 Sep 2026 13:00:29 -0700 Subject: [PATCH 02/16] feat(mobile): clone repositories in the background and gate the draft on the clone (#11774) Co-authored-by: Claude Fable 5 --- .../src/components/ProjectCloneBanner.tsx | 81 ++++++++++++ .../features/projects/AddProjectScreen.tsx | 62 ++++++++- .../threads/NewTaskDraftRouteScreen.tsx | 3 + .../features/threads/NewTaskDraftScreen.tsx | 122 ++++++++++++++++-- apps/mobile/src/state/entities.ts | 27 ++++ apps/mobile/src/state/projectClones.ts | 55 ++++++++ 6 files changed, 340 insertions(+), 10 deletions(-) create mode 100644 apps/mobile/src/components/ProjectCloneBanner.tsx create mode 100644 apps/mobile/src/state/projectClones.ts diff --git a/apps/mobile/src/components/ProjectCloneBanner.tsx b/apps/mobile/src/components/ProjectCloneBanner.tsx new file mode 100644 index 000000000000..cca6a71e3f5e --- /dev/null +++ b/apps/mobile/src/components/ProjectCloneBanner.tsx @@ -0,0 +1,81 @@ +import { + projectCloneDisplayName, + projectCloneProgressSummary, + type ProjectCloneSnapshot, +} from "@t3tools/contracts"; +import { ActivityIndicator, Pressable, View } from "react-native"; + +import { cn } from "../lib/cn"; +import { AppText as Text } from "./AppText"; + +/** + * Live state of the clone that backs a freshly added project, shown above + * the composer while the draft waits for its files. Running clones offer + * Cancel; failed or cancelled ones offer Retry and Remove project. + */ +export function ProjectCloneBanner(props: { + readonly clone: ProjectCloneSnapshot; + readonly onCancel: () => void; + readonly onRetry: () => void; + readonly onRemove: () => void; +}) { + const { clone } = props; + const name = projectCloneDisplayName(clone); + if (clone.phase === "running") { + return ( + + + + + Cloning {name} + + + {projectCloneProgressSummary(clone)} + + + + + ); + } + const cancelled = clone.phase === "cancelled"; + return ( + + + {cancelled ? `Cancelled cloning ${name}` : `Failed to clone ${name}`} + + {clone.error ? ( + + {clone.error} + + ) : null} + + + + + + ); +} + +function BannerAction(props: { readonly label: string; readonly onPress: () => void }) { + return ( + + {props.label} + + ); +} diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 5231228829d3..5724abb138c8 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -49,7 +49,7 @@ import * as Order from "effect/Order"; import { AsyncResult } from "effect/unstable/reactivity"; import { cn } from "../../lib/cn"; -import { useProjects, useServerConfigs } from "../../state/entities"; +import { useProjects, useServerConfigs, waitForProject } from "../../state/entities"; import { filesystemEnvironment } from "../../state/filesystem"; import { projectEnvironment } from "../../state/projects"; import { useEnvironmentQuery } from "../../state/query"; @@ -77,6 +77,8 @@ interface EnvironmentOption { readonly connectionState: EnvironmentConnectionPhase; readonly connectionError: string | null; readonly connectionErrorTraceId: string | null; + /** Server runs clones in the background and streams progress; older servers block. */ + readonly supportsCloneTracking: boolean; } const environmentOptionOrder = Order.mapInput( @@ -366,6 +368,7 @@ function useEnvironmentOptions(): ReadonlyArray { connectionState: runtime?.connectionState ?? "available", connectionError: runtime?.connectionError ?? null, connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null, + supportsCloneTracking: config?.environment.capabilities.projectCloneTracking === true, }; }); return Arr.sort(options, environmentOptionOrder); @@ -575,6 +578,15 @@ export function AddProjectSourceScreen() { ); } +function openNewTaskDraft( + navigation: { dispatch: (action: ReturnType) => void }, + params: { environmentId: EnvironmentId; projectId: ProjectId; title: string; cloning?: "1" }, +) { + navigation.dispatch( + CommonActions.reset({ index: 0, routes: [{ name: "NewTaskDraft", params }] }), + ); +} + function useCreateProject(environment: EnvironmentOption | null) { const navigation = useNavigation(); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); @@ -908,6 +920,10 @@ export function AddProjectDestinationScreen(props: { const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { reportFailure: false, }); + const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, { + reportFailure: false, + }); + const navigation = useNavigation(); const environment = useEnvironmentFromParam(props.environmentId); const createProject = useCreateProject(environment); const remoteUrl = stringParam(props.remoteUrl); @@ -938,6 +954,48 @@ export function AddProjectDestinationScreen(props: { } setIsSubmitting(true); + if (environment.supportsCloneTracking) { + // The server creates the project and clones in the background; the + // draft screen shows progress and holds Start until the files land. + const projectId = ProjectId.make(uuidv4()); + const title = inferProjectTitleFromPath(resolved.path); + const startResult = await startProjectClone({ + environmentId: environment.environmentId, + input: { + projectId, + title, + createdAt: new Date().toISOString(), + remoteUrl, + destinationPath: resolved.path, + }, + }); + if (AsyncResult.isFailure(startResult)) { + setError(errorMessage(Cause.squash(startResult.cause))); + } else { + // The draft screen resolves its project from the client store, so it + // must not open before the create event has arrived (it would fall + // back to the project picker and lose the clone controls). Stay in + // the submitting state until then; the clone keeps running either way. + const project = await waitForProject( + { environmentId: environment.environmentId, projectId }, + 15_000, + ); + if (project === null) { + setError( + "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.", + ); + } else { + openNewTaskDraft(navigation, { + environmentId: environment.environmentId, + projectId, + title, + cloning: "1", + }); + } + } + setIsSubmitting(false); + return; + } const cloneResult = await cloneRepository({ environmentId: environment.environmentId, input: { @@ -960,8 +1018,10 @@ export function AddProjectDestinationScreen(props: { environment, isBrowseNavigating, isSubmitting, + navigation, pathInput, remoteUrl, + startProjectClone, ]); return ( diff --git a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx index aab423a3792e..67b68b35f991 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx @@ -21,6 +21,8 @@ type NewTaskDraftRouteParams = { readonly branch?: string | null; readonly worktreePath?: string | null; readonly title?: string | string[]; + /** Set by Add Project when this draft opens while the project's clone runs. */ + readonly cloning?: string | string[]; readonly pendingTaskId?: string | string[]; readonly draftId?: string | string[]; readonly incomingShareId?: string | string[]; @@ -48,6 +50,7 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps environment.environmentId === selectedProject.environmentId, )?.connectionState === "connected"; const modelUnavailable = environmentConnected && flow.selectedModelOption?.isUnavailable === true; + // A project added by cloning exists before its files do: the prompt can be + // written meanwhile, but Start waits for the clone. + const projectCloneState = useProjectClone( + selectedProject + ? { environmentId: selectedProject.environmentId, projectId: selectedProject.id } + : null, + ); + const projectClone = projectCloneState === "pending" ? null : projectCloneState; + // Before the clone stream delivers, only the project this draft was opened + // for by Add Project is known to be cloning; any other project (offline + // included) must still be able to queue a task. + const awaitingKnownClone = + projectCloneState === "pending" && + props.initialProjectRef?.cloning === true && + selectedProject !== null && + selectedProject.id === props.initialProjectRef.projectId && + selectedProject.environmentId === props.initialProjectRef.environmentId; + const cloneBlocksStart = + awaitingKnownClone || (projectClone !== null && projectClone.phase !== "done"); + const cancelProjectClone = useAtomCommand(sourceControlEnvironment.cancelProjectClone, { + reportFailure: false, + }); + const retryProjectClone = useAtomCommand(sourceControlEnvironment.retryProjectClone, { + reportFailure: false, + }); + // The banner only reflects the server's state, so a request that never got + // there needs its own feedback. + const runCloneAction = async ( + title: string, + action: () => Promise>, + ) => { + const result = await action(); + if (AsyncResult.isFailure(result)) { + const error = Cause.squash(result.cause); + Alert.alert(title, error instanceof Error ? error.message : "An error occurred."); + } + }; + const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); + // The delete is awaited; by then the picker may point somewhere else, and + // only the removed project's draft should leave the screen. + const selectedProjectRef = useRef(selectedProject); + useEffect(() => { + selectedProjectRef.current = selectedProject; + }, [selectedProject]); + const removeClonedProject = async () => { + if (!selectedProject) return; + const removed = selectedProject; + const result = await deleteProject({ + environmentId: removed.environmentId, + input: { projectId: removed.id }, + }); + if (AsyncResult.isFailure(result)) { + const error = Cause.squash(result.cause); + Alert.alert( + "Failed to remove project", + error instanceof Error ? error.message : "An error occurred.", + ); + return; + } + const current = selectedProjectRef.current; + if (current?.id === removed.id && current.environmentId === removed.environmentId) { + navigation.dispatch(StackActions.replace("Home")); + } + }; const uploadStates = useAtomValue(composerAttachmentUploadsAtom); const attachmentBlockReason = selectedProject ? composerAttachmentUploadBlockReason({ @@ -1261,6 +1334,7 @@ export function NewTaskDraftScreen(props: { const isAndroid = Platform.OS === "android"; const canStart = !isImportingContext && + !cloneBlocksStart && attachmentBlockReason === null && !modelUnavailable && Boolean(flow.selectedProject) && @@ -1477,6 +1551,32 @@ export function NewTaskDraftScreen(props: { /> ) : null} + {/* Above the workspace controls so they keep their place relative to + the composer when the banner goes away once the clone lands. */} + {projectClone && projectClone.phase !== "done" && selectedProject ? ( + + + void runCloneAction("Failed to cancel clone", () => + cancelProjectClone({ + environmentId: selectedProject.environmentId, + input: { projectId: selectedProject.id }, + }), + ) + } + onRetry={() => + void runCloneAction("Failed to retry clone", () => + retryProjectClone({ + environmentId: selectedProject.environmentId, + input: { projectId: selectedProject.id }, + }), + ) + } + onRemove={() => void removeClonedProject()} + /> + + ) : null} {workspaceControls} {modelUnavailable ? ( @@ -1618,15 +1718,19 @@ export function NewTaskDraftScreen(props: { 0 - ? "Attaching pasted text" - : flow.submitting - ? "Starting task" - : attachmentsUploading - ? "Queue task, sends when uploads finish" - : environmentConnected - ? "Start task" - : "Queue task") + (cloneBlocksStart + ? projectClone === null || projectClone.phase === "running" + ? "Cloning repository" + : "Repository not cloned" + : pendingPastedTextAttachmentCount > 0 + ? "Attaching pasted text" + : flow.submitting + ? "Starting task" + : attachmentsUploading + ? "Queue task, sends when uploads finish" + : environmentConnected + ? "Start task" + : "Queue task") } disabled={!canStart} icon={queuesInsteadOfStarting ? "tray.and.arrow.up" : "arrow.up"} diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts index 8199dee34866..eca98d0563b3 100644 --- a/apps/mobile/src/state/entities.ts +++ b/apps/mobile/src/state/entities.ts @@ -1,4 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; + +import { appAtomRegistry } from "./atom-registry"; import type { EnvironmentProject, EnvironmentThreadShell, @@ -25,6 +27,31 @@ const EMPTY_SERVER_CONFIG_ATOM = Atom.make(null).pipe( Atom.withLabel("mobile-server-config:empty"), ); +/** Resolves when the project event reaches the live client store. */ +export function waitForProject( + ref: ScopedProjectRef, + timeoutMs = 10_000, +): Promise { + const atom = environmentProjects.projectAtom(ref); + const current = appAtomRegistry.get(atom); + if (current !== null) return Promise.resolve(current); + return new Promise((resolve) => { + let unsubscribe: (() => void) | null = null; + const timeout = setTimeout(() => { + unsubscribe?.(); + resolve(null); + }, timeoutMs); + const finish = (project: EnvironmentProject | null) => { + if (project === null) return; + clearTimeout(timeout); + unsubscribe?.(); + resolve(project); + }; + unsubscribe = appAtomRegistry.subscribe(atom, finish); + finish(appAtomRegistry.get(atom)); + }); +} + export function useProjects(): ReadonlyArray { return useAtomValue(environmentProjects.projectsAtom); } diff --git a/apps/mobile/src/state/projectClones.ts b/apps/mobile/src/state/projectClones.ts new file mode 100644 index 000000000000..9a86411e099d --- /dev/null +++ b/apps/mobile/src/state/projectClones.ts @@ -0,0 +1,55 @@ +import { useAtomValue } from "@effect/atom-react"; +import { parseScopedProjectKey, scopedProjectKey } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, ProjectCloneSnapshot, ScopedProjectRef } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { serverEnvironment } from "./server"; +import { sourceControlEnvironment } from "./sourceControl"; + +/** + * `"pending"` while the environment's clone stream has not delivered its + * first list yet: the draft opens right after a clone starts, and a caller + * that treated the gap as "no clone" would enable Start for a moment. + */ +export type ProjectCloneState = ProjectCloneSnapshot | "pending" | null; + +const EMPTY_CLONES: ReadonlyArray = []; +const EMPTY_CLONE_ATOM = Atom.make(null).pipe( + Atom.withLabel("mobile-project-clone:empty"), +); + +/** + * Latest clone list an environment has streamed, `"pending"` until the first + * one, and never subscribed on servers that predate clone tracking. + */ +const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get): ReadonlyArray | "pending" => { + const config = get(serverEnvironment.configValueAtom(environmentId)); + if (config?.environment.capabilities.projectCloneTracking !== true) return EMPTY_CLONES; + const result = get(sourceControlEnvironment.projectClones({ environmentId, input: {} })); + // A failed subscription must not hold Start forever. Treating it as + // "no clone tracked" lets the server's own dispatch guard decide; the + // registry re-establishes the stream on reconnect. + if (result._tag === "Failure") return EMPTY_CLONES; + return Option.getOrElse(AsyncResult.value(result), () => "pending" as const); + }).pipe(Atom.withLabel(`mobile-project-clones:${environmentId}`)), +); + +const projectCloneAtom = Atom.family((key: string) => { + const ref = parseScopedProjectKey(key); + return Atom.make((get): ProjectCloneState => { + if (ref === null) return null; + const clones = get(environmentProjectClonesAtom(ref.environmentId)); + if (clones === "pending") return "pending"; + return clones.find((clone) => clone.projectId === ref.projectId) ?? null; + }).pipe(Atom.withLabel(`mobile-project-clone:${key}`)); +}); + +/** + * The tracked clone for a project: `"pending"` before the stream's first + * list, null once it finished or never started. + */ +export function useProjectClone(ref: ScopedProjectRef | null): ProjectCloneState { + return useAtomValue(ref === null ? EMPTY_CLONE_ATOM : projectCloneAtom(scopedProjectKey(ref))); +} From 9d4bb550a6588a462852a7802bc37a3fd62d0a76 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 14 Sep 2026 14:07:54 -0700 Subject: [PATCH 03/16] fix(mobile): scale inline pills with Dynamic Type (#11792) --- .../modules/t3-markdown-text/ios/T3ContextChip.h | 1 + .../t3-markdown-text/ios/T3MarkdownTextShadowNode.mm | 11 +++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3ContextChip.h b/apps/mobile/modules/t3-markdown-text/ios/T3ContextChip.h index 09ddd9c0fe86..625b7ca97c7b 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3ContextChip.h +++ b/apps/mobile/modules/t3-markdown-text/ios/T3ContextChip.h @@ -102,6 +102,7 @@ static inline NSAttributedString *T3MarkdownTextAttachmentString( static UIFont *T3ContextChipFont(NSDictionary *payload) { CGFloat size = MAX(10, MIN(40, [payload[@"fontSize"] doubleValue])); + size *= payload[@"fontSizeMultiplier"] != nil ? [payload[@"fontSizeMultiplier"] doubleValue] : 1; return [UIFont fontWithName:@"DMSans-Medium" size:size] ?: [UIFont systemFontOfSize:size weight:UIFontWeightMedium]; } diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm index 6afd92eb94b5..e1cc7c2046b2 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm @@ -197,12 +197,19 @@ static void applyAttachments( } if (props.nativeId.rfind("t3-chip:", 0) == 0 && fragmentLength > 0) { const std::string uri = props.nativeId.substr(3); - NSDictionary *payload = T3ContextChipPayload([NSString stringWithUTF8String:uri.c_str()]); + NSMutableDictionary *payload = + [T3ContextChipPayload([NSString stringWithUTF8String:uri.c_str()]) mutableCopy]; + // Chips must scale with the paragraph or smaller Dynamic Type sizes clip them. + // Store the scaled payload so measurement and the rendered bitmap use the same font. + payload[@"fontSizeMultiplier"] = @(fontSizeMultiplier); + NSData *data = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil]; + NSString *scaledUri = [@"chip:" stringByAppendingString: + [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]]; const CGFloat maxWidth = std::isfinite(layoutConstraints.maximumSize.width) ? layoutConstraints.maximumSize.width : 320; const CGSize size = T3ContextChipSize(payload, maxWidth); attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ - utf16Offset, 1, uri, false, + utf16Offset, 1, std::string(scaledUri.UTF8String), false, static_cast(size.width), static_cast(size.height), }); } else if (props.nativeId.rfind(FileAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { From bcc20249cc0a888430f2006d61c11de176d531cd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 14 Sep 2026 15:05:39 -0700 Subject: [PATCH 04/16] chore(deps): bump the Clerk stack to current releases (#11764) Co-authored-by: Claude Fable 5.1 --- ...o@4.2.0.patch => @clerk__expo@4.6.6.patch} | 0 pnpm-lock.yaml | 123 +++++++++--------- pnpm-workspace.yaml | 26 ++-- 3 files changed, 74 insertions(+), 75 deletions(-) rename patches/{@clerk__expo@4.2.0.patch => @clerk__expo@4.6.6.patch} (100%) diff --git a/patches/@clerk__expo@4.2.0.patch b/patches/@clerk__expo@4.6.6.patch similarity index 100% rename from patches/@clerk__expo@4.2.0.patch rename to patches/@clerk__expo@4.6.6.patch diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a37d9bf6fa4..1f1b7b0c221f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,19 +45,19 @@ overrides: '@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-linux-x64-musl': '-' '@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-win32-arm64': '-' '@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-win32-x64': '-' - '@clerk/backend': 3.14.0 - '@clerk/clerk-js': 6.30.1 + '@clerk/backend': 3.17.2 + '@clerk/clerk-js': 6.31.1 '@clerk/clerk-js>@base-org/account': '-' '@clerk/clerk-js>@coinbase/wallet-sdk': '-' '@clerk/clerk-js>@solana/wallet-adapter-base': '-' '@clerk/clerk-js>@solana/wallet-adapter-react': '-' '@clerk/clerk-js>@solana/wallet-standard': '-' '@clerk/clerk-js>@wallet-standard/core': '-' - '@clerk/electron': 0.0.37 + '@clerk/electron': 0.0.42 '@clerk/electron-passkeys': 0.0.3 - '@clerk/expo': 4.2.0 - '@clerk/react': 6.14.7 - '@clerk/shared': 4.30.1 + '@clerk/expo': 4.6.6 + '@clerk/react': 6.15.2 + '@clerk/shared': 4.31.1 '@effect/atom-react': 4.0.0-rc.112 '@effect/platform-node': 4.0.0-rc.112 '@effect/platform-node-shared': 4.0.0-rc.112 @@ -85,7 +85,7 @@ overrides: packageExtensionsChecksum: sha256-k/dT9NFDl5hihRPaoFKeY11hzyutMFs5psfZLFiKJic= patchedDependencies: - '@clerk/expo@4.2.0': 72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1 + '@clerk/expo@4.6.6': 72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1 '@effect/vitest@4.0.0-rc.112': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2 '@ff-labs/fff-node@0.9.4': c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8 @@ -129,8 +129,8 @@ importers: apps/desktop: dependencies: '@clerk/electron': - specifier: 0.0.37 - version: 0.0.37(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 0.0.42 + version: 0.0.42(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/electron-passkeys': specifier: 0.0.3 version: 0.0.3 @@ -230,8 +230,8 @@ importers: apps/mobile: dependencies: '@clerk/expo': - specifier: 4.2.0 - version: 4.2.0(patch_hash=72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1)(dbc31631339ce74330e188d5d7a88158) + specifier: 4.6.6 + version: 4.6.6(patch_hash=72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1)(dbc31631339ce74330e188d5d7a88158) '@effect/atom-react': specifier: 4.0.0-rc.112 version: 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(react@19.2.3)(scheduler@0.27.0) @@ -571,11 +571,11 @@ importers: specifier: ^1.4.1 version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/electron': - specifier: 0.0.37 - version: 0.0.37(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 0.0.42 + version: 0.0.42(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/react': - specifier: 6.14.7 - version: 6.14.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 6.15.2 + version: 6.15.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -752,8 +752,8 @@ importers: infra/relay: dependencies: '@clerk/backend': - specifier: 3.14.0 - version: 3.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 3.17.2 + version: 3.17.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@effect/sql-pg': specifier: 4.0.0-rc.112 version: 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) @@ -1798,12 +1798,12 @@ packages: resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} engines: {node: '>= 20.12.0'} - '@clerk/backend@3.14.0': - resolution: {integrity: sha512-WsphTvDFHDuQilKI7dyVE5qmt7USu8qSrujAq6SqpEHbVFfNBfINDNuzxlF9M2skOTfHFfgiTE8Jjb1egIBGLg==} + '@clerk/backend@3.17.2': + resolution: {integrity: sha512-pQz/+ClFBcL4OijAX3gDcXQYNqr1JbabAqY8szuU8/Dcvuuk3WgUmQDGyIU0tIqmitdd17RLppDmhN088pADew==} engines: {node: '>=20.9.0'} - '@clerk/clerk-js@6.30.1': - resolution: {integrity: sha512-ipsUhTf1uPJ5az4eiLOCM1Qz8z0I4kl54N6RtnL3VbxezwptPEUHx1PlHnHKUjTCV/PHNVq/3vht9w+8VLOoHw==} + '@clerk/clerk-js@6.31.1': + resolution: {integrity: sha512-qSk35+vm0J7ZEf7dcbywBC4VjNtWgZDU4PMipgHS/PIEy9Txyddt0OFJ6U1Gzgvz69zUcUJGttP0I0KpbiSvhQ==} engines: {node: '>=20.9.0'} '@clerk/electron-passkeys-darwin-arm64@0.0.3': @@ -1830,8 +1830,8 @@ packages: resolution: {integrity: sha512-OHhIe88qDL+FxyBalXdXNHAS5eEramr6Rerp+6iNkfkjqT8rx4hHNmfpmjg5/T1/am8QfknbOBZkqoXZlCrjPg==} engines: {node: '>=20.9.0'} - '@clerk/electron@0.0.37': - resolution: {integrity: sha512-NsATM6rMISdL1K3mlKVPqZpDUA4h+uWmbGv8h9PK3zYfnwMgSpDKRX2y341Lt0fhz+eWkZpMU5SQmlZPNqwiww==} + '@clerk/electron@0.0.42': + resolution: {integrity: sha512-8/1EPsSsnYFb3aEbmFPGThKrtBP/uRal1rO6aafnsn9lSHpeje2E/f61GRuvfan3ErG65BQf6g8grVUb507Nvg==} engines: {node: '>=20.9.0'} peerDependencies: '@clerk/electron-passkeys': 0.0.3 @@ -1847,8 +1847,8 @@ packages: react-dom: optional: true - '@clerk/expo@4.2.0': - resolution: {integrity: sha512-S2TYZn1Tltm/gXfe9J1NCuLRCmQX/3gfD0c+i/B1RWGbIJpSZX7KGRzvW70ABaqrK9IUcribLVmMN6unmpuCtQ==} + '@clerk/expo@4.6.6': + resolution: {integrity: sha512-q+cRM0q1lY1SbxTrdxvDPzr/abmjb1OKHEf+m4Y2/cJeG5aQ7mf/mXNEc64V+QnwvxSgrd32arP6SIEUQI3ZAQ==} engines: {node: '>=20.9.0'} peerDependencies: '@clerk/expo-google-signin': '>=0.1.0' @@ -1886,15 +1886,15 @@ packages: react-dom: optional: true - '@clerk/react@6.14.7': - resolution: {integrity: sha512-+d+VqD4nZR3vBn5UU++H96zloHFDe+Ll0sGwMmLXnHtoIs9oMx91GaGXzXo9rU83kl660EfAmgeWcsLMfWnOYg==} + '@clerk/react@6.15.2': + resolution: {integrity: sha512-7oI6Mcfzrlsnrz8JXNyXLgjK1uhO9mTEg2ut0bUfcgXwUSAqY6QG+/UGXUV/hHeeLKOrxrnOe6gQ8/TT4RQvwg==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 - '@clerk/shared@4.30.1': - resolution: {integrity: sha512-Mawatm7CTKZXBqIW8t/z9LfoAKgOHtRRxROpnJ4VIkTdgzj/mmAZhPOPjzUttPXXtulR1uLWisvQXEMNeF/jTQ==} + '@clerk/shared@4.31.1': + resolution: {integrity: sha512-j3cDEZ/j7r5tAv4mmo2JhpFRtL1z0JghjCgvBJjZSR6Q4ZVIlwd2Bv0loM+opKReeN17H30u1/WHEBl7pZuxrw==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 @@ -5236,8 +5236,8 @@ packages: resolution: {integrity: sha512-qhCRSFei0hokQr3xYcQXqxsRD/LKlgHCxHXtKHrQoImp4x2Zu6tUOpUGVH4y2qexIrzSu3aibQBNNfC3Eay6Mg==} engines: {node: '>=18'} - '@tanstack/query-core@5.100.14': - resolution: {integrity: sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==} + '@tanstack/query-core@5.102.8': + resolution: {integrity: sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==} '@tanstack/react-pacer@0.19.4': resolution: {integrity: sha512-coj8ULAuR0qFpjAKD44gTgRuZyjxU6Xu+IX5MwwYvr4e61OtZcJshaExoOBKpCGde0Edb12jDnzzj2Im13Qm9Q==} @@ -8279,9 +8279,8 @@ packages: js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} - js-cookie@3.0.7: - resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==} - engines: {node: '>=20'} + js-cookie@3.0.8: + resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==} js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -12363,21 +12362,21 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@clerk/backend@3.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/backend@3.17.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.30.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.31.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) standardwebhooks: 1.0.0 tslib: 2.8.1 transitivePeerDependencies: - react - react-dom - '@clerk/clerk-js@6.30.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/clerk-js@6.31.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.30.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.31.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 - '@tanstack/query-core': 5.100.14 + '@tanstack/query-core': 5.102.8 '@zxcvbn-ts/core': 3.0.4 '@zxcvbn-ts/language-common': 3.0.4 alien-signals: 2.0.6 @@ -12389,12 +12388,12 @@ snapshots: - react - react-dom - '@clerk/clerk-js@6.30.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/clerk-js@6.31.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.30.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.31.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 - '@tanstack/query-core': 5.100.14 + '@tanstack/query-core': 5.102.8 '@zxcvbn-ts/core': 3.0.4 '@zxcvbn-ts/language-common': 3.0.4 alien-signals: 2.0.6 @@ -12425,11 +12424,11 @@ snapshots: '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.3 '@clerk/electron-passkeys-win32-x64-msvc': 0.0.3 - '@clerk/electron@0.0.37(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/electron@0.0.42(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/clerk-js': 6.30.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@clerk/react': 6.14.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@clerk/shared': 4.30.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/clerk-js': 6.31.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/react': 6.15.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.31.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) electron: 44.1.0 react: 19.2.6 tslib: 2.8.1 @@ -12438,11 +12437,11 @@ snapshots: electron-store: 8.2.0 react-dom: 19.2.6(react@19.2.6) - '@clerk/expo@4.2.0(patch_hash=72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1)(dbc31631339ce74330e188d5d7a88158)': + '@clerk/expo@4.6.6(patch_hash=72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1)(dbc31631339ce74330e188d5d7a88158)': dependencies: - '@clerk/clerk-js': 6.30.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@clerk/react': 6.14.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@clerk/shared': 4.30.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/clerk-js': 6.31.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/react': 6.15.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.31.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@expo/config-plugins': 57.0.9(typescript@7.0.2) base-64: 1.0.0 expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) @@ -12461,36 +12460,36 @@ snapshots: - supports-color - typescript - '@clerk/react@6.14.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/react@6.15.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.30.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.31.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) tslib: 2.8.1 - '@clerk/react@6.14.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/react@6.15.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.30.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.31.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) tslib: 2.8.1 - '@clerk/shared@4.30.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/shared@4.31.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/query-core': 5.100.14 + '@tanstack/query-core': 5.102.8 dequal: 2.0.3 glob-to-regexp: 0.4.1 - js-cookie: 3.0.7 + js-cookie: 3.0.8 optionalDependencies: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@clerk/shared@4.30.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/shared@4.31.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@tanstack/query-core': 5.100.14 + '@tanstack/query-core': 5.102.8 dequal: 2.0.3 glob-to-regexp: 0.4.1 - js-cookie: 3.0.7 + js-cookie: 3.0.8 optionalDependencies: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -15693,7 +15692,7 @@ snapshots: '@tanstack/devtools-event-client': 0.4.3 '@tanstack/store': 0.8.1 - '@tanstack/query-core@5.100.14': {} + '@tanstack/query-core@5.102.8': {} '@tanstack/react-pacer@0.19.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: @@ -19068,7 +19067,7 @@ snapshots: js-base64@3.7.8: {} - js-cookie@3.0.7: {} + js-cookie@3.0.8: {} js-tokens@4.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f121121251d8..d6a933a070a2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -23,13 +23,13 @@ allowBuilds: workerd: false catalog: - "@clerk/backend": 3.14.0 - "@clerk/clerk-js": 6.30.1 - "@clerk/electron": 0.0.37 + "@clerk/backend": 3.17.2 + "@clerk/clerk-js": 6.31.1 + "@clerk/electron": 0.0.42 "@clerk/electron-passkeys": 0.0.3 - "@clerk/expo": 4.2.0 - "@clerk/react": 6.14.7 - "@clerk/shared": 4.30.1 + "@clerk/expo": 4.6.6 + "@clerk/react": 6.15.2 + "@clerk/shared": 4.31.1 "@effect/atom-react": 4.0.0-rc.112 "@effect/openapi-generator": 4.0.0-rc.112 "@effect/platform-node": 4.0.0-rc.112 @@ -55,12 +55,12 @@ catalog: yaml: ^2.9.0 minimumReleaseAgeExclude: - - "@clerk/backend@3.14.0" - - "@clerk/clerk-js@6.30.1" - - "@clerk/electron@0.0.37" - - "@clerk/expo@4.2.0" - - "@clerk/react@6.14.7" - - "@clerk/shared@4.30.1" + - "@clerk/backend@3.17.2" + - "@clerk/clerk-js@6.31.1" + - "@clerk/electron@0.0.42" + - "@clerk/expo@4.6.6" + - "@clerk/react@6.15.2" + - "@clerk/shared@4.31.1" - "@distilled.cloud/aws@0.30.2" - "@distilled.cloud/axiom@0.30.2" - "@distilled.cloud/cloudflare@0.30.2" @@ -157,7 +157,7 @@ packageExtensions: vite: "catalog:" patchedDependencies: - "@clerk/expo@4.2.0": patches/@clerk__expo@4.2.0.patch + "@clerk/expo@4.6.6": patches/@clerk__expo@4.6.6.patch "@effect/vitest@4.0.0-rc.112": patches/@effect__vitest@4.0.0-rc.112.patch "@expo/metro-config@57.0.12": patches/@expo__metro-config@57.0.12.patch "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch From 0f21fcbb6dbabb6d2185a73edf6b865fd64c6019 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 14 Sep 2026 15:05:40 -0700 Subject: [PATCH 05/16] feat(mobile): add a T3 Connect page to the Clerk profile (#11765) Co-authored-by: Claude Fable 5.1 --- apps/mobile/global.css | 25 ++ .../features/cloud/T3ConnectProfilePage.tsx | 273 ++++++++++++++++++ .../src/features/cloud/managedRelayState.ts | 22 ++ .../settings/SettingsAuthRouteScreen.tsx | 19 +- patches/@clerk__expo@4.6.6.patch | 37 ++- pnpm-lock.yaml | 10 +- 6 files changed, 374 insertions(+), 12 deletions(-) create mode 100644 apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx diff --git a/apps/mobile/global.css b/apps/mobile/global.css index 2f686e3a1fa9..7a401fd7289d 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -233,6 +233,31 @@ } } +/* ─── Clerk native profile ──────────────────────────────────────────── */ +/* Fixed palette for custom pages inside Clerk's native user profile. Mirrors + clerk-theme.json, which themes the SDK's own screens, so ours match them. + Kept out of the runtime palette above on purpose: custom themes must not + restyle Clerk's chrome. Keep in sync with clerk-theme.json. */ +@layer theme { + :root { + @variant light { + --color-clerk-page: #f2f2f7; + --color-clerk-foreground: #262626; + --color-clerk-foreground-muted: #737373; + --color-clerk-border: rgba(229, 229, 234, 0.06); + --color-clerk-danger: #dc2626; + } + + @variant dark { + --color-clerk-page: #0e0e0e; + --color-clerk-foreground: #f5f5f5; + --color-clerk-foreground-muted: #a3a3a3; + --color-clerk-border: rgba(42, 42, 42, 0.06); + --color-clerk-danger: #fca5a5; + } + } +} + /* ─── Typography ────────────────────────────────────────────────────── */ @theme { /* Keep these native family names aligned with app.config.ts. */ diff --git a/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx b/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx new file mode 100644 index 000000000000..702aa5f5e8ef --- /dev/null +++ b/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx @@ -0,0 +1,273 @@ +import { findErrorTraceId } from "@t3tools/client-runtime/errors"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { MenuAction } from "@react-native-menu/menu"; +import type { EnvironmentId } from "@t3tools/contracts"; +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; +import { type ReactNode, useRef, useState } from "react"; +import { + ActivityIndicator, + Alert, + Pressable, + RefreshControl, + ScrollView, + Text, + View, +} from "react-native"; + +import { SymbolView } from "../../components/AppSymbol"; +import { showConfirmDialog } from "../../components/ConfirmDialogHost"; +import { ControlPillMenu } from "../../components/ControlPill"; +import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { + deregisterManagedRelayEnvironmentCommand, + useManagedRelayEnvironments, +} from "./managedRelayState"; + +const linkedAtFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }); + +function linkedAtLabel(value: string): string { + const linkedAt = new Date(value); + return Number.isNaN(linkedAt.getTime()) + ? "Link date unavailable" + : `Linked ${linkedAtFormatter.format(linkedAt)}`; +} + +function endpointLabel(environment: RelayClientEnvironmentRecord): string { + return environment.endpoint.providerKind === "cloudflare_tunnel" + ? "Managed tunnel" + : "Activity publishing only"; +} + +function confirmDeregister(environment: RelayClientEnvironmentRecord, onConfirm: () => void) { + const title = "Deregister server?"; + const message = `“${environment.label}” will be removed from this account. T3 Connect access will be revoked, any managed tunnel will be removed, and a host space will become available. Local connections on your devices are not changed.`; + if (process.env.EXPO_OS === "ios") { + Alert.alert(title, message, [ + { text: "Cancel", style: "cancel" }, + { text: "Deregister", style: "destructive", onPress: onConfirm }, + ]); + return; + } + showConfirmDialog({ title, message, confirmText: "Deregister", destructive: true, onConfirm }); +} + +/** + * The "T3 Connect" custom page inside Clerk's native user profile: every + * environment registered to the signed-in account, with account-level + * deregistration. Mirrors the web UserButton page; connections on this device + * are managed in Settings instead. + */ +export function T3ConnectProfilePage() { + const environmentsState = useManagedRelayEnvironments(); + const deregisterEnvironment = useAtomCommand(deregisterManagedRelayEnvironmentCommand, { + reportFailure: false, + }); + const [deregisteringEnvironmentId, setDeregisteringEnvironmentId] = + useState(null); + const mutationPendingRef = useRef(false); + // Deregistered rows stay in the cached list until the refresh lands, so hide + // them by the linkedAt they had. A re-link produces a new linkedAt and shows again. + const [removedEnvironments, setRemovedEnvironments] = useState<{ + readonly accountId: string | null; + readonly linkedAtById: ReadonlyMap; + }>({ accountId: null, linkedAtById: new Map() }); + + const handleDeregister = async (environment: RelayClientEnvironmentRecord) => { + const accountId = environmentsState.accountId; + if (!accountId || mutationPendingRef.current) return; + + mutationPendingRef.current = true; + setDeregisteringEnvironmentId(environment.environmentId); + const result = await deregisterEnvironment({ + accountId, + environmentId: environment.environmentId, + }); + mutationPendingRef.current = false; + setDeregisteringEnvironmentId(null); + + if (result._tag === "Success") { + setRemovedEnvironments((current) => { + const linkedAtById = new Map(current.accountId === accountId ? current.linkedAtById : []); + linkedAtById.set(environment.environmentId, environment.linkedAt); + return { accountId, linkedAtById }; + }); + environmentsState.refresh(); + return; + } + if (isAtomCommandInterrupted(result)) return; + + const cause = squashAtomCommandFailure(result); + const message = cause instanceof Error ? cause.message : "Could not deregister the server."; + const traceId = findErrorTraceId(cause); + console.error("[t3-connect] Could not deregister environment", { + environmentId: environment.environmentId, + message, + traceId, + cause, + }); + Alert.alert( + "Could not deregister server", + traceId ? `${message}\n\nTrace ID: ${traceId}` : message, + traceId + ? [ + { + text: "Copy trace ID", + onPress: () => copyTextWithHaptic(traceId, { target: "connection-trace-id" }), + }, + { text: "OK", style: "cancel" }, + ] + : undefined, + ); + }; + + const removedEnvironmentLinkedAt = + removedEnvironments.accountId === environmentsState.accountId + ? removedEnvironments.linkedAtById + : new Map(); + const environments = (environmentsState.data ?? []).filter( + (environment) => + removedEnvironmentLinkedAt.get(environment.environmentId) !== environment.linkedAt, + ); + const isInitialLoad = + !environmentsState.accountId || (environmentsState.data === null && !environmentsState.error); + const errorTraceId = environmentsState.errorTraceId; + + return ( + + } + > + Registered servers + + {environmentsState.error ? ( + <> + + {errorTraceId ? ( + { + copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" }); + }} + /> + ) : null} + + ) : isInitialLoad ? ( + + + Loading environments + + ) : environments.length > 0 ? ( + environments.map((environment) => ( + + ) : ( + + confirmDeregister(environment, () => void handleDeregister(environment)) + } + > + + + + + + + ) + } + /> + )) + ) : ( + + )} + + + Connections on this device are managed in Settings. + + + ); +} + +const ENVIRONMENT_MENU_ACTIONS = [ + { id: "deregister", title: "Deregister", image: "trash", attributes: { destructive: true } }, +] satisfies MenuAction[]; + +// Layout primitives that mirror clerk-ios ClerkKitUI's profile rows so a custom +// page reads as one of Clerk's own screens. System font on purpose: Clerk's +// native views do not use the app's DM Sans. + +function ClerkSectionHeader(props: { readonly children: string }) { + return ( + + {props.children} + + ); +} + +function ClerkRow(props: { + readonly title: string; + readonly subtitle: string; + readonly accessory?: ReactNode; +}) { + return ( + + + + {props.title} + + + {props.subtitle} + + + {props.accessory} + + ); +} + +function ClerkButtonRow(props: { readonly label: string; readonly onPress: () => void }) { + return ( + + {props.label} + + ); +} diff --git a/apps/mobile/src/features/cloud/managedRelayState.ts b/apps/mobile/src/features/cloud/managedRelayState.ts index 8c41d74841e7..375bda715e12 100644 --- a/apps/mobile/src/features/cloud/managedRelayState.ts +++ b/apps/mobile/src/features/cloud/managedRelayState.ts @@ -1,9 +1,15 @@ import { useAtomValue } from "@effect/atom-react"; import { createManagedRelayQueryManager, + deregisterManagedRelayEnvironment, managedRelaySessionAtom, readManagedRelaySnapshotState, } from "@t3tools/client-runtime/relay"; +import { + createAtomCommandScheduler, + createRuntimeCommand, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect } from "react"; @@ -19,6 +25,22 @@ export const managedRelayQueryManager = createManagedRelayQueryManager(managedRe cloudDebugLog(`query:${event.operation}:${event.stage}:${event.phase}`, { ...event }), }); +const managedRelayMutationScheduler = createAtomCommandScheduler(); + +export const deregisterManagedRelayEnvironmentCommand = createRuntimeCommand( + managedRelayAtomRuntime, + { + label: "mobile:managed-relay:deregister-environment", + scheduler: managedRelayMutationScheduler, + concurrency: { + mode: "serial", + key: (input: { readonly accountId: string; readonly environmentId: EnvironmentId }) => + input.accountId, + }, + execute: (input, registry) => deregisterManagedRelayEnvironment(registry, input), + }, +); + const EMPTY_ENVIRONMENTS_ATOM = Atom.make( AsyncResult.success>([]), ).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:mobile:environments:null")); diff --git a/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx index 96d612f8c689..e6e23fd78be9 100644 --- a/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx @@ -1,10 +1,21 @@ import { useAuth } from "@clerk/expo"; -import { AuthView, UserProfileView } from "@clerk/expo/native"; +import { AuthView, type UserProfileCustomPage, UserProfileView } from "@clerk/expo/native"; import { StackActions, useNavigation } from "@react-navigation/native"; import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { View } from "react-native"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; +import { T3ConnectProfilePage } from "../cloud/T3ConnectProfilePage"; + +// Custom rows in Clerk's native profile. Mirrors the web UserButton pages. +const USER_PROFILE_CUSTOM_PAGES = [ + { + path: "t3-connect", + label: "T3 Connect", + icon: "globe", + content: , + }, +] satisfies UserProfileCustomPage[]; export function SettingsAuthRouteScreen() { const navigation = useNavigation(); @@ -40,7 +51,11 @@ function ConfiguredSettingsAuthRouteScreen() { {isLoaded ? ( hasBeenSignedIn.current ? ( - + ) : ( ) diff --git a/patches/@clerk__expo@4.6.6.patch b/patches/@clerk__expo@4.6.6.patch index 2d4a9287c114..e9bd5bfe07df 100644 --- a/patches/@clerk__expo@4.6.6.patch +++ b/patches/@clerk__expo@4.6.6.patch @@ -1,8 +1,8 @@ diff --git a/ios/ClerkAuthNativeView.swift b/ios/ClerkAuthNativeView.swift -index e76a8be1b1c8faa64ec6dfa83764764094133aff..17b36ad1319765e2b6db0551d32e07d7140e482f 100644 +index 5f8d2ba66d5b232890af75c22b173c86d2ef546b..b170304cbf2fbaae8032db8dd6f89b6a59c6e3fe 100644 --- a/ios/ClerkAuthNativeView.swift +++ b/ios/ClerkAuthNativeView.swift -@@ -108,7 +108,12 @@ public class ClerkAuthNativeView: ClerkNativeViewHost { +@@ -111,7 +111,12 @@ public class ClerkAuthNativeView: ClerkNativeViewHost { override func makeHostedController() -> UIViewController? { let hostBackAction: (() -> Void)? = currentHostBackButton @@ -16,8 +16,35 @@ index e76a8be1b1c8faa64ec6dfa83764764094133aff..17b36ad1319765e2b6db0551d32e07d7 : nil return ClerkNativeBridge.shared.makeAuthViewController( +diff --git a/ios/ClerkNativeBridge.swift b/ios/ClerkNativeBridge.swift +index c90ad2ef1162cc2ac025c8b0b4aa135e8913dc21..ce8cb68764b1a3d3537672fc83e4e3077fceb97a 100644 +--- a/ios/ClerkNativeBridge.swift ++++ b/ios/ClerkNativeBridge.swift +@@ -1470,6 +1470,8 @@ private struct ClerkReactEmbeddedUserProfileCustomPage: View { + } + + private struct ClerkReactUserProfileCustomPageContent: View { ++ @Environment(\.clerkTheme) private var theme ++ + let path: String + let rows: [ClerkUserProfileCustomRowConfig] + let state: ClerkUserProfileCustomPageState +@@ -1483,8 +1485,13 @@ private struct ClerkReactUserProfileCustomPageContent: View { + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) ++ // Match Clerk's own profile pages so the navigation bar and page share ++ // the theme's page background instead of a bare hosting container. ++ .background(theme.colors.muted) + .navigationTitle(userProfileCustomPageLabel(for: path, rows: rows)) + .navigationBarTitleDisplayMode(.inline) ++ .preGlassSolidNavBar() ++ .background(theme.colors.background) + } + } + diff --git a/ios/ClerkNativeViewHost.swift b/ios/ClerkNativeViewHost.swift -index 0d91f0e749f121595c17bc803663df6ac90e4163..8f8a89df97a54168b8b45d0e9c197ca3953b7028 100644 +index 4e6540aceeaaab56ddf327bb3f16e120438f066f..cc16c94ca7a6eb91a97e76d5de02dc586f02a766 100644 --- a/ios/ClerkNativeViewHost.swift +++ b/ios/ClerkNativeViewHost.swift @@ -5,6 +5,7 @@ public class ClerkNativeViewHost: ExpoView { @@ -60,10 +87,10 @@ index 0d91f0e749f121595c17bc803663df6ac90e4163..8f8a89df97a54168b8b45d0e9c197ca3 guard configuredObserver == nil else { return } diff --git a/ios/ClerkUserProfileNativeView.swift b/ios/ClerkUserProfileNativeView.swift -index 12d6248b1dc4b4779b252c9954c2f89145907310..d838283b7adcf49fec2a63bff53251b9bee31bf8 100644 +index f2ca604d41f50d05de8ac334879126cdc0414f84..46f406d5361b9a560a7c4ce855e344cbddd1d0b6 100644 --- a/ios/ClerkUserProfileNativeView.swift +++ b/ios/ClerkUserProfileNativeView.swift -@@ -44,7 +44,12 @@ public class ClerkUserProfileNativeView: ClerkNativeViewHost { +@@ -47,7 +47,12 @@ public class ClerkUserProfileNativeView: ClerkUserProfileCustomPageHost { override func makeHostedController() -> UIViewController? { let hostBackAction: (() -> Void)? = currentHostBackButton diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f1b7b0c221f..47f1ae85ea2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,7 +85,7 @@ overrides: packageExtensionsChecksum: sha256-k/dT9NFDl5hihRPaoFKeY11hzyutMFs5psfZLFiKJic= patchedDependencies: - '@clerk/expo@4.6.6': 72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1 + '@clerk/expo@4.6.6': a82bb41039ee88a290a87d4ab58be7be4c49fa338603b17ea646e1ec140a1cfb '@effect/vitest@4.0.0-rc.112': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2 '@ff-labs/fff-node@0.9.4': c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8 @@ -231,7 +231,7 @@ importers: dependencies: '@clerk/expo': specifier: 4.6.6 - version: 4.6.6(patch_hash=72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1)(dbc31631339ce74330e188d5d7a88158) + version: 4.6.6(patch_hash=a82bb41039ee88a290a87d4ab58be7be4c49fa338603b17ea646e1ec140a1cfb)(dbc31631339ce74330e188d5d7a88158) '@effect/atom-react': specifier: 4.0.0-rc.112 version: 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(react@19.2.3)(scheduler@0.27.0) @@ -774,7 +774,7 @@ importers: version: link:../../packages/shared alchemy: specifier: 2.0.0-beta.76 - version: 2.0.0-beta.76(b3825b36417e56a486ccb5f22a7f3d7e) + version: 2.0.0-beta.76(197b68e0d20fdf20cf352009eca17c9f) drizzle-orm: specifier: 1.0.0-rc.5-ab785fc version: 1.0.0-rc.5-ab785fc(8ab70e2706da13c78d64d8a92fef1884) @@ -12437,7 +12437,7 @@ snapshots: electron-store: 8.2.0 react-dom: 19.2.6(react@19.2.6) - '@clerk/expo@4.6.6(patch_hash=72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1)(dbc31631339ce74330e188d5d7a88158)': + '@clerk/expo@4.6.6(patch_hash=a82bb41039ee88a290a87d4ab58be7be4c49fa338603b17ea646e1ec140a1cfb)(dbc31631339ce74330e188d5d7a88158)': dependencies: '@clerk/clerk-js': 6.31.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@clerk/react': 6.15.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -16445,7 +16445,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.76(b3825b36417e56a486ccb5f22a7f3d7e): + alchemy@2.0.0-beta.76(197b68e0d20fdf20cf352009eca17c9f): dependencies: '@alchemy.run/cloudflare-runtime': 2.0.0-beta.76(@distilled.cloud/cloudflare@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)))(@effect/platform-bun@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6))(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(rolldown@1.2.5)(typescript@7.0.2) '@alchemy.run/floci': 2.0.0-beta.76(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) From dc0869b605f60c2c8afa924035a81c8f98b29385 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 14 Sep 2026 15:09:28 -0700 Subject: [PATCH 06/16] feat(server): use Clerk's device authorization grant for headless connect login (#11794) Co-authored-by: Claude Fable 5.1 --- apps/server/src/cli/connect.ts | 35 +-- apps/server/src/cloud/CliTokenManager.test.ts | 295 +++++++++++------- apps/server/src/cloud/CliTokenManager.ts | 218 +++++++++---- apps/server/src/cloud/publicConfig.test.ts | 3 +- apps/server/src/cloud/publicConfig.ts | 7 +- apps/web/src/cloud/connectCliAuth.test.ts | 58 +--- apps/web/src/cloud/connectCliAuth.ts | 61 +--- .../cloud/ConnectCliAuthSurface.tsx | 108 +------ apps/web/src/hostedPairing.ts | 2 +- apps/web/src/routeTree.gen.ts | 21 -- apps/web/src/routes/__root.tsx | 2 +- apps/web/src/routes/connect_.callback.tsx | 13 - docs/internals/t3-connect.md | 19 +- docs/operations/connect-setup.md | 11 +- docs/user/remote-access.md | 5 +- packages/shared/src/connectAuth.test.ts | 57 +--- packages/shared/src/connectAuth.ts | 92 +----- 17 files changed, 445 insertions(+), 562 deletions(-) delete mode 100644 apps/web/src/routes/connect_.callback.tsx diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index b7c78e5ea68b..e05913eb94ec 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -20,7 +20,6 @@ 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 * as Terminal from "effect/Terminal"; import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; import { FetchHttpClient, @@ -64,13 +63,14 @@ const jsonFlag = Flag.boolean("json").pipe( 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.withDescription("Authorize without a local browser using the OAuth device flow."), 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. + * OAuth callback, so the device authorization grant is the only flow that + * can work. */ export const headlessSessionConfig = Config.all({ sshConnection: Config.string("SSH_CONNECTION").pipe(Config.option), @@ -79,20 +79,21 @@ export const headlessSessionConfig = Config.all({ 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 })); - }, -); +const showDeviceAuthorizationPrompt = (prompt: CliTokenManager.DeviceAuthorizationPrompt) => + Console.log(formatDeviceAuthorizationPrompt(prompt)); -function formatHeadlessAuthorizationPrompt(authorizeUrl: string): string { +function formatDeviceAuthorizationPrompt( + prompt: CliTokenManager.DeviceAuthorizationPrompt, +): string { + const minutes = Math.max(1, Math.round(Duration.toMinutes(prompt.expiresIn))); return [ "Headless authorization", "Open this URL on a device with a browser:", - ` ${authorizeUrl}`, + ` ${prompt.verificationUriComplete ?? prompt.verificationUri}`, + "", + `Confirm this code when asked: ${prompt.userCode}`, "", - "After signing in, return here and enter the code shown in your browser.", + `Waiting for approval (expires in ${minutes} min). Press Ctrl+C to cancel.`, ].join("\n"); } @@ -110,7 +111,7 @@ const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: { 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. + // fall through to a fresh device authorization, not dead-end the command. const existing = yield* tokens.getExisting.pipe( Effect.catchTag("CloudCliCredentialRefreshError", () => Console.log( @@ -121,13 +122,11 @@ const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: { if (Option.isSome(existing)) { return existing.value.identity ?? null; } - const { token, identity } = yield* CliTokenManager.outOfBandOAuthLogin( - promptForOutOfBandOAuthCode, + const { token, identity } = yield* CliTokenManager.deviceAuthorizationLogin( + showDeviceAuthorizationPrompt, ).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) + isCloudCliTokenManagerError(cause) ? cause : new CliTokenManager.CloudCliAuthorizationError({ cause }), ), diff --git a/apps/server/src/cloud/CliTokenManager.test.ts b/apps/server/src/cloud/CliTokenManager.test.ts index 25ae3443daeb..37f645cbd4ff 100644 --- a/apps/server/src/cloud/CliTokenManager.test.ts +++ b/apps/server/src/cloud/CliTokenManager.test.ts @@ -1,21 +1,21 @@ -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 Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; +import * as Fiber from "effect/Fiber"; 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 TestClock from "effect/testing/TestClock"; 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 = { @@ -54,44 +54,12 @@ const TestTokenResponseJson = Schema.fromJsonString( ); 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.TaggedError()("PromptRejectedError", { - message: Schema.String, -}) {} - const makeTestTerminal = (queue: Queue.Queue) => Terminal.make({ columns: Effect.succeed(80), @@ -144,106 +112,215 @@ it.effect("finishes normally when the browser callback wins", () => }), ); -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 })), - ); +interface DeviceFlowServer { + readonly requests: Array; + /** Token endpoint replies, consumed in order; the last one repeats. */ + readonly tokenReplies: Array<{ readonly status: number; readonly body: string }>; +} + +const DEVICE_AUTHORIZATION_BODY = JSON.stringify({ + device_code: "device-code-1", + user_code: "BCDF-GHJK", + verification_uri: "https://accounts.example.test/device", + verification_uri_complete: "https://accounts.example.test/device?user_code=BCDF-GHJK", + expires_in: 600, + interval: 5, +}); + +const oauthError = (error: string) => ({ status: 400, body: JSON.stringify({ error }) }); +const tokenGranted = { + status: 200, + body: encodeTestTokenResponse({ + access_token: "access-token-1", + refresh_token: "refresh-token-1", + id_token: idTokenWithEmail, + expires_in: 3600, + token_type: "bearer", + }), +}; + +const makeDeviceFlowLayer = (server: DeviceFlowServer) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + const body = + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : ""; + server.requests.push({ url: request.url, params: new URLSearchParams(body) }); + const reply = request.url.endsWith("/oauth/device_authorization") + ? { status: 200, body: DEVICE_AUTHORIZATION_BODY } + : ((server.tokenReplies.length > 1 + ? server.tokenReplies.shift() + : server.tokenReplies[0]) ?? oauthError("invalid_grant")); + return HttpClientResponse.fromWeb( + request, + new Response(reply.body, { + status: reply.status, + headers: { "content-type": "application/json" }, }), - ).pipe(Effect.provide(makeTokenEndpointLayer(requests)), provideTestEnv); + ); + }), + ), + ); + +const tokenRequests = (requests: ReadonlyArray) => + requests.filter((request) => request.url.endsWith("/oauth/token")); - 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}$/); +it.layer(NodeServices.layer)("CliTokenManager.deviceAuthorizationLogin", (it) => { + it.effect("requests a device code, shows it, and polls until Clerk grants the token", () => + Effect.gen(function* () { + const server: DeviceFlowServer = { + requests: [], + tokenReplies: [oauthError("authorization_pending"), tokenGranted], + }; + const prompts: Array = []; + const fiber = yield* CliTokenManager.deviceAuthorizationLogin((prompt) => + Effect.sync(() => { + prompts.push(prompt); + }), + ).pipe(Effect.provide(makeDeviceFlowLayer(server)), provideTestEnv, Effect.forkChild); + + yield* TestClock.adjust(Duration.seconds(10)); + const { token, identity } = yield* Fiber.join(fiber); + + assert.deepEqual(prompts, [ + { + verificationUri: "https://accounts.example.test/device", + verificationUriComplete: "https://accounts.example.test/device?user_code=BCDF-GHJK", + userCode: "BCDF-GHJK", + expiresIn: Duration.seconds(600), + }, + ]); 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", + const authorization = server.requests[0]!; + assert.equal(authorization.url, "https://clerk.example.test/oauth/device_authorization"); + assert.equal(authorization.params.get("client_id"), "oauth_client_test"); + assert.equal(authorization.params.get("scope"), "openid profile email offline_access"); + + const polls = tokenRequests(server.requests); + assert.lengthOf(polls, 2); + for (const poll of polls) { + assert.equal(poll.url, "https://clerk.example.test/oauth/token"); + assert.equal(poll.params.get("grant_type"), "urn:ietf:params:oauth:grant-type:device_code"); + assert.equal(poll.params.get("device_code"), "device-code-1"); + assert.equal(poll.params.get("client_id"), "oauth_client_test"); + } + }), + ); + + it.effect("waits the advertised interval between polls and backs off on slow_down", () => + Effect.gen(function* () { + const server: DeviceFlowServer = { + requests: [], + tokenReplies: [oauthError("slow_down"), oauthError("authorization_pending")], + }; + + const fiber = yield* CliTokenManager.deviceAuthorizationLogin(() => Effect.void).pipe( + Effect.provide(makeDeviceFlowLayer(server)), + provideTestEnv, + Effect.forkChild, ); - 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); + + yield* TestClock.adjust(Duration.seconds(4)); + assert.lengthOf(tokenRequests(server.requests), 0); + yield* TestClock.adjust(Duration.seconds(1)); + assert.lengthOf(tokenRequests(server.requests), 1); + // slow_down widens the 5s interval to 10s. + yield* TestClock.adjust(Duration.seconds(9)); + assert.lengthOf(tokenRequests(server.requests), 1); + yield* TestClock.adjust(Duration.seconds(1)); + assert.lengthOf(tokenRequests(server.requests), 2); + yield* Fiber.interrupt(fiber); }), ); - it.effect("rejects out-of-band codes whose state does not match the request", () => + it.effect("backs off after a transient upstream failure and keeps polling", () => 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); + const server: DeviceFlowServer = { + requests: [], + tokenReplies: [{ status: 503, body: "upstream unavailable" }, tokenGranted], + }; + + const fiber = yield* CliTokenManager.deviceAuthorizationLogin(() => Effect.void).pipe( + Effect.provide(makeDeviceFlowLayer(server)), + provideTestEnv, + Effect.forkChild, + ); + + yield* TestClock.adjust(Duration.seconds(5)); + assert.lengthOf(tokenRequests(server.requests), 1); + // The 5xx widens the 5s interval to 10s before the retry. + yield* TestClock.adjust(Duration.seconds(9)); + assert.lengthOf(tokenRequests(server.requests), 1); + yield* TestClock.adjust(Duration.seconds(1)); + const { token } = yield* Fiber.join(fiber); + assert.lengthOf(tokenRequests(server.requests), 2); + assert.equal(token.accessToken, "access-token-1"); }), ); - it.effect("ignores an id_token whose claims are not valid JSON", () => + it.effect("fails with a denied error when the user rejects the request", () => 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 })), + const server: DeviceFlowServer = { + requests: [], + tokenReplies: [oauthError("access_denied")], + }; + + const fiber = yield* CliTokenManager.deviceAuthorizationLogin(() => Effect.void).pipe( + Effect.provide(makeDeviceFlowLayer(server)), + provideTestEnv, + Effect.flip, + Effect.forkChild, + ); + yield* TestClock.adjust(Duration.seconds(5)); + const result = yield* Fiber.join(fiber); + + assert.instanceOf(result, CliTokenManager.CloudCliAuthorizationDeniedError); + assert.lengthOf(tokenRequests(server.requests), 1); + }), + ); + + it.effect("times out once the device code lifetime elapses", () => + Effect.gen(function* () { + const server: DeviceFlowServer = { + requests: [], + tokenReplies: [oauthError("authorization_pending")], + }; + + const fiber = yield* CliTokenManager.deviceAuthorizationLogin(() => Effect.void).pipe( + Effect.provide(makeDeviceFlowLayer(server)), provideTestEnv, + Effect.flip, + Effect.forkChild, ); + yield* TestClock.adjust(Duration.seconds(600)); + const result = yield* Fiber.join(fiber); - assert.isNull(identity); - assert.lengthOf(requests, 1); + assert.instanceOf(result, CliTokenManager.CloudCliAuthorizationTimeoutError); }), ); - it.effect("fails without touching the token endpoint when the prompt returns garbage", () => + it.effect("surfaces other OAuth errors as authorization failures", () => Effect.gen(function* () { - const requests: Array = []; + const server: DeviceFlowServer = { + requests: [], + tokenReplies: [oauthError("invalid_client")], + }; - const result = yield* CliTokenManager.outOfBandOAuthLogin(() => - Effect.succeed("not-a-connect-code"), - ).pipe(Effect.provide(makeTokenEndpointLayer(requests)), provideTestEnv, Effect.flip); + const fiber = yield* CliTokenManager.deviceAuthorizationLogin(() => Effect.void).pipe( + Effect.provide(makeDeviceFlowLayer(server)), + provideTestEnv, + Effect.flip, + Effect.forkChild, + ); + yield* TestClock.adjust(Duration.seconds(5)); + const result = yield* Fiber.join(fiber); - 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 8f7ac7bfc8d4..4172578d45f3 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -18,17 +18,14 @@ 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 HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; 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, - checkConnectAuthCode, - connectCallbackUrl, -} from "@t3tools/shared/connectAuth"; +import { buildConnectAuthorizeRequestUrl } from "@t3tools/shared/connectAuth"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ExternalLauncher from "../process/externalLauncher.ts"; @@ -42,6 +39,11 @@ 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 DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; +// RFC 8628 defaults, used only when Clerk omits the field. +const DEVICE_AUTHORIZATION_DEFAULT_INTERVAL = Duration.seconds(5); +// RFC 8628 §3.5: a slow_down response means "add 5 seconds to the interval". +const DEVICE_AUTHORIZATION_SLOW_DOWN_INCREMENT = Duration.seconds(5); const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`; function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string { @@ -136,6 +138,20 @@ const OAuthTokenResponse = Schema.Struct({ token_type: Schema.String, }); +const OAuthErrorResponse = Schema.Struct({ + error: Schema.String, + error_description: Schema.optional(Schema.String), +}); + +const DeviceAuthorizationResponse = Schema.Struct({ + device_code: Schema.String, + user_code: Schema.String, + verification_uri: Schema.String, + verification_uri_complete: Schema.optional(Schema.String), + expires_in: Schema.Number, + interval: Schema.optional(Schema.Number), +}); + const OidcIdentityClaimsJson = Schema.fromJsonString( Schema.Struct({ email: Schema.optional(Schema.String), @@ -209,12 +225,22 @@ export class CloudCliAuthorizationTimeoutError extends Schema.TaggedError()( + "CloudCliAuthorizationDeniedError", + {}, +) { + override get message(): string { + return "T3 Connect authorization was denied in the browser."; + } +} + export const CloudCliTokenManagerError = Schema.Union([ CloudCliCredentialRemovalError, CloudCliCredentialRefreshError, CloudCliCredentialReadError, CloudCliAuthorizationError, CloudCliAuthorizationTimeoutError, + CloudCliAuthorizationDeniedError, ]); export type CloudCliTokenManagerError = typeof CloudCliTokenManagerError.Type; @@ -241,29 +267,36 @@ function bytesToString(value: Uint8Array): string { return new TextDecoder().decode(value); } -const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* ( - metadata: Pick, +const readTokenResponse = Effect.fn("cloud.cli_token.read_token_response")(function* ( + response: HttpClientResponse.HttpClientResponse, 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 body = yield* HttpClientResponse.schemaBodyJson(OAuthTokenResponse)(response); const now = yield* Clock.currentTimeMillis; - const identity = idTokenIdentity(response.id_token); + const identity = idTokenIdentity(body.id_token); return { token: { - accessToken: response.access_token, - refreshToken: response.refresh_token ?? params.refresh_token ?? "", - expiresAtEpochMs: now + response.expires_in * 1_000, + accessToken: body.access_token, + refreshToken: body.refresh_token ?? params.refresh_token ?? "", + expiresAtEpochMs: now + body.expires_in * 1_000, ...(identity === null ? {} : { identity }), } satisfies PersistedToken, identity, }; }); +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, + ); + return yield* readTokenResponse(response, params); +}); + const makePkceRequest = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32)); @@ -274,61 +307,118 @@ const makePkceRequest = Effect.gen(function* () { return { verifier, challenge, state }; }); -export interface OutOfBandOAuthPromptInput { - readonly authorizeUrl: string; - readonly validate: (value: string) => Effect.Effect; +export interface DeviceAuthorizationPrompt { + readonly verificationUri: string; + readonly verificationUriComplete: string | undefined; + readonly userCode: string; + readonly expiresIn: Duration.Duration; } +const isTransportError = (error: unknown) => + HttpClientError.isHttpClientError(error) && error.reason._tag === "TransportError"; + /** - * 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. + * Polls Clerk's token endpoint until the user approves or denies the device + * request in the browser (RFC 8628 §3.4/3.5). `authorization_pending` keeps + * waiting, while `slow_down` and transient failures widen the interval before + * the next tick; the caller bounds the whole loop with the device code's + * lifetime. */ -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), +const pollDeviceToken = Effect.fn("cloud.cli_token.poll_device_token")(function* ( + metadata: Pick, + deviceCode: string, + initialInterval: Duration.Duration, +) { + const httpClient = yield* HttpClient.HttpClient; + const params = { + grant_type: DEVICE_CODE_GRANT_TYPE, + device_code: deviceCode, client_id: metadata.clientId, - code_verifier: verifier, - }); + }; + let interval = initialInterval; + while (true) { + yield* Effect.sleep(interval); + const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe( + HttpClientRequest.bodyUrlParams(params), + httpClient.execute, + Effect.map(Option.some), + Effect.catchIf(isTransportError, () => Effect.succeedNone), + ); + // Transport failures and upstream 5xx are transient while the device code + // is still valid. RFC 8628 §3.5 asks clients to back off before retrying, + // so widen the interval like slow_down; drain the body so the connection + // returns to the pool for the next poll. + if (Option.isNone(response) || response.value.status >= 500) { + if (Option.isSome(response)) yield* Effect.ignore(response.value.text); + interval = Duration.sum(interval, DEVICE_AUTHORIZATION_SLOW_DOWN_INCREMENT); + continue; + } + if (response.value.status >= 200 && response.value.status < 300) { + return yield* readTokenResponse(response.value, params); + } + const failure = yield* HttpClientResponse.schemaBodyJson(OAuthErrorResponse)(response.value); + switch (failure.error) { + case "authorization_pending": + continue; + case "slow_down": + interval = Duration.sum(interval, DEVICE_AUTHORIZATION_SLOW_DOWN_INCREMENT); + continue; + case "expired_token": + return yield* new CloudCliAuthorizationTimeoutError({ cause: failure }); + case "access_denied": + return yield* new CloudCliAuthorizationDeniedError(); + default: + return yield* new CloudCliAuthorizationError({ + cause: failure.error_description ?? failure.error, + }); + } + } }); +/** + * OAuth device authorization grant for machines without a local browser + * (SSH). Clerk issues a short user code; the user approves it on Clerk's + * hosted device page from any browser while this process polls the token + * endpoint. Nothing is typed into the terminal and no redirect URI is + * involved, so the hosted app plays no part in this flow. + */ +export const deviceAuthorizationLogin = Effect.fn("cloud.cli_token.device_authorization_login")( + function* (showPrompt: (prompt: DeviceAuthorizationPrompt) => Effect.Effect) { + const metadata = yield* cloudCliOAuthConfig; + const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); + const authorization = yield* HttpClientRequest.post(metadata.deviceAuthorizationEndpoint).pipe( + HttpClientRequest.bodyUrlParams({ + client_id: metadata.clientId, + scope: metadata.scopes.join(" "), + }), + httpClient.execute, + Effect.flatMap(HttpClientResponse.schemaBodyJson(DeviceAuthorizationResponse)), + ); + // Clerk's advertised lifetime and interval are authoritative. + const expiresIn = Duration.seconds(authorization.expires_in); + const interval = + authorization.interval === undefined + ? DEVICE_AUTHORIZATION_DEFAULT_INTERVAL + : Duration.seconds(authorization.interval); + yield* showPrompt({ + verificationUri: authorization.verification_uri, + verificationUriComplete: authorization.verification_uri_complete, + userCode: authorization.user_code, + expiresIn, + }); + return yield* pollDeviceToken(metadata, authorization.device_code, interval).pipe( + Effect.timeout(expiresIn), + Effect.catchTag("TimeoutError", (cause) => + Effect.fail(new CloudCliAuthorizationTimeoutError({ cause })), + ), + ); + }, +); + /** @public Service construction is part of the canonical Effect module API. */ 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. + // Capture exactly the services the login/refresh flows need at build time, + // not the whole ambient context. const crypto = yield* Crypto.Crypto; const httpClient = yield* HttpClient.HttpClient; const services = Context.make(Crypto.Crypto, crypto).pipe( @@ -461,7 +551,7 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { // 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 + // the command — authorizeCli applies the same fallback to device // authorization. const token = yield* getExistingNoLock().pipe( Effect.orElseSucceed(() => Option.none()), diff --git a/apps/server/src/cloud/publicConfig.test.ts b/apps/server/src/cloud/publicConfig.test.ts index f8324f9478ed..02276e87d6de 100644 --- a/apps/server/src/cloud/publicConfig.test.ts +++ b/apps/server/src/cloud/publicConfig.test.ts @@ -91,10 +91,11 @@ it.effect("derives direct Clerk OAuth endpoints from statically injected public assert.deepEqual(config, { tokenEndpoint: "https://clerk.example.test/oauth/token", + deviceAuthorizationEndpoint: "https://clerk.example.test/oauth/device_authorization", clientId: "oauth_client_embedded", loopbackPort: 34338, redirectUri: "http://127.0.0.1:34338/callback", - scopes: ["openid", "profile", "email"], + scopes: ["openid", "profile", "email", "offline_access"], }); }), ); diff --git a/apps/server/src/cloud/publicConfig.ts b/apps/server/src/cloud/publicConfig.ts index d60a09b6decb..6fcffdf8f5e6 100644 --- a/apps/server/src/cloud/publicConfig.ts +++ b/apps/server/src/cloud/publicConfig.ts @@ -152,11 +152,13 @@ function makePublicValueConfig(name: string, fallback: string) { /** * The CLI never calls Clerk's /oauth/authorize itself: the browser leg goes * through the hosted /connect page, which builds the authorize URL after a - * Clerk session exists (see CliTokenManager.login). Only the token endpoint - * is contacted directly. + * Clerk session exists (see CliTokenManager.login). The token endpoint and, + * for headless hosts, the device authorization endpoint are contacted + * directly. */ export interface CloudCliOAuthConfig { readonly tokenEndpoint: string; + readonly deviceAuthorizationEndpoint: string; readonly clientId: string; readonly loopbackPort: number; readonly redirectUri: string; @@ -195,6 +197,7 @@ export function makeCloudCliOAuthConfig({ (clerkFrontendApiUrl) => ({ tokenEndpoint: `${clerkFrontendApiUrl}/oauth/token`, + deviceAuthorizationEndpoint: `${clerkFrontendApiUrl}/oauth/device_authorization`, clientId, loopbackPort: CLOUD_CLI_OAUTH_LOOPBACK_PORT, redirectUri: connectLoopbackRedirectUri(CLOUD_CLI_OAUTH_LOOPBACK_PORT), diff --git a/apps/web/src/cloud/connectCliAuth.test.ts b/apps/web/src/cloud/connectCliAuth.test.ts index 3d41c4166332..aaddd9ce0dbe 100644 --- a/apps/web/src/cloud/connectCliAuth.test.ts +++ b/apps/web/src/cloud/connectCliAuth.test.ts @@ -4,7 +4,6 @@ import { buildConnectCliClerkAuthorizeUrl, connectCliSignInRedirectUrl, hasConnectCliAuthConfig, - readConnectCliCallbackResult, } from "./connectCliAuth"; // Any pk_test_* key decodes to .clerk.accounts.dev. @@ -25,48 +24,34 @@ describe("connectCliAuth", () => { expect(hasConnectCliAuthConfig()).toBe(true); }); - it("builds the Clerk authorize URL with the configured hosted origin's callback", () => { + it("builds a PKCE authorize URL that redirects to the CLI's loopback listener", () => { 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", + loopbackPort: 34338, }); 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("redirect_uri")).toBe("http://127.0.0.1:34338/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("redirects straight to the CLI's loopback listener when the request carries a port", () => { - vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); - vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123"); - - const authorizeUrl = buildConnectCliClerkAuthorizeUrl({ - state: "state-1", - challenge: "challenge-1", - loopbackPort: 34338, - }); - expect(authorizeUrl).not.toBeNull(); - - const url = new URL(authorizeUrl!); - expect(url.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:34338/callback"); - expect(url.searchParams.get("state")).toBe("state-1"); - }); - 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" }), + buildConnectCliClerkAuthorizeUrl({ + state: "state-1", + challenge: "challenge-1", + loopbackPort: 34338, + }), ).toBeNull(); }); @@ -74,9 +59,10 @@ describe("connectCliAuth", () => { vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123"); - const connectUrl = "https://app.t3.codes/connect#state=state-1&challenge=challenge-1"; + const connectUrl = + "https://app.t3.codes/connect#state=state-1&challenge=challenge-1&port=34338"; const redirectUrl = connectCliSignInRedirectUrl( - { state: "state-1", challenge: "challenge-1" }, + { state: "state-1", challenge: "challenge-1", loopbackPort: 34338 }, connectUrl, ); @@ -87,23 +73,13 @@ describe("connectCliAuth", () => { it("falls back to the current URL when the authorize URL cannot be built", () => { vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); - const connectUrl = "https://app.t3.codes/connect#state=state-1&challenge=challenge-1"; + const connectUrl = + "https://app.t3.codes/connect#state=state-1&challenge=challenge-1&port=34338"; expect( - connectCliSignInRedirectUrl({ state: "state-1", challenge: "challenge-1" }, connectUrl), - ).toBe(connectUrl); - }); - - 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"), + connectCliSignInRedirectUrl( + { state: "state-1", challenge: "challenge-1", loopbackPort: 34338 }, + connectUrl, ), - ).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(); + ).toBe(connectUrl); }); }); diff --git a/apps/web/src/cloud/connectCliAuth.ts b/apps/web/src/cloud/connectCliAuth.ts index 0bc65080cf8c..03b311fdca19 100644 --- a/apps/web/src/cloud/connectCliAuth.ts +++ b/apps/web/src/cloud/connectCliAuth.ts @@ -1,17 +1,14 @@ import { buildConnectClerkAuthorizeUrl, - connectCallbackUrl, connectLoopbackRedirectUri, CONNECT_OAUTH_SCOPES, type ConnectAuthorizeRequest, } from "@t3tools/shared/connectAuth"; import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth"; -import { configuredHostedAppUrl, isHostedStaticApp } from "../hostedPairing"; +import { isHostedStaticApp } from "../hostedPairing"; import { hasCloudPublicConfig, resolveCloudPublicConfig, trimNonEmpty } from "./publicConfig"; -const CONNECT_CLI_AUTH_STATE_STORAGE_KEY = "t3code-connect-cli-auth-state"; - function resolveConnectCliOAuthClientId(): string | null { return trimNonEmpty(import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID as string | undefined); } @@ -23,7 +20,7 @@ export function hasConnectCliAuthConfig(): boolean { } /** - * Gate for the /connect routes: the CLI handshake only exists on the hosted + * Gate for the /connect route: 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. */ @@ -33,13 +30,9 @@ export function connectCliAuthRoutesEnabled(): boolean { /** * 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. - * - * A request carrying a loopback port came from a CLI with a local callback - * listener: the authorization code must return to `127.0.0.1` directly, so - * the hosted callback page never sees it. Clerk enforces its registered - * redirect URI allowlist either way. + * authorization code returns to the CLI's `127.0.0.1` listener directly, so + * this page never sees it. Clerk enforces its registered redirect URI + * allowlist either way. */ export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeRequest): string | null { const { clerkPublishableKey } = resolveCloudPublicConfig(); @@ -50,10 +43,7 @@ export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeReques return buildConnectClerkAuthorizeUrl({ authorizationEndpoint: `${clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey)}/oauth/authorize`, clientId, - redirectUri: - request.loopbackPort === undefined - ? connectCallbackUrl(configuredHostedAppUrl()) - : connectLoopbackRedirectUri(request.loopbackPort), + redirectUri: connectLoopbackRedirectUri(request.loopbackPort), scopes: CONNECT_OAUTH_SCOPES, state: request.state, challenge: request.challenge, @@ -76,42 +66,3 @@ export function connectCliSignInRedirectUrl( ): string { return buildConnectCliClerkAuthorizeUrl(request) ?? currentHref; } - -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/components/cloud/ConnectCliAuthSurface.tsx b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx index 5d5c280bb81c..afe0dbc31c5e 100644 --- a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx +++ b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx @@ -1,16 +1,12 @@ -import { useAuth, useClerk, useUser } from "@clerk/react"; -import { encodeConnectAuthCode, readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth"; +import { useAuth, useClerk } from "@clerk/react"; +import { readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth"; import { useCallback, useEffect, useRef, useState } from "react"; import { buildConnectCliClerkAuthorizeUrl, connectCliSignInRedirectUrl, - readConnectCliAuthState, - readConnectCliCallbackResult, - rememberConnectCliAuthState, } from "../../cloud/connectCliAuth"; import { isElectron } from "../../env"; -import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { AuthSurfaceShell } from "../auth/AuthSurfaceShell"; import { resolveClerkSignInProps } from "../clerk/authRedirect"; import { Button } from "../ui/button"; @@ -45,10 +41,10 @@ const invalidLinkMessage = { } as const; /** - * /connect: the URL the CLI prints for both flows. Waits for a Clerk session, - * then forwards the CLI's PKCE request to Clerk's authorize endpoint — with a - * loopback redirect URI when the request carries a port, so the code returns - * straight to the waiting CLI, and the hosted callback page otherwise. + * /connect: the URL the CLI prints for the loopback flow. Waits for a Clerk + * session, then forwards the CLI's PKCE request to Clerk's authorize endpoint + * with the loopback redirect URI so the code returns straight to the waiting + * CLI. Headless hosts use Clerk's device authorization page instead. */ export function ConnectCliAuthorizeSurface() { const [request] = useState(() => readConnectAuthorizeRequest(new URL(window.location.href))); @@ -61,9 +57,6 @@ export function ConnectCliAuthorizeSurface() { if (!request) { return; } - // Clerk redirects to the authorize endpoint itself once sign-in completes, - // so the callback's state check has to be armed before handing off. - rememberConnectCliAuthState(request.state); clerk.openSignIn( resolveClerkSignInProps( connectCliSignInRedirectUrl(request, window.location.href), @@ -88,7 +81,6 @@ export function ConnectCliAuthorizeSurface() { return; } redirecting.current = true; - rememberConnectCliAuthState(request.state); window.location.assign(authorizeUrl); }, [isLoaded, isSignedIn, openSignIn, request]); @@ -103,11 +95,7 @@ export function ConnectCliAuthorizeSurface() { return ( ); } - -/** - * /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 87c352244e5f..f3f056522bf7 100644 --- a/apps/web/src/hostedPairing.ts +++ b/apps/web/src/hostedPairing.ts @@ -10,7 +10,7 @@ export interface HostedPairingRequest { export type HostedAppChannel = "latest" | "nightly"; -export function configuredHostedAppUrl(): string { +function configuredHostedAppUrl(): string { return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL; } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 4ce1c3557ee8..f6ba4dece7f9 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -29,7 +29,6 @@ import { Route as SettingsConnectionsRouteImport } from './routes/settings.conne import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance' import { Route as ProjectsProjectKeyRouteImport } from './routes/projects.$projectKey' -import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-requests' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -134,11 +133,6 @@ const ProjectsProjectKeyRoute = ProjectsProjectKeyRouteImport.update({ path: '/projects/$projectKey', getParentRoute: () => rootRouteImport, } as any) -const ConnectCallbackRoute = ConnectCallbackRouteImport.update({ - id: '/connect_/callback', - path: '/connect/callback', - getParentRoute: () => rootRouteImport, -} as any) const ChatPullRequestsRoute = ChatPullRequestsRouteImport.update({ id: '/pull-requests', path: '/pull-requests', @@ -164,7 +158,6 @@ export interface FileRoutesByFullPath { '/usage': typeof UsageRoute '/welcome': typeof WelcomeRoute '/pull-requests': typeof ChatPullRequestsRoute - '/connect/callback': typeof ConnectCallbackRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute @@ -188,7 +181,6 @@ export interface FileRoutesByTo { '/usage': typeof UsageRoute '/welcome': typeof WelcomeRoute '/pull-requests': typeof ChatPullRequestsRoute - '/connect/callback': typeof ConnectCallbackRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute @@ -215,7 +207,6 @@ export interface FileRoutesById { '/usage': typeof UsageRoute '/welcome': typeof WelcomeRoute '/_chat/pull-requests': typeof ChatPullRequestsRoute - '/connect_/callback': typeof ConnectCallbackRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute @@ -243,7 +234,6 @@ export interface FileRouteTypes { | '/usage' | '/welcome' | '/pull-requests' - | '/connect/callback' | '/projects/$projectKey' | '/settings/appearance' | '/settings/archived' @@ -267,7 +257,6 @@ export interface FileRouteTypes { | '/usage' | '/welcome' | '/pull-requests' - | '/connect/callback' | '/projects/$projectKey' | '/settings/appearance' | '/settings/archived' @@ -293,7 +282,6 @@ export interface FileRouteTypes { | '/usage' | '/welcome' | '/_chat/pull-requests' - | '/connect_/callback' | '/projects/$projectKey' | '/settings/appearance' | '/settings/archived' @@ -319,7 +307,6 @@ export interface RootRouteChildren { SettingsRoute: typeof SettingsRouteWithChildren UsageRoute: typeof UsageRoute WelcomeRoute: typeof WelcomeRoute - ConnectCallbackRoute: typeof ConnectCallbackRoute ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute } @@ -465,13 +452,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProjectsProjectKeyRouteImport parentRoute: typeof rootRouteImport } - '/connect_/callback': { - id: '/connect_/callback' - path: '/connect/callback' - fullPath: '/connect/callback' - preLoaderRoute: typeof ConnectCallbackRouteImport - parentRoute: typeof rootRouteImport - } '/_chat/pull-requests': { id: '/_chat/pull-requests' path: '/pull-requests' @@ -553,7 +533,6 @@ const rootRouteChildren: RootRouteChildren = { SettingsRoute: SettingsRouteWithChildren, UsageRoute: UsageRoute, WelcomeRoute: WelcomeRoute, - ConnectCallbackRoute: ConnectCallbackRoute, ProjectsProjectKeyRoute: ProjectsProjectKeyRoute, } export const routeTree = rootRouteImport diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 4db30275ee6b..165f475c8f2b 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -151,7 +151,7 @@ function RootRouteView() { }; }, [pathname]); - if (pathname === "/pair" || pathname === "/connect" || pathname.startsWith("/connect/")) { + if (pathname === "/pair" || pathname === "/connect") { return ( <> diff --git a/apps/web/src/routes/connect_.callback.tsx b/apps/web/src/routes/connect_.callback.tsx deleted file mode 100644 index a5beee0b9d3b..000000000000 --- a/apps/web/src/routes/connect_.callback.tsx +++ /dev/null @@ -1,13 +0,0 @@ -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/docs/internals/t3-connect.md b/docs/internals/t3-connect.md index bf02eb3538be..d0329295f4b4 100644 --- a/docs/internals/t3-connect.md +++ b/docs/internals/t3-connect.md @@ -72,10 +72,15 @@ different credentials. The relay accepts both session-template JWTs and CLI OAuth tokens; requiring a JWT template for the CLI would reject valid logins. The CLI is a public OAuth client using PKCE and stores no client secret. -CLI authorization starts on the hosted `/connect` page so sign-in completes -before entering Clerk's authorize endpoint. Sending a signed-out browser -straight to that endpoint loses the authorize parameters during the sign-in -redirect. The [shared flow](../../packages/shared/src/connectAuth.ts) preserves -PKCE and state for both loopback and pasted-code callbacks. SSH and headless -sessions use the pasted-code flow because the browser cannot ordinarily reach a -listener on the remote machine. +Loopback CLI authorization starts on the hosted `/connect` page so sign-in +completes before entering Clerk's authorize endpoint. Sending a signed-out +browser straight to that endpoint loses the authorize parameters during the +sign-in redirect. The [shared flow](../../packages/shared/src/connectAuth.ts) +preserves PKCE and state for the loopback callback. + +SSH and headless sessions use Clerk's OAuth device authorization grant because +the browser cannot ordinarily reach a listener on the remote machine. The CLI +polls Clerk's token endpoint directly while the user approves a short code on +Clerk's hosted device page; the hosted app plays no part and there is no +redirect URI or PKCE. The grant must be enabled on the CLI OAuth application +or the device endpoint returns an error before any prompt is shown. diff --git a/docs/operations/connect-setup.md b/docs/operations/connect-setup.md index 86697d2798c9..febc6f82e68d 100644 --- a/docs/operations/connect-setup.md +++ b/docs/operations/connect-setup.md @@ -39,11 +39,12 @@ depend on. The deploy wrapper writes the resulting relay URL back to the root `. In Clerk's OAuth applications settings: 1. Create a public OAuth application for the T3 CLI, using authorization-code exchange with PKCE. -2. Allow both redirect URIs: `http://127.0.0.1:34338/callback` and - `https://app.t3.codes/connect/callback`. A custom `T3CODE_HOSTED_APP_URL` needs its own - `/connect/callback` URL. Headless and SSH authorization depend on the hosted redirect. -3. Enable the `openid`, `profile`, and `email` scopes. -4. Set `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` to the generated public client ID in local and release +2. Allow the redirect URI `http://127.0.0.1:34338/callback`. +3. Enable the `openid`, `profile`, `email`, and `offline_access` scopes. +4. Enable **Device authorization grant** on the application. Headless and SSH authorization use + it, and Clerk only advertises the device endpoint once it is on. The feature is in beta and + Clerk enables it per account on request. +5. Set `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` to the generated public client ID in local and release build environments. ## JWT template diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 939140aee69d..ade332337187 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -21,8 +21,9 @@ server with `npx t3 serve`. Saving your sign-in alone does not make the machine reachable. On your other device, sign in to the same T3 Connect account and choose the -environment. Over SSH, the CLI prints a browser link and accepts the returned -authorization code, so you do not need to forward an OAuth callback port. +environment. Over SSH, the CLI prints a browser link and a short code. Open the +link on any device, confirm the code matches, and approve. The CLI continues on +its own, so you do not need to forward an OAuth callback port. T3 Connect renews access credentials when needed without disconnecting a healthy connection. Pull request diffs and provider settings keep working after the diff --git a/packages/shared/src/connectAuth.test.ts b/packages/shared/src/connectAuth.test.ts index 275a958f274d..b6f1d4055d4c 100644 --- a/packages/shared/src/connectAuth.test.ts +++ b/packages/shared/src/connectAuth.test.ts @@ -3,19 +3,17 @@ import { describe, expect, it } from "vite-plus/test"; import { buildConnectAuthorizeRequestUrl, buildConnectClerkAuthorizeUrl, - connectCallbackUrl, connectLoopbackRedirectUri, - encodeConnectAuthCode, - parseConnectAuthCode, readConnectAuthorizeRequest, } from "./connectAuth.ts"; describe("connectAuth", () => { - it("round-trips state and challenge through the authorize URL fragment", () => { + it("round-trips state, challenge, and loopback port through the authorize URL fragment", () => { const url = buildConnectAuthorizeRequestUrl({ hostedAppUrl: "https://app.t3.codes", state: "q7mK9xV2pL4nR8sT6wYzAQ", challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + loopbackPort: 34338, }); const parsed = new URL(url); @@ -25,33 +23,22 @@ describe("connectAuth", () => { expect(readConnectAuthorizeRequest(parsed)).toEqual({ state: "q7mK9xV2pL4nR8sT6wYzAQ", challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + loopbackPort: 34338, }); + expect(connectLoopbackRedirectUri(34338)).toBe("http://127.0.0.1:34338/callback"); }); - it("rejects authorize requests missing state or challenge", () => { + it("rejects authorize requests missing state, challenge, or port", () => { expect(readConnectAuthorizeRequest(new URL("https://app.t3.codes/connect"))).toBeNull(); expect( - readConnectAuthorizeRequest(new URL("https://app.t3.codes/connect#state=abc")), + readConnectAuthorizeRequest(new URL("https://app.t3.codes/connect#state=abc&port=34338")), ).toBeNull(); expect( - readConnectAuthorizeRequest(new URL("https://app.t3.codes/connect#challenge=abc")), + readConnectAuthorizeRequest(new URL("https://app.t3.codes/connect#challenge=abc&port=34338")), + ).toBeNull(); + expect( + readConnectAuthorizeRequest(new URL("https://app.t3.codes/connect#state=abc&challenge=abc")), ).toBeNull(); - }); - - it("round-trips the loopback port through the authorize URL fragment", () => { - const url = buildConnectAuthorizeRequestUrl({ - hostedAppUrl: "https://app.t3.codes", - state: "state-1", - challenge: "challenge-1", - loopbackPort: 34338, - }); - - expect(readConnectAuthorizeRequest(new URL(url))).toEqual({ - state: "state-1", - challenge: "challenge-1", - loopbackPort: 34338, - }); - expect(connectLoopbackRedirectUri(34338)).toBe("http://127.0.0.1:34338/callback"); }); it("rejects authorize requests whose loopback port is corrupted", () => { @@ -68,8 +55,8 @@ describe("connectAuth", () => { buildConnectClerkAuthorizeUrl({ authorizationEndpoint: "https://clerk.t3.codes/oauth/authorize", clientId: "oauthapp_123", - redirectUri: connectCallbackUrl("https://app.t3.codes"), - scopes: ["openid", "profile", "email"], + redirectUri: connectLoopbackRedirectUri(34338), + scopes: ["openid", "profile", "email", "offline_access"], state: "state-1", challenge: "challenge-1", }), @@ -78,27 +65,11 @@ describe("connectAuth", () => { 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("redirect_uri")).toBe("http://127.0.0.1:34338/callback"); expect(url.searchParams.get("response_type")).toBe("code"); - expect(url.searchParams.get("scope")).toBe("openid profile email"); + expect(url.searchParams.get("scope")).toBe("openid profile email offline_access"); 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 index e2a2af106640..0d9b089c34d4 100644 --- a/packages/shared/src/connectAuth.ts +++ b/packages/shared/src/connectAuth.ts @@ -3,11 +3,9 @@ import { readHashParams } from "./remote.ts"; const CONNECT_AUTH_STATE_PARAM = "state"; const CONNECT_AUTH_CHALLENGE_PARAM = "challenge"; const CONNECT_AUTH_PORT_PARAM = "port"; -const CONNECT_AUTH_CODE_SEPARATOR = "."; const CONNECT_LOOPBACK_CALLBACK_PATH = "/callback"; 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 @@ -17,20 +15,20 @@ const CONNECT_CALLBACK_PATH = "/connect/callback"; 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. + * Requested at authorize time by the hosted page and by the CLI's device + * authorization request; keep both sides on this single definition. + * `offline_access` asks Clerk for the refresh token the CLI relies on. */ -export const CONNECT_OAUTH_SCOPES = ["openid", "profile", "email"] as const; +export const CONNECT_OAUTH_SCOPES = ["openid", "profile", "email", "offline_access"] as const; export interface ConnectAuthorizeRequest { readonly state: string; readonly challenge: string; /** - * Present when a loopback CLI initiated the request: the hosted /connect - * page then asks Clerk to redirect the authorization code straight to - * `http://127.0.0.1:/callback` instead of the hosted callback page. + * The hosted /connect page asks Clerk to redirect the authorization code + * straight to `http://127.0.0.1:/callback` on the waiting CLI. */ - readonly loopbackPort?: number; + readonly loopbackPort: number; } /** @@ -38,26 +36,25 @@ export interface ConnectAuthorizeRequest { * `code_challenge` ride the fragment so they never reach the hosted app's * server or CDN logs; neither is a secret. * - * Both CLI flows route through the hosted /connect page rather than hitting + * The CLI routes through the hosted /connect page rather than hitting * Clerk's /oauth/authorize directly: a signed-out browser sent straight to * /oauth/authorize goes through Clerk's sign-in redirect, which does not * reliably preserve the authorize query parameters (state, response_type, * code_challenge). The hosted page waits for a Clerk session first, then - * forwards the request with the parameters intact. + * forwards the request with the parameters intact. Headless hosts use the + * OAuth device authorization grant instead and never involve this page. */ export function buildConnectAuthorizeRequestUrl(input: { readonly hostedAppUrl: string; readonly state: string; readonly challenge: string; - readonly loopbackPort?: number; + readonly loopbackPort: number; }): 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], - ...(input.loopbackPort === undefined - ? [] - : [[CONNECT_AUTH_PORT_PARAM, String(input.loopbackPort)] as [string, string]]), + [CONNECT_AUTH_PORT_PARAM, String(input.loopbackPort)], ]).toString(); return url.toString(); } @@ -66,18 +63,8 @@ export function readConnectAuthorizeRequest(url: URL): ConnectAuthorizeRequest | 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; - } - const port = params.get(CONNECT_AUTH_PORT_PARAM); - if (port === null) { - return { state, challenge }; - } - // A present-but-invalid port means the link was corrupted; reject the whole - // request rather than silently downgrading a loopback flow to the - // out-of-band one, which would strand the waiting CLI. - const loopbackPort = parseLoopbackPort(port.trim()); - if (loopbackPort === null) { + const loopbackPort = parseLoopbackPort(params.get(CONNECT_AUTH_PORT_PARAM)?.trim() ?? ""); + if (!state || !challenge || loopbackPort === null) { return null; } return { state, challenge, loopbackPort }; @@ -99,10 +86,6 @@ export function connectLoopbackRedirectUri(port: number): string { return `http://127.0.0.1:${port}${CONNECT_LOOPBACK_CALLBACK_PATH}`; } -export function connectCallbackUrl(hostedAppUrl: string): string { - return new URL(CONNECT_CALLBACK_PATH, hostedAppUrl).toString(); -} - export function buildConnectClerkAuthorizeUrl(input: { readonly authorizationEndpoint: string; readonly clientId: string; @@ -121,50 +104,3 @@ export function buildConnectClerkAuthorizeUrl(input: { 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 }; -} From 84192388b3a54ef593d35b64390f99bf6dec6a74 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 14 Sep 2026 15:13:05 -0700 Subject: [PATCH 07/16] Add new GitHub user f-trycua --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 988e223d219f..2f9faba635e3 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -25,6 +25,7 @@ github:D3OXY github:dbalders github:eggfriedrice24 github:extoci +github:f-trycua github:flamboh github:FllipEis github:gbarros-dev From 7931227977ca3e6f3354a63467caa634ab79796f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 14 Sep 2026 16:01:36 -0700 Subject: [PATCH 08/16] fix(server): stop refreshing providers on every config subscription (#11811) Co-authored-by: Bil0000 Co-authored-by: Claude Fable 5 --- apps/server/src/server.test.ts | 52 ++++++++++------------------------ apps/server/src/ws.ts | 4 --- 2 files changed, 15 insertions(+), 41 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 7402e908fcf8..0411a0826965 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -6327,48 +6327,26 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("refreshes providers for each subscribeServerConfig connection", () => + it.effect("serves config on reconnect without starting provider probes", () => Effect.gen(function* () { - const refreshCalls = yield* Ref.make(0); - const firstRefreshDone = yield* Deferred.make(); - const secondRefreshDone = yield* Deferred.make(); - + const refresh = vi.fn(() => Effect.never); yield* buildAppUnderTest({ - layers: { - providerRegistry: { - refresh: () => - Ref.updateAndGet(refreshCalls, (count) => count + 1).pipe( - Effect.tap((count) => - Deferred.succeed( - count === 1 ? firstRefreshDone : secondRefreshDone, - undefined, - ).pipe(Effect.ignore), - ), - Effect.as([]), - ), - }, - }, + layers: { providerRegistry: { refresh } }, }); const wsUrl = yield* getWsServerUrl("/ws"); - yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - Effect.gen(function* () { - yield* client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.runHead); - yield* Deferred.await(firstRefreshDone); - }), - ), - ); - yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - Effect.gen(function* () { - yield* client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.runHead); - yield* Deferred.await(secondRefreshDone); - }), - ), - ); - - assert.equal(yield* Ref.get(refreshCalls), 2); + for (let connection = 0; connection < 2; connection += 1) { + const event = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerConfig]({}).pipe( + Stream.runHead, + Effect.map(Option.getOrThrow), + ), + ), + ); + assert.equal(event.type, "snapshot"); + } + assert.equal(refresh.mock.calls.length, 0); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6e5d9c02db39..cec94e358871 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3371,10 +3371,6 @@ const makeWsRpcLayer = ( })), ); - yield* providerRegistry - .refresh() - .pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped); - const liveUpdates = Stream.merge( keybindingsUpdates, Stream.merge( From 5bf43c9f3d09faae49fbc99714456d18e3172ff2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 14 Sep 2026 16:09:33 -0700 Subject: [PATCH 09/16] fix(web): align monogram project icons in menus (#11806) Co-authored-by: Claude Fable 5 --- apps/web/src/components/ProjectFavicon.tsx | 73 ++++++++++++---------- 1 file changed, 39 insertions(+), 34 deletions(-) diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index c545fb188880..17006889fb9c 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -112,44 +112,49 @@ function ProjectFaviconFallback({ }) { if (projectName && projectName.trim().length > 0) { const identity = deriveProjectIdentity(projectName); + // Wrapped like the emoji and Lucide branches so the monogram sits where an + // favicon would. Menu items, buttons and the like pull every bare svg + // in with [&_svg]:-mx-0.5 to trim the padding stroke icons carry, and this + // tile has no such padding. return ( - + + {identity.monogram} + + + + ); } From 9a49d6d5a656254d7079d463aa6ead5d62f4a3e6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 14 Sep 2026 16:23:25 -0700 Subject: [PATCH 10/16] ci(desktop): sign fork PR macOS previews without exposing signing secrets (#11760) Co-authored-by: Claude Fable 5 --- .github/scripts/stage-preview-bundle.py | 76 +++ .github/scripts/stage-preview-bundle.test.py | 97 +++ .github/workflows/ci.yml | 3 + .../desktop-macos-preview-publish.yml | 575 ++++++++++++++++++ .github/workflows/desktop-macos-preview.yml | 381 ++---------- .github/workflows/release-desktop.yml | 81 ++- docs/operations/release.md | 29 + 7 files changed, 899 insertions(+), 343 deletions(-) create mode 100644 .github/scripts/stage-preview-bundle.py create mode 100644 .github/scripts/stage-preview-bundle.test.py create mode 100644 .github/workflows/desktop-macos-preview-publish.yml diff --git a/.github/scripts/stage-preview-bundle.py b/.github/scripts/stage-preview-bundle.py new file mode 100644 index 000000000000..0b3a53285916 --- /dev/null +++ b/.github/scripts/stage-preview-bundle.py @@ -0,0 +1,76 @@ +"""Stage an untrusted preview ZIP without letting it replace packaging code.""" + +import shutil +import stat +import sys +import zipfile +from pathlib import Path + +ROOTS = ("server/dist", "desktop/dist-electron") +REQUIRED_FILES = { + "server/dist/bin.mjs", + "server/dist/client/index.html", + "desktop/dist-electron/main.cjs", +} +# The current bundle is about 32 MiB compressed. Bound extraction on the +# trusted runner even when the PR replaces the uploader entirely. +MAX_ARCHIVE_BYTES = 512 * 1024 * 1024 +MAX_EXPANDED_BYTES = 2 * 1024 * 1024 * 1024 +MAX_ENTRIES = 50_000 + + +def stage_bundle(archive: Path, destination: Path): + if archive.stat().st_size > MAX_ARCHIVE_BYTES: + raise ValueError("Preview archive is too large") + with zipfile.ZipFile(archive) as bundle: + entries = bundle.infolist() + if len(entries) > MAX_ENTRIES: + raise ValueError("Preview archive has too many entries") + if sum(entry.file_size for entry in entries) > MAX_EXPANDED_BYTES: + raise ValueError("Expanded preview bundle is too large") + seen = set() + files = set() + for entry in entries: + name = entry.filename.removesuffix("/") + parts = name.split("/") + # Reject ambiguous paths before normalization, including names + # that would alias on the macOS signing runner. + if ( + entry.orig_filename != entry.filename + or any(part in ("", ".", "..") for part in parts) + or any(char in name for char in "\\:") + or not name.isascii() + or any(ord(char) < 32 or ord(char) == 127 for char in name) + ): + raise ValueError(f"Unsafe preview path: {entry.filename!r}") + allowed = any(name.startswith(root + "/") for root in ROOTS) + if entry.is_dir(): + allowed |= any(root == name or root.startswith(name + "/") for root in ROOTS) + if not allowed: + raise ValueError(f"Unexpected preview path: {name!r}") + kind = stat.S_IFMT(entry.external_attr >> 16) + if kind not in (0, stat.S_IFDIR if entry.is_dir() else stat.S_IFREG): + raise ValueError(f"Non-regular preview entry: {name!r}") + if name.casefold() in seen: + raise ValueError(f"Duplicate preview path: {name!r}") + seen.add(name.casefold()) + if not entry.is_dir(): + files.add(name) + if not REQUIRED_FILES <= files: + raise ValueError("Preview bundle is missing required entry points") + # Validate all names before writing anything. This is a fresh directory + # outside the checkout; neither pre-existing links nor trusted files + # can be followed or overwritten. ZIP permissions are never restored. + destination.mkdir(parents=True, exist_ok=False) + for entry in entries: + target = destination / entry.filename + if entry.is_dir(): + target.mkdir(parents=True, exist_ok=True) + else: + target.parent.mkdir(parents=True, exist_ok=True) + with bundle.open(entry) as source, target.open("xb") as output: + shutil.copyfileobj(source, output) + + +if __name__ == "__main__": + stage_bundle(Path(sys.argv[1]), Path(sys.argv[2])) diff --git a/.github/scripts/stage-preview-bundle.test.py b/.github/scripts/stage-preview-bundle.test.py new file mode 100644 index 000000000000..5957279c8885 --- /dev/null +++ b/.github/scripts/stage-preview-bundle.test.py @@ -0,0 +1,97 @@ +import importlib.util +import stat +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location( + "stage_preview_bundle", Path(__file__).with_name("stage-preview-bundle.py") +) +staging = importlib.util.module_from_spec(spec) +spec.loader.exec_module(staging) + + +class StagePreviewBundleTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.archive = self.root / "bundle.zip" + self.destination = self.root / "staged" + + def bundle(self, extra=(), missing=None): + with zipfile.ZipFile(self.archive, "w") as bundle: + for name in sorted(staging.REQUIRED_FILES - {missing}): + bundle.writestr(name, b"bundle data, never executed") + for name, content in extra: + bundle.writestr(name, content) + + def stage(self): + staging.stage_bundle(self.archive, self.destination) + + def test_preserves_valid_bundle_layout_and_bytes(self): + self.bundle([("server/", b""), ("server/dist/", b""), + ("desktop/dist-electron/chunks/helper.cjs", b"chunk")]) + self.stage() + for name in staging.REQUIRED_FILES: + self.assertEqual((self.destination / name).read_bytes(), b"bundle data, never executed") + self.assertEqual((self.destination / "desktop/dist-electron/chunks/helper.cjs").read_bytes(), b"chunk") + + def test_rejects_builder_overwrite_and_unsafe_paths_before_writing(self): + for name in [ + "desktop/node_modules/electron-builder/cli.js", + "desktop/package.json", + "server/dist/../../desktop/package.json", + "../package.json", + "/server/dist/absolute", + "server/dist/./alias", + "server/dist//alias", + "server/dist/back\\slash", + "server/dist/file:stream", + "server/dist/BIN.MJS", + ]: + with self.subTest(name=name): + self.bundle([(name, b"untrusted")]) + with self.assertRaises(ValueError): + self.stage() + self.assertFalse(self.destination.exists()) + + def test_rejects_links_and_special_files(self): + for mode in [stat.S_IFLNK, stat.S_IFIFO, stat.S_IFCHR]: + with self.subTest(mode=mode): + entry = zipfile.ZipInfo("server/dist/link") + entry.create_system = 3 + entry.external_attr = (mode | 0o777) << 16 + self.bundle([(entry, b"../../../desktop/node_modules")]) + with self.assertRaises(ValueError): + self.stage() + self.assertFalse(self.destination.exists()) + + def test_requires_entry_points(self): + self.bundle(missing="desktop/dist-electron/main.cjs") + with self.assertRaises(ValueError): + self.stage() + self.assertFalse(self.destination.exists()) + + def test_bounds_archive_size_expanded_size_and_entry_count(self): + for limit in ["MAX_ARCHIVE_BYTES", "MAX_EXPANDED_BYTES", "MAX_ENTRIES"]: + with self.subTest(limit=limit), patch.object(staging, limit, 1): + self.bundle() + with self.assertRaises(ValueError): + self.stage() + self.assertFalse(self.destination.exists()) + + def test_refuses_existing_destination(self): + self.bundle() + self.destination.mkdir() + sentinel = self.destination / "trusted" + sentinel.write_text("untouched") + with self.assertRaises(FileExistsError): + self.stage() + self.assertEqual(sentinel.read_text(), "untouched") + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19a28f205d86..611d4cf44f75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,9 @@ jobs: sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + - name: Test preview artifact validation + run: python3 -B .github/scripts/stage-preview-bundle.test.py + - name: Test nightly release checks run: node --test .github/scripts/check-nightly-release.test.cjs diff --git a/.github/workflows/desktop-macos-preview-publish.yml b/.github/workflows/desktop-macos-preview-publish.yml new file mode 100644 index 000000000000..4d141e327c1f --- /dev/null +++ b/.github/workflows/desktop-macos-preview-publish.yml @@ -0,0 +1,575 @@ +name: Desktop macOS Preview Publish + +# Trusted half of the macOS preview. Runs from main with secrets and a write +# token, so it must never execute PR code: the PR's JS bundle is only data that +# gets packaged into the app. Everything that runs here (packaging, signing, +# notarization, publishing) is main's code. +# +# Gate, in order: the completed build run belongs to an open PR that still +# carries the preview:mac label and whose head is the built commit, and the PR +# author is trusted by the vouch list. A maintainer applying the label alone is +# not enough, since the bundle gets signed with the Developer ID certificate. +# +# The label is consumed here once the gate passes, so it only ever covers the +# one commit a maintainer applied it to. A later push builds nothing until the +# label is applied again. + +on: + workflow_run: + workflows: [Desktop macOS Preview] + types: [completed] + # The way out: closing the PR deletes its download, and removing the label + # before it is consumed cancels the preview. pull_request_target gives this a + # write token for fork PRs; it never checks out PR code. + pull_request_target: + types: [closed, unlabeled] + +permissions: + contents: read + +jobs: + resolve: + name: Verify preview eligibility + if: >- + github.event_name == 'workflow_run' && + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' + # The build workflow completes for every PR push (its label gate is on the + # job), so this runs often and usually finds nothing. Keep it cheap. + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + actions: read + contents: read + # write only to consume the label; nothing here runs PR code. + pull-requests: write + outputs: + eligible: ${{ steps.gate.outputs.eligible }} + pr_number: ${{ steps.pr.outputs.pr_number }} + head_sha: ${{ steps.pr.outputs.head_sha }} + version: ${{ steps.version.outputs.version }} + clerk_publishable_key: ${{ steps.version.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ steps.version.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ steps.version.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ steps.version.outputs.relay_url }} + steps: + - id: pr + name: Resolve the pull request behind the build + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const run = context.payload.workflow_run; + const { owner, repo } = context.repo; + + // The build workflow also completes (with every job skipped) for + // label events that are not the preview label. Only a run that + // produced a bundle is worth resolving. + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner, + repo, + run_id: run.id, + per_page: 100, + }); + const bundles = artifacts.filter((artifact) => artifact.name === "js-bundle" && !artifact.expired); + if (bundles.length !== 1) { + core.info(`Expected one js-bundle artifact; found ${bundles.length}. Skipping.`); + core.setOutput("eligible", "false"); + return; + } + + // workflow_run.pull_requests is empty for fork PRs, so resolve the + // PR from the built commit instead and require exactly one open PR + // from the same head repository and branch. The build baked its + // PR number into the version, so two candidates would mean the + // asset name could belong to either. + const associated = await github.paginate( + github.rest.repos.listPullRequestsAssociatedWithCommit, + { owner, repo, commit_sha: run.head_sha, per_page: 100 }, + ); + const matching = associated.filter( + (candidate) => + candidate.state === "open" && + candidate.head.sha === run.head_sha && + candidate.head.ref === run.head_branch && + candidate.head.repo?.full_name === run.head_repository?.full_name, + ); + if (matching.length !== 1) { + core.info(`Expected one open PR for ${run.head_sha}; found ${matching.length}. Skipping.`); + core.setOutput("eligible", "false"); + return; + } + const { data: pull } = await github.rest.pulls.get({ + owner, + repo, + pull_number: matching[0].number, + }); + + if (pull.state !== "open") { + core.info(`PR #${pull.number} is not open. Skipping.`); + core.setOutput("eligible", "false"); + return; + } + if (pull.head.sha !== run.head_sha) { + core.info(`PR #${pull.number} moved to ${pull.head.sha} after ${run.head_sha} was built. Skipping.`); + core.setOutput("eligible", "false"); + return; + } + if (!pull.labels.some((label) => label.name === "preview:mac")) { + core.info(`PR #${pull.number} no longer carries the preview:mac label. Skipping.`); + core.setOutput("eligible", "false"); + return; + } + + core.setOutput("artifact_id", String(bundles[0].id)); + core.setOutput("eligible", "true"); + core.setOutput("pr_number", String(pull.number)); + core.setOutput("head_sha", pull.head.sha); + core.setOutput("author", pull.user.login); + + # Reads VOUCHED.td from the default branch through the API, so a PR + # cannot vouch for itself. + - id: vouch + name: Check PR author trust + if: steps.pr.outputs.eligible == 'true' + uses: mitchellh/vouch/action/check-user@d66fa29a64600490892131ad87597c30c91fcac4 # v1 + with: + user: ${{ steps.pr.outputs.author }} + allow-fail: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # The label authorized exactly this build, so take it now, before the + # long signing job. Removing it with GITHUB_TOKEN does not fire the + # unlabeled cleanup below (workflow-token events never start runs), so + # the download this run publishes survives. If a maintainer removed the + # label first, that removal wins: the 404 makes this run ineligible. + - id: consume + name: Consume the preview label + if: steps.pr.outputs.eligible == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + with: + script: | + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(process.env.PR_NUMBER), + name: "preview:mac", + }); + core.setOutput("consumed", "true"); + } catch (error) { + if (error.status !== 404) throw error; + core.info("The preview:mac label was removed before this build could consume it. Skipping."); + core.setOutput("consumed", "false"); + } + + - id: gate + name: Decide eligibility + shell: bash + env: + PR_ELIGIBLE: ${{ steps.pr.outputs.eligible }} + LABEL_CONSUMED: ${{ steps.consume.outputs.consumed }} + VOUCH_STATUS: ${{ steps.vouch.outputs.status }} + AUTHOR: ${{ steps.pr.outputs.author }} + run: | + set -euo pipefail + if [[ "$PR_ELIGIBLE" != "true" || "$LABEL_CONSUMED" != "true" ]]; then + echo "eligible=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + case "$VOUCH_STATUS" in + bot|collaborator|vouched) + echo "Author $AUTHOR is trusted ($VOUCH_STATUS)." + echo "eligible=true" >> "$GITHUB_OUTPUT" + ;; + *) + echo "Author $AUTHOR is not vouched ($VOUCH_STATUS). Add them to .github/VOUCHED.td to allow signed previews." + echo "eligible=false" >> "$GITHUB_OUTPUT" + ;; + esac + + # Same inputs as the build workflow, read from the built commit through + # the contents API as data: the desktop manifest's base version plus the + # build run's number reproduces the version baked into the bundle, and + # .env.example holds the public T3 Connect identifiers the bundle was + # compiled with, which the signed app's passkey entitlement must match. + # Both are validated before they reach a file name or an entitlement. + - id: version + name: Resolve preview version and public configuration + if: steps.gate.outputs.eligible == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + BUILD_RUN_NUMBER: ${{ github.event.workflow_run.run_number }} + run: | + set -euo pipefail + head_file() { + gh api "repos/${GITHUB_REPOSITORY}/contents/$1?ref=${HEAD_SHA}" --jq '.content' | base64 --decode + } + + base_version="$(head_file apps/desktop/package.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version")" + # The committed desktop version is always a plain X.Y.Z; every + # prerelease identifier is added by a release run. Anything else + # would also let a foreign -pr.N. marker into the asset name, which + # is what publish and cleanup key on. + if [[ ! "$base_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Unexpected desktop version '$base_version' at $HEAD_SHA; expected X.Y.Z." >&2 + exit 1 + fi + echo "version=${base_version}-pr.${PR_NUMBER}.${BUILD_RUN_NUMBER}" >> "$GITHUB_OUTPUT" + + head_file .env.example > "$RUNNER_TEMP/head.env.example" + for key in clerk_publishable_key:T3CODE_CLERK_PUBLISHABLE_KEY clerk_jwt_template:T3CODE_CLERK_JWT_TEMPLATE clerk_cli_oauth_client_id:T3CODE_CLERK_CLI_OAUTH_CLIENT_ID relay_url:T3CODE_RELAY_URL; do + output="${key%%:*}" + name="${key##*:}" + value="$(sed -n "s/^${name}=//p" "$RUNNER_TEMP/head.env.example" | head -n 1)" + if [[ ! "$value" =~ ^[A-Za-z0-9._:/-]+$ ]]; then + echo "$name is missing or malformed in .env.example at $HEAD_SHA." >&2 + exit 1 + fi + echo "${output}=${value}" >> "$GITHUB_OUTPUT" + done + + # Only the default-branch revision that owns this workflow supplies the + # validator. Never check out the PR in a workflow_run job. + - name: Checkout trusted artifact validator + if: steps.gate.outputs.eligible == 'true' + shell: bash + env: + CHECKOUT_REF: ${{ github.sha }} + GIT_TERMINAL_PROMPT: "0" + # Anonymous fetch avoids checkout's credential cleanup, which fails on + # orphaned gitlinks in .repos even when that directory is excluded. + run: | + set -euo pipefail + git init . + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git fetch --no-tags --depth=1 origin "$CHECKOUT_REF" + git sparse-checkout set .github/scripts + git checkout --detach FETCH_HEAD + + # Fetch the archive as bytes. Extracting it over the checkout, even with + # download-artifact, could replace code that runs with signing secrets. + - name: Download and validate PR JS bundle + if: steps.gate.outputs.eligible == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_ID: ${{ steps.pr.outputs.artifact_id }} + run: | + set -euo pipefail + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/js-bundle.zip" + python3 .github/scripts/stage-preview-bundle.py "$RUNNER_TEMP/js-bundle.zip" "$RUNNER_TEMP/js-bundle" + + # Only validated bundle files cross into the signing job's artifact. + - name: Stage JS bundle for packaging + if: steps.gate.outputs.eligible == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: js-bundle + path: ${{ runner.temp }}/js-bundle + if-no-files-found: error + # Re-running this workflow re-uploads under the same run. + overwrite: true + retention-days: 1 + + build: + name: Package and sign macOS arm64 preview + needs: resolve + if: needs.resolve.outputs.eligible == 'true' + concurrency: + group: desktop-macos-preview-${{ needs.resolve.outputs.pr_number }}-build + cancel-in-progress: true + uses: ./.github/workflows/release-desktop.yml + secrets: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} + with: + version: ${{ needs.resolve.outputs.version }} + ref: ${{ github.sha }} + release_channel: preview + relay_client_tracing: false + clerk_publishable_key: ${{ needs.resolve.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ needs.resolve.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ needs.resolve.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ needs.resolve.outputs.relay_url }} + label: macOS arm64 preview + runner: blacksmith-12vcpu-macos-26 + platform: mac + target: dmg + arch: arm64 + rust_target: aarch64-apple-darwin + resource_key: darwin-arm64 + cli_archive: false + + # Release assets download without a GitHub account, unlike workflow + # artifacts. All preview DMGs live on one rolling prerelease tagged + # "desktop-preview" (release.yml only matches v*.*.* tags), so publishing a + # build never notifies release watchers. + publish: + name: Publish anonymous download + needs: [resolve, build] + if: needs.resolve.outputs.eligible == 'true' && needs.build.result == 'success' + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 10 + # Its own group, so a publish never cancels a newer commit's signing job + # (they would share the build group) and is never cancelled mid-upload. + # preview_eligible's head check keeps a superseded publish from landing. + concurrency: + group: desktop-macos-preview-${{ needs.resolve.outputs.pr_number }}-publish + cancel-in-progress: false + permissions: + contents: write + pull-requests: write + steps: + - name: Download macOS artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: desktop-mac-arm64 + path: release + + - id: upload + name: Upload DMG to the rolling preview release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ needs.resolve.outputs.pr_number }} + HEAD_SHA: ${{ needs.resolve.outputs.head_sha }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + + tag="desktop-preview" + + # True while the PR is open and still points at the commit this + # build came from. The label was consumed in resolve, so it is not + # part of this check. A push does not cancel an already-running + # signing job, so this is what keeps a superseded commit's DMG off + # the release. + preview_eligible() { + [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \ + --json state,headRefOid \ + --jq '.state + " " + .headRefOid')" == "OPEN $HEAD_SHA" ]] + } + + # The build ran for many minutes. If the PR closed or moved on + # meanwhile, cleanup already ran in its own concurrency group or a + # newer build owns the asset, so publishing now would resurrect a + # deleted download or clobber a newer one. + if ! preview_eligible; then + echo "PR closed or head moved while building. Skipping publish." + exit 0 + fi + + shopt -s nullglob + dmg_files=(release/*.dmg) + if (( ${#dmg_files[@]} != 1 )); then + printf 'Expected one DMG, found %s.\n' "${#dmg_files[@]}" >&2 + exit 1 + fi + dmg_path="${dmg_files[0]}" + + # Requiring this PR's marker keeps a build from clobbering or + # deleting another PR's asset, since those names carry a different + # -pr.N. marker. + if [[ "$(basename "$dmg_path")" != *"-pr.${PR_NUMBER}."* ]]; then + echo "DMG name '$(basename "$dmg_path")' does not carry this PR's -pr.${PR_NUMBER}. marker. Refusing to publish." >&2 + exit 1 + fi + + if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + # "|| true" tolerates a concurrent publish job creating the + # release between the check and the create. + gh release create "$tag" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$DEFAULT_BRANCH" \ + --prerelease \ + --title "Desktop preview builds" \ + --notes "Rolling desktop builds from pull requests with a preview label. Each download is removed when its pull request closes or loses the label. Install stable builds from the latest release instead." \ + || true + fi + + # Keep one DMG per PR: drop this PR's older builds first. The + # trailing dot keeps -pr.12. from matching -pr.123. builds. + gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \ + | { grep -F -- "-pr.${PR_NUMBER}." || true; } \ + | while read -r asset; do + gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \ + || echo "Asset $asset was already removed by a concurrent run." + done + + gh release upload "$tag" "$dmg_path" --repo "$GITHUB_REPOSITORY" --clobber + + # Re-check after uploading. A cleanup run that started during the + # upload listed assets before ours existed, so it cannot delete it. + # Whichever writer acts last sees the final PR state; if the preview + # became ineligible, delete what we just uploaded. + if ! preview_eligible; then + gh release delete-asset "$tag" "$(basename "$dmg_path")" --repo "$GITHUB_REPOSITORY" --yes \ + || echo "Asset was already removed by a concurrent run." + echo "PR closed or head moved during upload. Removed the download." + exit 0 + fi + + echo "dmg_name=$(basename "$dmg_path")" >> "$GITHUB_OUTPUT" + echo "download_url=https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}/$(basename "$dmg_path")" >> "$GITHUB_OUTPUT" + + - name: Comment download link + if: steps.upload.outputs.download_url != '' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + PR_NUMBER: ${{ needs.resolve.outputs.pr_number }} + DOWNLOAD_URL: ${{ steps.upload.outputs.download_url }} + HEAD_SHA: ${{ needs.resolve.outputs.head_sha }} + PREVIEW_VERSION: ${{ needs.resolve.outputs.version }} + with: + script: | + const prNumber = Number(process.env.PR_NUMBER); + const { data: pullRequest } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + if (pullRequest.head.sha !== process.env.HEAD_SHA || pullRequest.state !== "open") { + core.info("Skipping the outdated macOS preview comment."); + return; + } + + const marker = ""; + const body = [ + marker, + "### macOS preview", + "", + `[Download Apple Silicon DMG](${process.env.DOWNLOAD_URL})`, + "", + `Version: ${process.env.PREVIEW_VERSION}`, + `Commit: ${process.env.HEAD_SHA.slice(0, 7)}`, + "", + "Signed and notarized, with T3 Connect enabled. The app bundle (server, web client, Electron main) is built from this PR; packaging, native helpers, and desktop dependencies come from `main`.", + "", + "No GitHub sign-in is needed. The download stays available until this PR closes. The `preview:mac` label was consumed by this build; a maintainer applies it again to build a newer commit.", + ].join("\n"); + + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + per_page: 100, + }); + const existing = comments.find( + (comment) => comment.user?.login === "github-actions[bot]" && comment.body?.includes(marker), + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + } + + cleanup: + name: Remove preview download + # A published preview no longer carries the label (resolve consumed it), + # so every close must look for assets; the -pr.N. filter below makes + # that a cheap no-op for PRs that never had one. A manual unlabel before + # the build consumed it withdraws the request and drops any older + # download too. + if: >- + github.event_name == 'pull_request_target' && + (github.event.action == 'closed' || + (github.event.action == 'unlabeled' && github.event.label.name == 'preview:mac')) + # Runs on every PR close and usually finds nothing. Keep it cheap. + runs-on: ubuntu-24.04 + timeout-minutes: 10 + # Cleanup runs must complete: a close event right after an unlabel queues + # behind the running cleanup instead of canceling it mid-delete. + concurrency: + group: desktop-macos-preview-${{ github.event.pull_request.number }}-cleanup + cancel-in-progress: false + permissions: + contents: write + pull-requests: write + steps: + - id: delete + name: Delete this PR's preview assets + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + tag="desktop-preview" + + # A stale cleanup must not delete a download that became valid + # again. If the PR is open and labeled once more, the next publish + # owns this PR's assets and replaces them itself. + if [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \ + --json state,labels \ + --jq '.state + " " + (.labels | map(.name) | contains(["preview:mac"]) | tostring)')" == "OPEN true" ]]; then + echo "PR is open and labeled again. Skipping cleanup." + echo "removed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "removed=true" >> "$GITHUB_OUTPUT" + + if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "No preview release exists. Nothing to clean up." + exit 0 + fi + + gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \ + | { grep -F -- "-pr.${PR_NUMBER}." || true; } \ + | while read -r asset; do + gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \ + || echo "Asset $asset was already removed by a concurrent run." + done + + - name: Mark the preview comment as removed + if: steps.delete.outputs.removed == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const marker = ""; + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + per_page: 100, + }); + const existing = comments.find( + (comment) => comment.user?.login === "github-actions[bot]" && comment.body?.includes(marker), + ); + if (!existing) { + return; + } + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: [ + marker, + "### macOS preview", + "", + "The preview download was removed because this PR closed or the preview label was removed.", + ].join("\n"), + }); diff --git a/.github/workflows/desktop-macos-preview.yml b/.github/workflows/desktop-macos-preview.yml index 7875aec6f36b..43b6c8220aaf 100644 --- a/.github/workflows/desktop-macos-preview.yml +++ b/.github/workflows/desktop-macos-preview.yml @@ -1,74 +1,67 @@ name: Desktop macOS Preview +# Untrusted half of the macOS preview. This runs PR code (including fork PRs) +# with a read-only token and no secrets, and only produces the JS bundle. The +# trusted half, desktop-macos-preview-publish.yml, runs on workflow_run from +# main, verifies the PR author is vouched, then packages, signs, notarizes, and +# publishes the bundle without ever executing it. +# +# The label is a one-shot request for the commit it was applied to, not a +# standing subscription: the trusted half removes it once this run completes, +# and later pushes do not build until a maintainer applies it again. Each +# signed preview is therefore an explicit per-commit decision. +# +# Closing the PR is handled by the publish workflow too, since deleting the +# download needs a write token. + on: pull_request: - types: [labeled, unlabeled, synchronize, reopened, closed] + types: [labeled] permissions: contents: read -# Build events and cleanup events use separate groups: a push must cancel a -# stale in-flight build, but must never cancel a cleanup run mid-delete. The -# publish job re-checks PR state before uploading to cover the reverse race. concurrency: - group: desktop-macos-preview-${{ github.event.pull_request.number }}-${{ contains(fromJSON('["closed", "unlabeled"]'), github.event.action) && 'cleanup' || 'build' }} - # Cleanup runs must complete (a close event right after an unlabel queues - # behind the running cleanup instead of canceling it mid-delete), and events - # that skip the build job, such as adding an unrelated label, must not - # cancel an in-flight build either. - cancel-in-progress: ${{ !contains(fromJSON('["closed", "unlabeled"]'), github.event.action) && (github.event.action != 'labeled' || github.event.label.name == 'preview:mac') }} + group: desktop-macos-preview-${{ github.event.pull_request.number }} + # Adding an unrelated label skips the job and must not cancel a build. + cancel-in-progress: ${{ github.event.label.name == 'preview:mac' }} jobs: - # Builds run PR code, so this job keeps a read-only token. Publishing to the - # release happens in the publish job below, which never checks out PR code. build: - name: Build macOS Apple Silicon preview - if: >- - github.event.action != 'closed' && - github.event.action != 'unlabeled' && - github.event.pull_request.head.repo.full_name == github.repository && - contains(github.event.pull_request.labels.*.name, 'preview:mac') && - (github.event.action != 'labeled' || github.event.label.name == 'preview:mac') - runs-on: blacksmith-12vcpu-macos-26 + name: Build preview JS bundle + if: github.event.label.name == 'preview:mac' + runs-on: blacksmith-32vcpu-ubuntu-2404 timeout-minutes: 30 - outputs: - dmg_name: ${{ steps.build.outputs.dmg_name }} - version: ${{ steps.version.outputs.version }} steps: + # This repository is public, so Git needs no credentials. checkout's + # credential cleanup runs submodule foreach even with submodules disabled, + # which fails on the orphaned gitlinks in our vendored .repos tree. - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ github.event.pull_request.head.sha }} - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false + shell: bash + env: + CHECKOUT_REF: ${{ github.event.pull_request.head.sha }} + GIT_TERMINAL_PROMPT: "0" + run: | + set -euo pipefail + git init . + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git fetch --no-tags --depth=1 origin "$CHECKOUT_REF" + git sparse-checkout set --no-cone '/*' '!/.repos/' + git checkout --detach FETCH_HEAD - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 with: node-version-file: package.json cache: true run-install: false - - name: Install desktop dependencies - run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... - - - name: Cache resource monitor - id: resource_monitor_cache - uses: actions/cache@v6 - with: - path: native/resource-monitor/target/aarch64-apple-darwin/release/t3-resource-monitor - key: resource-monitor-aarch64-apple-darwin-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} - - - name: Setup Rust - if: steps.resource_monitor_cache.outputs.cache-hit != 'true' - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-apple-darwin + - name: Install bundle dependencies + run: vp install --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... - - id: version - name: Set preview version and public configuration + # The publish workflow derives the same version from this run's number, + # so the version baked into the bundle matches the packaged app. + - name: Set preview version and public configuration shell: bash env: PR_NUMBER: ${{ github.event.pull_request.number }} @@ -76,286 +69,26 @@ jobs: set -euo pipefail base_version="$(node -p "require('./apps/desktop/package.json').version")" - preview_version="${base_version}-pr.${PR_NUMBER}.${GITHUB_RUN_NUMBER}" - node scripts/update-release-package-versions.ts "$preview_version" + node scripts/update-release-package-versions.ts "${base_version}-pr.${PR_NUMBER}.${GITHUB_RUN_NUMBER}" + # Public T3 Connect identifiers (Clerk publishable key, relay URL). cp .env.example .env - echo "version=$preview_version" >> "$GITHUB_OUTPUT" - - - id: build - name: Build unsigned macOS DMG - shell: bash - env: - T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} - PREVIEW_VERSION: ${{ steps.version.outputs.version }} - run: | - set -euo pipefail + - uses: ./.github/actions/setup-apt-mirrors - vp run dist:desktop:artifact \ - --platform mac \ - --target dmg \ - --arch arm64 \ - --build-version "$PREVIEW_VERSION" \ - --verbose + - name: Install browser secret helper build libraries + run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - shopt -s nullglob - dmg_files=(release/*.dmg) - if (( ${#dmg_files[@]} != 1 )); then - printf 'Expected one DMG, found %s.\n' "${#dmg_files[@]}" >&2 - exit 1 - fi - printf 'dmg_name=%s\n' "$(basename "${dmg_files[0]}")" >> "$GITHUB_OUTPUT" + - name: Build JS bundle + run: vp run build:desktop - # archive: false uploads the file as its own artifact named after the - # file, so the publish job downloads by *.dmg pattern, not by name. - - name: Upload macOS DMG - uses: actions/upload-artifact@v7 + # Same layout as release.yml's js-bundle so release-desktop.yml can + # package it unchanged. + - name: Upload JS bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - path: release/*.dmg + name: js-bundle + path: | + apps/server/dist + apps/desktop/dist-electron if-no-files-found: error - archive: false - overwrite: true - retention-days: 7 - - # Release assets download without a GitHub account, unlike workflow - # artifacts. All preview DMGs live on one rolling prerelease tagged - # "desktop-preview" (release.yml only matches v*.*.* tags), so publishing a - # build never notifies release watchers. This job holds the write token and - # only handles the artifact the build job produced; it never runs PR code. - publish: - name: Publish anonymous download - needs: build - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - permissions: - contents: write - pull-requests: write - steps: - - name: Download macOS DMG - uses: actions/download-artifact@v8 - with: - pattern: "*.dmg" - merge-multiple: true - path: release - - - id: upload - name: Upload DMG to the rolling preview release - shell: bash - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: | - set -euo pipefail - - tag="desktop-preview" - - # True while the PR is open and still carries the preview label. - preview_eligible() { - [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \ - --json state,labels \ - --jq '.state + " " + (.labels | map(.name) | contains(["preview:mac"]) | tostring)')" == "OPEN true" ]] - } - - # The build ran for many minutes. If the PR closed or lost the label - # meanwhile, cleanup already ran in its own concurrency group, so - # publishing now would resurrect a deleted download. - if ! preview_eligible; then - echo "PR closed or preview label removed while building. Skipping publish." - exit 0 - fi - - dmg_path="$(find release -type f -name '*.dmg' -print -quit)" - if [[ -z "$dmg_path" ]]; then - echo "No DMG found in the downloaded artifact." >&2 - exit 1 - fi - - # The filename comes out of the build, which runs PR code. Requiring - # this PR's marker keeps a build from clobbering or deleting another - # PR's asset, since those names carry a different -pr.N. marker. - if [[ "$(basename "$dmg_path")" != *"-pr.${PR_NUMBER}."* ]]; then - echo "DMG name '$(basename "$dmg_path")' does not carry this PR's -pr.${PR_NUMBER}. marker. Refusing to publish." >&2 - exit 1 - fi - - if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - # "|| true" tolerates a concurrent publish job creating the - # release between the check and the create. - gh release create "$tag" \ - --repo "$GITHUB_REPOSITORY" \ - --target "$DEFAULT_BRANCH" \ - --prerelease \ - --title "Desktop preview builds" \ - --notes "Rolling unsigned desktop builds from pull requests with a preview label. Each download is removed when its pull request closes or loses the label. Install stable builds from the latest release instead." \ - || true - fi - - # Keep one DMG per PR: drop this PR's older builds first. The - # trailing dot keeps -pr.12. from matching -pr.123. builds. - gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \ - | { grep -F -- "-pr.${PR_NUMBER}." || true; } \ - | while read -r asset; do - gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \ - || echo "Asset $asset was already removed by a concurrent run." - done - - gh release upload "$tag" "$dmg_path" --repo "$GITHUB_REPOSITORY" --clobber - - # Re-check after uploading. A cleanup run that started during the - # upload listed assets before ours existed, so it cannot delete it. - # Whichever writer acts last sees the final PR state; if the preview - # became ineligible, delete what we just uploaded. - if ! preview_eligible; then - gh release delete-asset "$tag" "$(basename "$dmg_path")" --repo "$GITHUB_REPOSITORY" --yes \ - || echo "Asset was already removed by a concurrent run." - echo "PR closed or preview label removed during upload. Removed the download." - exit 0 - fi - - echo "download_url=https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}/$(basename "$dmg_path")" >> "$GITHUB_OUTPUT" - - - name: Comment download link - if: steps.upload.outputs.download_url != '' - uses: actions/github-script@v8 - env: - DOWNLOAD_URL: ${{ steps.upload.outputs.download_url }} - DMG_NAME: ${{ needs.build.outputs.dmg_name }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - PREVIEW_VERSION: ${{ needs.build.outputs.version }} - with: - script: | - const { data: pullRequest } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.payload.pull_request.number, - }); - if ( - pullRequest.head.sha !== process.env.HEAD_SHA || - pullRequest.state !== "open" || - !pullRequest.labels.some((label) => label.name === "preview:mac") - ) { - core.info("Skipping the outdated macOS preview comment."); - return; - } - - const marker = ""; - const body = [ - marker, - "### macOS preview", - "", - `[Download Apple Silicon DMG](${process.env.DOWNLOAD_URL})`, - "", - `Version: ${process.env.PREVIEW_VERSION}`, - `Commit: ${process.env.HEAD_SHA.slice(0, 7)}`, - "", - "Unsigned build. Clear quarantine before opening:", - "```sh", - `xattr -d com.apple.quarantine ~/Downloads/${process.env.DMG_NAME}`, - "```", - "", - "No GitHub sign-in is needed. The download stays available until this PR closes or the preview label is removed.", - ].join("\n"); - - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - per_page: 100, - }); - const existing = comments.find((comment) => comment.body?.includes(marker)); - - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - body, - }); - } - - # The way out: closing the PR or removing the label deletes its DMG from the - # rolling release and updates the PR comment to say so. - cleanup: - name: Remove preview download - if: >- - github.event.pull_request.head.repo.full_name == github.repository && - ((github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'preview:mac')) || - (github.event.action == 'unlabeled' && github.event.label.name == 'preview:mac')) - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - permissions: - contents: write - pull-requests: write - steps: - - id: delete - name: Delete this PR's preview assets - shell: bash - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - - tag="desktop-preview" - - # A stale cleanup must not delete a download that became valid - # again. If the PR is open and labeled once more, the next publish - # owns this PR's assets and replaces them itself. - if [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \ - --json state,labels \ - --jq '.state + " " + (.labels | map(.name) | contains(["preview:mac"]) | tostring)')" == "OPEN true" ]]; then - echo "PR is open and labeled again. Skipping cleanup." - echo "removed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "removed=true" >> "$GITHUB_OUTPUT" - - if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "No preview release exists. Nothing to clean up." - exit 0 - fi - - gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \ - | { grep -F -- "-pr.${PR_NUMBER}." || true; } \ - | while read -r asset; do - gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \ - || echo "Asset $asset was already removed by a concurrent run." - done - - - name: Mark the preview comment as removed - if: steps.delete.outputs.removed == 'true' - uses: actions/github-script@v8 - with: - script: | - const marker = ""; - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - per_page: 100, - }); - const existing = comments.find((comment) => comment.body?.includes(marker)); - if (!existing) { - return; - } - - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body: [ - marker, - "### macOS preview", - "", - "The preview download was removed because this PR closed or the preview label was removed.", - ].join("\n"), - }); + retention-days: 1 diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 5d790e49a061..c374a8692db6 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -8,6 +8,33 @@ name: Release desktop build on: workflow_call: + secrets: + CSC_LINK: + required: false + CSC_KEY_PASSWORD: + required: false + APPLE_API_KEY: + required: false + APPLE_API_KEY_ID: + required: false + APPLE_API_ISSUER: + required: false + MACOS_PROVISIONING_PROFILE: + required: false + AZURE_TENANT_ID: + required: false + AZURE_CLIENT_ID: + required: false + AZURE_CLIENT_SECRET: + required: false + AZURE_TRUSTED_SIGNING_ENDPOINT: + required: false + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: + required: false + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: + required: false + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: + required: false inputs: label: required: true @@ -46,6 +73,12 @@ on: release_channel: required: true type: string + # Whether a `relay-client-tracing-config` artifact from the production + # relay state is expected. PR previews carry no tracing config. + relay_client_tracing: + required: false + default: true + type: boolean clerk_publishable_key: required: true type: string @@ -73,17 +106,24 @@ jobs: T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ inputs.clerk_cli_oauth_client_id }} T3CODE_RELAY_URL: ${{ inputs.relay_url }} steps: + # This repository is public, so Git needs no credentials. checkout's + # credential cleanup runs submodule foreach even with submodules disabled, + # which fails on the orphaned gitlinks in our vendored .repos tree. - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref }} - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false + shell: bash + env: + CHECKOUT_REF: ${{ inputs.ref }} + GIT_TERMINAL_PROMPT: "0" + run: | + set -euo pipefail + git init . + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git fetch --no-tags --depth=1 origin "$CHECKOUT_REF" + git sparse-checkout set --no-cone '/*' '!/.repos/' + git checkout --detach FETCH_HEAD - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 with: node-version-file: package.json cache: ${{ inputs.platform != 'win' }} @@ -97,7 +137,7 @@ jobs: - name: Cache Windows packages if: inputs.platform == 'win' - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.package_cache_path.outputs.path }} key: windows-release-packages-v1-${{ inputs.arch }}-${{ hashFiles('pnpm-lock.yaml') }} @@ -106,7 +146,7 @@ jobs: # artifact leaves the cache empty, so installation runs the checks again. - name: Download dependency verification continue-on-error: true - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: release-dependency-verification path: ${{ runner.temp }}/pnpm-metadata @@ -118,7 +158,7 @@ jobs: - name: Cache resource monitor id: resource_monitor_cache - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: native/resource-monitor/target/${{ inputs.rust_target }}/release/t3-resource-monitor${{ inputs.platform == 'win' && '.exe' || '' }} key: resource-monitor-${{ inputs.rust_target }}-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} @@ -126,7 +166,7 @@ jobs: - name: Cache Linux capture helpers if: inputs.platform == 'linux' id: capture_helper_cache - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: | native/kde-snap-shot/target/${{ inputs.rust_target }}/release/t3-kde-snap-shot @@ -135,17 +175,20 @@ jobs: - name: Setup Rust if: steps.resource_monitor_cache.outputs.cache-hit != 'true' || (inputs.platform == 'linux' && steps.capture_helper_cache.outputs.cache-hit != 'true') - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: + toolchain: stable targets: ${{ inputs.rust_target }} - name: Download relay client tracing config - uses: actions/download-artifact@v8 + if: inputs.relay_client_tracing + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: relay-client-tracing-config path: ${{ runner.temp }}/relay-client-tracing - name: Load relay client tracing config + if: inputs.relay_client_tracing shell: bash run: | config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" @@ -160,7 +203,7 @@ jobs: # ancestor of its paths), so extracting into `apps` restores # apps/server/dist and apps/desktop/dist-electron at their build paths. - name: Download JS bundle - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: js-bundle path: apps @@ -169,7 +212,7 @@ jobs: # Windows desktop embeds the same-arch archive the release attaches. - name: Download Linux CLI archive for WSL if: inputs.platform == 'win' - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: cli-linux-${{ inputs.arch }} path: wsl-runtime @@ -450,7 +493,7 @@ jobs: - name: Upload CLI archive if: inputs.cli_archive - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: cli-${{ inputs.platform }}-${{ inputs.arch }} path: release-cli/* @@ -514,14 +557,14 @@ jobs: cp "$source_path" "$target_dir/$binary_name" - name: Upload build artifacts - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: desktop-${{ inputs.platform }}-${{ inputs.arch }} path: release-publish/* if-no-files-found: error - name: Upload resource monitor - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: resource-monitor-${{ inputs.resource_key }} path: resource-monitor-publish/${{ inputs.resource_key }}/* diff --git a/docs/operations/release.md b/docs/operations/release.md index a143411c4bf4..fe8a8e2081ad 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -50,6 +50,35 @@ This document covers the unified release workflow for stable and nightly desktop - nightly releases are aliased to the `nightly` hosted app channel - Signing is optional and auto-detected per platform from secrets. +## Pull request macOS previews + +Labeling a PR `preview:mac` publishes a signed, notarized Apple Silicon DMG with T3 Connect enabled +to the rolling `desktop-preview` prerelease, and works for fork PRs. The label is a one-shot request +for the commit it is applied to: the trusted workflow removes it once the build is in hand, and later +pushes do not build until a maintainer applies it again. Every signed preview is therefore a +per-commit maintainer decision, which matters because the result carries the Developer ID signature. +Vouching a contributor lets their labeled commits be signed; it is not a standing grant. The build is +split so the Developer ID certificate never shares a job with PR code: + +- `.github/workflows/desktop-macos-preview.yml` runs on `pull_request` with no secrets and builds + only the JS bundle from the PR (the same `js-bundle` artifact `release.yml` produces). +- `.github/workflows/desktop-macos-preview-publish.yml` runs on `workflow_run` from `main`. It + refuses unless the PR is open, still labeled, its head is the built commit, and the author is a + bot, a collaborator, or listed in `.github/VOUCHED.td` (read from the default branch, so a PR cannot vouch + for itself). It then packages and signs the bundle through `release-desktop.yml` checked out at + `main`, so packaging, native helpers, and the Electron/desktop dependencies come from `main`, not + the PR. Only the version and the public T3 Connect identifiers in `.env.example` are read from the + PR commit, as data, so the signed app's passkey entitlement matches the bundle. A PR that changes + packaging must use the `channel=preview` release train above instead. + +Before handing the bundle to the signing runner, the trusted workflow validates its ZIP entries +and accepts only regular files under `server/dist` and `desktop/dist-electron`, plus the directory +entries that lead to those roots. The artifact cannot +overwrite packaging code or installed dependencies. The bundle is copied into the app, never executed, +on the signing runner. The +`pull_request_target` cleanup job in the publish workflow removes the download when the PR closes, or +when the label is removed by hand before a build consumed it, and never checks out PR code. + ## Required release credentials Stable releases require these GitHub Actions secrets in addition to the platform and deployment From 014016a62344ab03dbccdb73c3c1861bc4f7e349 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Tue, 15 Sep 2026 03:00:28 +0300 Subject: [PATCH 11/16] fix(web): make copy PR link discoverable in keybindings (#11826) --- .../settings/KeybindingsSettings.logic.test.ts | 15 +++++++++------ .../settings/KeybindingsSettings.logic.ts | 2 +- docs/user/keybindings.md | 2 +- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index d55b047e6638..ff3b30124a73 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -37,12 +37,15 @@ describe("KeybindingsSettings.logic", () => { }); } }); - it("finds the existing URL shortcut in Settings", () => { - const rows = buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, "PR URL"); - expect(rows).toEqual([ - expect.objectContaining({ command: "thread.copyReference", key: "mod+shift+c" }), - ]); - }); + it.each(["pu", "pull request", "copy link", "thread id"])( + "finds the copy link shortcut with %s", + (query) => { + const rows = buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, query); + expect(rows).toContainEqual( + expect.objectContaining({ command: "thread.copyReference", key: "mod+shift+c" }), + ); + }, + ); it("builds searchable rows with readable key and when values", () => { const rows = buildKeybindingRows( [ diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index 7119d9ee4e5a..a910c7e5535b 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -276,7 +276,7 @@ export function buildKeybindingCommandOptions( } export function commandLabel(command: KeybindingCommand): string { - if (command === "thread.copyReference") return "Thread: Copy PR URL or Thread ID"; + if (command === "thread.copyReference") return "Pull Request: Copy Link or Thread ID"; const raw = String(command); if (raw.startsWith("script.") && raw.endsWith(".run")) { return `Run Script: ${titleCaseCommandSegment(raw.slice("script.".length, -".run".length))}`; diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 556525910531..d5a9b3c920ac 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -25,7 +25,7 @@ in Settings. With a PR open in the right panel or on the Pull Requests page, use `mod+shift+c` to copy its URL and `mod+shift+k` to copy its number with a `#` prefix. -Both shortcuts can be changed in Settings. Search for “Copy PR URL or Thread ID” +Both shortcuts can be changed in Settings. Search for “Copy Link or Thread ID” or “Copy Number”. They copy the selected PR and leave terminal input alone. ## Edit the configuration file From 3be02ae5791480b8c8bf3a13020a932378eca1ba Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 14 Sep 2026 17:01:35 -0700 Subject: [PATCH 12/16] feat: add custom snooze dates and durations (#11800) --- .../src/components/SegmentedControl.tsx | 74 +++++ .../threads/CustomSnoozeSheet.ios.tsx | 268 ++++++++++++++++++ .../features/threads/CustomSnoozeSheet.tsx | 182 ++++++++++++ .../features/threads/thread-list-v2-items.tsx | 15 +- .../src/features/usage/UsageRouteScreen.tsx | 73 +---- apps/web/package.json | 1 + .../web/src/components/CustomSnoozeDialog.tsx | 245 ++++++++++++++++ apps/web/src/components/Sidebar.tsx | 49 +++- .../components/threadActionMenu.logic.test.ts | 2 +- .../src/components/threadActionMenu.logic.ts | 11 +- apps/web/src/components/ui/calendar.tsx | 112 ++++++++ apps/web/src/hooks/useThreadActionMenu.ts | 6 +- apps/web/src/routes/__root.tsx | 3 + docs/user/thread-sidebar.md | 7 + .../src/state/customSnooze.test.ts | 66 +++++ .../client-runtime/src/state/threadSettled.ts | 35 +++ pnpm-lock.yaml | 58 +++- 17 files changed, 1109 insertions(+), 98 deletions(-) create mode 100644 apps/mobile/src/components/SegmentedControl.tsx create mode 100644 apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx create mode 100644 apps/mobile/src/features/threads/CustomSnoozeSheet.tsx create mode 100644 apps/web/src/components/CustomSnoozeDialog.tsx create mode 100644 apps/web/src/components/ui/calendar.tsx create mode 100644 packages/client-runtime/src/state/customSnooze.test.ts diff --git a/apps/mobile/src/components/SegmentedControl.tsx b/apps/mobile/src/components/SegmentedControl.tsx new file mode 100644 index 000000000000..04a562956c46 --- /dev/null +++ b/apps/mobile/src/components/SegmentedControl.tsx @@ -0,0 +1,74 @@ +import { Platform, Pressable, View } from "react-native"; +import Animated, { Easing, LinearTransition, ReduceMotion } from "react-native-reanimated"; +import { AppText as Text } from "./AppText"; +import { cn } from "../lib/cn"; + +export function SegmentedControl(props: { + readonly options: readonly { + readonly value: Value; + readonly label: string; + readonly accessibilityLabel?: string; + }[]; + readonly selected: Value; + readonly onSelect: (value: Value) => void; + /** The tab bar is full height; filters under it are shorter so it stays primary. */ + readonly size?: "default" | "compact"; + /** "tab" for the view switcher; filters stay plain buttons. */ + readonly role?: "tab" | "button"; + readonly className?: string; +}) { + const compact = props.size === "compact"; + return ( + + option.value === props.selected), + ) * + 100) / + props.options.length + }%`, + }} + /> + {props.options.map((option) => { + const active = option.value === props.selected; + return ( + props.onSelect(option.value)} + className={cn( + "flex-1 items-center justify-center rounded-full", + compact ? "h-9" : "h-11", + )} + > + + {option.label} + + + ); + })} + + ); +} diff --git a/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx new file mode 100644 index 000000000000..7f5b69ed224a --- /dev/null +++ b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx @@ -0,0 +1,268 @@ +import { + Button, + DatePicker, + Host, + HStack, + Menu, + Picker, + Popover, + Spacer, + Text, + VStack, +} from "@expo/ui/swift-ui"; +import { + background, + buttonStyle, + datePickerStyle, + font, + foregroundStyle, + frame, + padding, + pickerStyle, + presentationBackground, + shapes, + tag, +} from "@expo/ui/swift-ui/modifiers"; +import { + localSnoozeDate, + localSnoozeTime, + resolveCustomSnooze, + type CustomSnoozeInput, +} from "@t3tools/client-runtime/state/thread-settled"; +import { useState } from "react"; +import { Modal, Pressable, ScrollView, View } from "react-native"; +import { AppText } from "../../components/AppText"; +import { SegmentedControl } from "../../components/SegmentedControl"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; + +const durationAmounts = Array.from({ length: 99 }, (_, index) => index + 1); +const modes = [ + { value: "date", label: "Date and time" }, + { value: "duration", label: "Duration" }, +] as const; +const units = [ + { value: "minutes", label: "Minutes" }, + { value: "hours", label: "Hours" }, + { value: "days", label: "Days" }, +] as const; + +export function CustomSnoozeSheet(props: { + readonly onClose: () => void; + readonly onSnooze: (snoozedUntil: string) => void; +}) { + const [mode, setMode] = useState("date"); + const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000)); + const [amount, setAmount] = useState(2); + const [amountOpen, setAmountOpen] = useState(false); + const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours"); + const [error, setError] = useState(null); + const { themeVariables: colors, themeAppearance, appearance } = useAppearancePreferences(); + const updateDate = (value: Date) => { + setDate(value); + setError(null); + }; + + const submit = () => { + const input: CustomSnoozeInput = + mode === "date" + ? { mode, date: localSnoozeDate(date), time: localSnoozeTime(date) } + : { mode, amount: String(amount), unit }; + const snoozedUntil = resolveCustomSnooze(input, new Date()); + if (!snoozedUntil) { + setError( + mode === "date" ? "Choose a date and time in the future." : "Enter a positive duration.", + ); + return; + } + props.onSnooze(snoozedUntil); + props.onClose(); + }; + + return ( + + + + + + Cancel + + + Custom snooze + + + Snooze + + + { + setMode(value); + setError(null); + }} + role="tab" + /> + + + {mode === "date" ? "Until" : "Snooze for"} + + {mode === "date" ? ( + <> + + + + ) : ( + <> + + + + + + + { + setAmount(value); + setError(null); + }} + modifiers={[pickerStyle("wheel"), frame({ height: 160 })]} + > + {durationAmounts.map((value) => ( + + {String(value)} + + ))} + + + + + + + + + + + + + ); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cc3487b595ed..6b0f3d7c11ef 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { requestCustomSnooze } from "./CustomSnoozeDialog"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; import { useAtomValue } from "@effect/atom-react"; @@ -439,7 +440,7 @@ function SidebarThreadTooltip({ function SnoozePopoverButton(props: { open: boolean; onOpenChange: (open: boolean) => void; - onSnooze: (preset: SnoozePreset) => void; + onSnooze: (preset: Pick) => void; timestampFormat: TimestampFormat; }) { const { open, onOpenChange, onSnooze, timestampFormat } = props; @@ -489,6 +490,19 @@ function SnoozePopoverButton(props: { ))} +
+ ); @@ -999,7 +1013,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; onSettle: (threadRef: ScopedThreadRef) => void; onUnsettle: (threadRef: ScopedThreadRef) => void; - onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; + onSnooze: (threadRef: ScopedThreadRef, preset: Pick) => void; onUnsnooze: (threadRef: ScopedThreadRef) => void; onUnpin: (threadRef: ScopedThreadRef) => void; onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; @@ -1333,7 +1347,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { [onUnpin, threadRef], ); const handleSnoozePreset = useCallback( - (preset: SnoozePreset) => { + (preset: Pick) => { onSnooze(threadRef, preset); }, [onSnooze, threadRef], @@ -3644,7 +3658,7 @@ export default function Sidebar() { const performSnooze = useCallback( async ( threadRef: ScopedThreadRef, - preset: SnoozePreset, + preset: Pick, opts: { coSnoozingKeys?: ReadonlySet } = {}, ) => { const threadKey = scopedThreadKey(threadRef); @@ -3678,7 +3692,7 @@ export default function Sidebar() { const attemptSnooze = useCallback( ( threadRef: ScopedThreadRef, - preset: SnoozePreset, + preset: Pick, opts: { coSnoozingKeys?: ReadonlySet } = {}, ) => { void (async () => { @@ -3774,10 +3788,13 @@ export default function Sidebar() { { id: "snooze", label: `Snooze (${count})`, - children: snoozePresets.map((preset) => ({ - id: `snooze:${preset.id}`, - label: `${preset.label} (${preset.whenLabel})`, - })), + children: [ + ...snoozePresets.map((preset) => ({ + id: `snooze:${preset.id}`, + label: `${preset.label} (${preset.whenLabel})`, + })), + { id: "snooze:custom", label: "Custom…", separatorBefore: true }, + ], }, ] : []), @@ -3790,9 +3807,10 @@ export default function Sidebar() { ); if (clicked._tag === "Failure") return; if (clicked.value?.startsWith("snooze:")) { - const preset = snoozePresets.find( - (candidate) => `snooze:${candidate.id}` === clicked.value, - ); + const preset = + clicked.value === "snooze:custom" + ? await requestCustomSnooze() + : snoozePresets.find((candidate) => `snooze:${candidate.id}` === clicked.value); if (preset) { // Post-snooze navigation must skip threads snoozing in this same // batch — they are all leaving the card block together. @@ -4019,9 +4037,10 @@ export default function Sidebar() { ); if (clicked._tag === "Failure") return; if (clicked.value?.startsWith("snooze:")) { - const preset = snoozePresets.find( - (candidate) => `snooze:${candidate.id}` === clicked.value, - ); + const preset = + clicked.value === "snooze:custom" + ? await requestCustomSnooze() + : snoozePresets.find((candidate) => `snooze:${candidate.id}` === clicked.value); if (preset) attemptSnooze(threadRef, preset); return; } diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 1bdd04693759..783453ac0082 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -67,7 +67,7 @@ describe("buildThreadActionMenuItems", () => { (item) => item.id === "snooze", ); expect(snooze?.disabled).toBe(true); - expect(snooze?.children?.map((child) => child.id)).toEqual(["snooze:hour"]); + expect(snooze?.children?.map((child) => child.id)).toEqual(["snooze:hour", "snooze:custom"]); }); it("disables title regeneration while one is in flight", () => { diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index 5ba266f7709d..35b14ec44397 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -88,10 +88,13 @@ export function buildThreadActionMenuItems( label: "Snooze", icon: "clock", disabled: !state.canSnoozeNow, - children: state.snoozePresets.map((preset) => ({ - id: `snooze:${preset.id}` as const, - label: `${preset.label} (${preset.whenLabel})`, - })), + children: [ + ...state.snoozePresets.map((preset) => ({ + id: `snooze:${preset.id}` as const, + label: `${preset.label} (${preset.whenLabel})`, + })), + { id: "snooze:custom" as const, label: "Custom…", separatorBefore: true }, + ], }, ] : []), diff --git a/apps/web/src/components/ui/calendar.tsx b/apps/web/src/components/ui/calendar.tsx new file mode 100644 index 000000000000..8488fd9ac6d6 --- /dev/null +++ b/apps/web/src/components/ui/calendar.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { DayPicker } from "@daypicker/react"; +import { ChevronLeftIcon, ChevronRightIcon, ChevronsUpDownIcon } from "lucide-react"; +import type * as React from "react"; +import { cn } from "~/lib/utils"; + +const buttonClassNames = + "relative flex size-(--cell-size) text-base sm:text-sm items-center justify-center rounded-lg text-foreground not-in-data-selected:hover:bg-accent disabled:pointer-events-none disabled:opacity-64 [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"; + +const defaultComponents = { + Chevron: ({ + className, + orientation, + ...props + }: { + className?: string; + orientation?: "left" | "right" | "up" | "down"; + }): React.ReactElement => { + if (orientation === "left") { + return ( +